From d7533375deb21a5678b2e2e4504e3f74ae221ff8 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 10 Oct 2026 12:49:29 -0500 Subject: [PATCH 01/12] docs(repo): add contributor guide and release history Record the published 0.1.0 release and advance the unreleased development version. Preserve the contributor guide created during repository recovery. Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- AGENTS.md | 43 +++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 26 ++++++++++++++++++++++++++ Directory.Build.props | 2 +- 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33d1946 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +AppKit provides shared .NET 10 foundations for Windows apps. `AppKit.slnx` groups four packages in `src/`: core services, Velopack updates, WinUI adapters, and WPF adapters. Keep testable logic in core or updates; UI packages contain code-built views and framework adapters. + +`tests/` contains unit and UI tests. `samples/` contains runnable WinUI and WPF apps; shared code and brand assets live in `samples/Shared/`. `tools/BrandTool/` generates icons, `build/` holds the coverage gate, and `docs/` contains ADRs, release instructions, and the threat model. + +## Build, Test, and Development Commands + +Use Windows 10 1809+ and the .NET 10 SDK selected by `global.json`. Run from the repository root: + +```powershell +# Restore the locked dependencies and build with zero warnings. +dotnet restore AppKit.slnx -p:Platform=x64 --locked-mode +dotnet build AppKit.slnx -p:Platform=x64 -warnaserror +# Run unit tests and enforce coverage. +dotnet test tests/KofTwentyTwo.AppKit.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults +./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages KofTwentyTwo.AppKit,KofTwentyTwo.AppKit.Updates +# Drive both built samples; requires an interactive desktop. +dotnet test tests/KofTwentyTwo.AppKit.UiTests +# Verify formatting and run the WPF sample locally. +dotnet format AppKit.slnx --verify-no-changes --severity warn --no-restore +dotnet run --project samples/AppKit.Sample.Wpf -p:Platform=x64 +# Produce release packages. +dotnet pack AppKit.slnx -c Release -p:Platform=x64 -o artifacts/nuget +``` + +## Coding Style & Naming Conventions + +Follow `.editorconfig`: three-space indentation, LF endings, Allman braces, file-scoped namespaces, and `if(` spacing. YAML uses two spaces. Use PascalCase types/members, `I`-prefixed interfaces, `_camelCase` private fields, and `s_camelCase` private static fields. Use `var` only when the type is apparent. Include type/method header comments and three blank lines between members. Builds enforce style and Meziantou analyzers; check linked sample files for conflict markers after formatting. + +## Testing Guidelines + +Use xUnit, Coverlet for coverage, and FlaUI/UIA3 for UI automation. Name tests `Method_Scenario_Expectation`. Core and updates must retain 100% line coverage. Add regression tests for fixes; exercise shell features in both samples and keep WinUI/WPF behavior aligned. + +## Commit & Pull Request Guidelines + +Recent history uses `chore: ...`; follow Conventional Commits (`fix(updates): ...`). Branch from `main` as `/`. Sign and sign off commits with `git commit -S -s`. Target PRs at `main` with a Conventional Commit title for squash merging. Explain what changed, why, linked issues, and validation; include screenshots for UI changes. Justify dependencies, disclose substantial AI assistance, update relevant docs, and resolve checks/review threads. See `CONTRIBUTING.md` for the full checklist. + +## Dependencies & Security + +Manage versions in `Directory.Packages.props` and regenerate affected `packages.lock.json` files. Never commit secrets or personal data. Update `docs/security/threat-model.md` for attack-surface changes; follow `SECURITY.md` for private vulnerability reports. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cba328f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +Release versions follow [Semantic Versioning](https://semver.org/). All four AppKit +packages release together. Unreleased changes are listed before tagging; published +versions retain their original package contents. + +## 0.1.0 — 2026-10-10 + +Initial public release for .NET 10 Windows desktop applications: + +- Core package: app identity and paths, build version, settings persistence and repair, + activity logs, credential storage, credits, and user interaction abstractions. +- Updates package: Velopack startup, GitHub release updates, and a shared update + coordinator with errors returned to the caller. +- WinUI and WPF packages: code-built splash, About, and log views; framework adapters + for dialogs, theming, window behavior, and crash handling. +- Runnable WinUI/WPF samples, unit and UI automation tests, full core/update line + coverage, and enforced build, formatting, dependency, and security checks. +- GitHub release assets include NuGet/symbol packages, per-package SBOMs, SHA-256 + checksums, and signed build provenance. NuGet publishing uses trusted publishing. + +This is the first release, so there is no earlier AppKit version to upgrade from. +The UI packages and Windows credential backend require Windows; see README for +package-specific target frameworks and setup. + +[GitHub release](https://github.com/KofTwentyTwo/AppKit/releases/tag/v0.1.0) diff --git a/Directory.Build.props b/Directory.Build.props index 25b5fe8..c21a4f7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -54,7 +54,7 @@ -p:Version=, which overrides both parts. SourceLink appends the git commit as "+" on AssemblyInformationalVersion. --> - 0.1.0 + 0.1.1 dev -- 2.54.0 From 4cea46bb1dbb3a0d38011fafef0b2443c6d0e53d Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 10 Oct 2026 12:49:46 -0500 Subject: [PATCH 02/12] test: exercise generated inputs and foreground sample menus Add four FsCheck properties with 500 cases each for settings and log input handling. Foreground the intended UI sample before invoking its menu so mouse fallback reaches the correct window. Normalize dependency lock file endings. Validation: 119 unit tests and 12 UI tests passed; core and updates retain 100% line coverage. Full solution build passed with zero warnings. Refs: #14, #20 Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- Directory.Packages.props | 1 + .../AppKit.Sample.WinUI/packages.lock.json | 2 +- samples/AppKit.Sample.Wpf/packages.lock.json | 2 +- .../packages.lock.json | 2 +- .../packages.lock.json | 2 +- .../packages.lock.json | 2 +- src/KofTwentyTwo.AppKit/packages.lock.json | 2 +- .../InputProperties.cs | 115 ++++++++++++++++++ .../KofTwentyTwo.AppKit.Tests.csproj | 1 + .../packages.lock.json | 26 +++- .../KofTwentyTwo.AppKit.UiTests/AppSession.cs | 4 +- .../packages.lock.json | 2 +- tools/BrandTool/packages.lock.json | 2 +- 13 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 tests/KofTwentyTwo.AppKit.Tests/InputProperties.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index b870b05..aa1e9ca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,6 +21,7 @@ + diff --git a/samples/AppKit.Sample.WinUI/packages.lock.json b/samples/AppKit.Sample.WinUI/packages.lock.json index 17e4c8b..f9232ee 100644 --- a/samples/AppKit.Sample.WinUI/packages.lock.json +++ b/samples/AppKit.Sample.WinUI/packages.lock.json @@ -243,4 +243,4 @@ } } } -} \ No newline at end of file +} diff --git a/samples/AppKit.Sample.Wpf/packages.lock.json b/samples/AppKit.Sample.Wpf/packages.lock.json index eb15c3e..72c2adc 100644 --- a/samples/AppKit.Sample.Wpf/packages.lock.json +++ b/samples/AppKit.Sample.Wpf/packages.lock.json @@ -33,4 +33,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/KofTwentyTwo.AppKit.Updates/packages.lock.json b/src/KofTwentyTwo.AppKit.Updates/packages.lock.json index b926b1e..ee0320b 100644 --- a/src/KofTwentyTwo.AppKit.Updates/packages.lock.json +++ b/src/KofTwentyTwo.AppKit.Updates/packages.lock.json @@ -19,4 +19,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/KofTwentyTwo.AppKit.WinUI/packages.lock.json b/src/KofTwentyTwo.AppKit.WinUI/packages.lock.json index f65d9e7..b1d87d1 100644 --- a/src/KofTwentyTwo.AppKit.WinUI/packages.lock.json +++ b/src/KofTwentyTwo.AppKit.WinUI/packages.lock.json @@ -163,4 +163,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/KofTwentyTwo.AppKit.Wpf/packages.lock.json b/src/KofTwentyTwo.AppKit.Wpf/packages.lock.json index 60c076b..851539f 100644 --- a/src/KofTwentyTwo.AppKit.Wpf/packages.lock.json +++ b/src/KofTwentyTwo.AppKit.Wpf/packages.lock.json @@ -26,4 +26,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/KofTwentyTwo.AppKit/packages.lock.json b/src/KofTwentyTwo.AppKit/packages.lock.json index d635df9..8a38241 100644 --- a/src/KofTwentyTwo.AppKit/packages.lock.json +++ b/src/KofTwentyTwo.AppKit/packages.lock.json @@ -10,4 +10,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/KofTwentyTwo.AppKit.Tests/InputProperties.cs b/tests/KofTwentyTwo.AppKit.Tests/InputProperties.cs new file mode 100644 index 0000000..a9a22a4 --- /dev/null +++ b/tests/KofTwentyTwo.AppKit.Tests/InputProperties.cs @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Text.Json; +using FsCheck.Xunit; +using KofTwentyTwo.AppKit.Logging; +using KofTwentyTwo.AppKit.Settings; + + +namespace KofTwentyTwo.AppKit.Tests; + +/// Generates hostile settings and log inputs; FsCheck shrinks and reports a replay seed on failure. +public sealed class InputProperties +{ + /// Repair always produces a valid theme and bounded splash, without changing startup choices. + [Property(MaxTest = 500, EndSize = 256)] + public void Sanitize_ArbitraryValues_IsValidAndIdempotent(string? theme, int splash, bool showSplash, bool checkUpdates) + { + int[] durations = [int.MinValue, int.MaxValue, 0, splash]; + foreach(int duration in durations) + { + var settings = new ShellSettings + { + Theme = theme!, + SplashMilliseconds = duration, + ShowSplashScreen = showSplash, + CheckForUpdatesOnStartup = checkUpdates, + }; + settings.Sanitize(); + AssertValid(settings); + string repairedTheme = settings.Theme; + int repairedDuration = settings.SplashMilliseconds; + settings.Sanitize(); + Assert.Equal(repairedTheme, settings.Theme); + Assert.Equal(repairedDuration, settings.SplashMilliseconds); + Assert.Equal(showSplash, settings.ShowSplashScreen); + Assert.Equal(checkUpdates, settings.CheckForUpdatesOnStartup); + } + } + + + + /// Arbitrary bytes, including broken UTF-8 and JSON, cannot stop loading preferences. + [Property(MaxTest = 500, EndSize = 256)] + public void Load_ArbitraryBytes_ReturnsUsableSettings(byte[] bytes) + { + using var temporary = new TempDirectory(); + string path = temporary.File("settings.json"); + File.WriteAllBytes(path, bytes); + var store = new SettingsStore(path, TestSettingsContext.Default.SampleSettings); + SampleSettings loaded = store.Load(); + AssertValid(loaded); + Assert.InRange(loaded.Concurrency, 1, 64); + } + + + + /// Valid JSON with arbitrary values is repaired, and repaired preferences survive a save/load cycle. + [Property(MaxTest = 500, EndSize = 256)] + public void Load_ArbitraryJsonValues_RepairsAndRoundTrips(string? theme, int splash, int concurrency, bool showSplash, bool checkUpdates) + { + using var temporary = new TempDirectory(); + string path = temporary.File("settings.json"); + var settings = new SampleSettings + { + Theme = theme!, + SplashMilliseconds = unchecked(splash * 1_000_003), + Concurrency = concurrency, + ShowSplashScreen = showSplash, + CheckForUpdatesOnStartup = checkUpdates, + }; + File.WriteAllText(path, JsonSerializer.Serialize(settings, TestSettingsContext.Default.SampleSettings)); + var store = new SettingsStore(path, TestSettingsContext.Default.SampleSettings); + SampleSettings loaded = store.Load(); + AssertValid(loaded); + Assert.InRange(loaded.Concurrency, 1, 64); + Assert.Equal(showSplash, loaded.ShowSplashScreen); + Assert.Equal(checkUpdates, loaded.CheckForUpdatesOnStartup); + Assert.True(store.Save(loaded)); + SampleSettings roundTripped = store.Load(); + Assert.Equal(loaded.Theme, roundTripped.Theme); + Assert.Equal(loaded.SplashMilliseconds, roundTripped.SplashMilliseconds); + Assert.Equal(loaded.Concurrency, roundTripped.Concurrency); + Assert.Equal(showSplash, roundTripped.ShowSplashScreen); + Assert.Equal(checkUpdates, roundTripped.CheckForUpdatesOnStartup); + Assert.False(File.Exists(path + ".tmp")); + } + + + + /// Malformed log bytes still yield the correct bounded tail, in original order. + [Property(MaxTest = 500, EndSize = 256)] + public void Read_ArbitraryLogBytes_ReturnsTheBoundedTail(byte[] bytes, byte limit) + { + using var temporary = new TempDirectory(); + string path = temporary.File("input.log"); + File.WriteAllBytes(path, bytes); + int maxLines = limit + 1; + LogSnapshot snapshot = LogTail.Read(path, maxLines); + Assert.InRange(snapshot.Lines.Count, 0, maxLines); + Assert.Equal(bytes.LongLength, snapshot.Length); + Assert.Equal(File.ReadAllLines(path).TakeLast(maxLines), snapshot.Lines, StringComparer.Ordinal); + } + + + + /// Checks the public settings contract independently of the repair implementation. + private static void AssertValid(ShellSettings settings) + { + Assert.Contains(settings.Theme, new[] { "System", "Light", "Dark" }, StringComparer.Ordinal); + Assert.InRange(settings.SplashMilliseconds, ShellSettings.MinSplashMilliseconds, ShellSettings.MaxSplashMilliseconds); + } +} diff --git a/tests/KofTwentyTwo.AppKit.Tests/KofTwentyTwo.AppKit.Tests.csproj b/tests/KofTwentyTwo.AppKit.Tests/KofTwentyTwo.AppKit.Tests.csproj index 83dd8d4..47260ec 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/KofTwentyTwo.AppKit.Tests.csproj +++ b/tests/KofTwentyTwo.AppKit.Tests/KofTwentyTwo.AppKit.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/KofTwentyTwo.AppKit.Tests/packages.lock.json b/tests/KofTwentyTwo.AppKit.Tests/packages.lock.json index de79af2..763bfce 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/packages.lock.json +++ b/tests/KofTwentyTwo.AppKit.Tests/packages.lock.json @@ -8,6 +8,17 @@ "resolved": "10.1.0", "contentHash": "SlYCIHnBYxINSUpR75SiM5JEmnx11pqT+Ac/C/fUqTTA0dLPl9eYzmFXKKuxmQhL3ZdTQl4PBD3YdAxAauaKLQ==" }, + "FsCheck.Xunit": { + "type": "Direct", + "requested": "[3.4.0, )", + "resolved": "3.4.0", + "contentHash": "AkrVSuLzu1KqTFPAJHMOzZf68MgnvGNFIblytfiZpRSAY+SFS7n0GdrhL005gLnUzeWdWs85rO1nx1uzmRSmSQ==", + "dependencies": { + "FSharp.Core": "5.0.2", + "FsCheck": "[3.4.0]", + "xunit.extensibility.execution": "[2.4.1, 3.0.0)" + } + }, "Meziantou.Analyzer": { "type": "Direct", "requested": "[3.0.296, )", @@ -41,6 +52,19 @@ "resolved": "4.0.0", "contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag==" }, + "FsCheck": { + "type": "Transitive", + "resolved": "3.4.0", + "contentHash": "wkWR1DORrKd46W6MEhmS4muo/0kgrtUV/AGW1ohMWemy7WukcRH8T3JjkDpn/daOHk0yVqf0CBvScU17Ab//PA==", + "dependencies": { + "FSharp.Core": "5.0.2" + } + }, + "FSharp.Core": { + "type": "Transitive", + "resolved": "5.0.2", + "contentHash": "DpcajqENgb3VXaHw1FKfVS+TWwEzck1PCajqLwBAVrtd1lfxd7T8JISVZBdV1mX9+2g48+tIgpNaVJWObdMrBw==" + }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "18.10.1", @@ -117,4 +141,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs b/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs index 5b41f4b..8ff5d90 100644 --- a/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs +++ b/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs @@ -148,12 +148,14 @@ public sealed class AppSession : IDisposable /// /// Opens the top-level menu named and invokes the item - /// . A freshly started app can swallow the first + /// . Foregrounds this app so mouse fallback cannot + /// land in another sample's window. A freshly started app can swallow the first /// expand (its flyout opens before the menu is ready), so the menu is re-opened /// until the item appears. /// public void InvokeMenuItem(string menuName, string itemAutomationId) { + MainWindow.SetForeground(); AutomationElement menu = WaitFor( () => MainWindow.FindFirstDescendant(cf => cf.ByControlType(ControlType.MenuItem).And(cf.ByName(menuName))), $"menu '{menuName}'"); diff --git a/tests/KofTwentyTwo.AppKit.UiTests/packages.lock.json b/tests/KofTwentyTwo.AppKit.UiTests/packages.lock.json index 547c595..9dbe0be 100644 --- a/tests/KofTwentyTwo.AppKit.UiTests/packages.lock.json +++ b/tests/KofTwentyTwo.AppKit.UiTests/packages.lock.json @@ -187,4 +187,4 @@ } } } -} \ No newline at end of file +} diff --git a/tools/BrandTool/packages.lock.json b/tools/BrandTool/packages.lock.json index 655d5df..6352287 100644 --- a/tools/BrandTool/packages.lock.json +++ b/tools/BrandTool/packages.lock.json @@ -13,4 +13,4 @@ } } } -} \ No newline at end of file +} -- 2.54.0 From 36886cbaae8810bf9c9a3c85867a2cee51d0ba3d Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 10 Oct 2026 12:50:05 -0500 Subject: [PATCH 03/12] ci: enforce release, script, and review quality gates Pin reusable workflows to published standards 0.1.2. Require tagged commits on main, rerun the main quality and security gates before release, and fail preflight when NuGet publishing configuration is missing. Add pinned pre-commit hooks, JavaScript lint and dependency verification, PowerShell analysis and coverage-gate regression tests, and a trusted current-commit review-record check. Document the single-maintainer limitations and the unfinished OpenSSF assessment without claiming certification. Validation: seven Node and four Pester tests passed, lint and workflow checks passed, and 83 npm registry signatures plus 20 attestations verified. Full AppKit build, unit coverage and UI gates passed. Live check enforcement and owner attestations remain tracked follow-up work. Refs: #4, #6, #7, #8, #13, #15, #18 Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- .github/PULL_REQUEST_TEMPLATE.md | 5 + .github/dependabot.yml | 13 + .github/scripts/review-gate.cjs | 74 ++ .github/scripts/review-gate.test.cjs | 144 +++ .github/workflows/ci.yml | 5 +- .github/workflows/codeql.yml | 7 +- .github/workflows/pr-gates.yml | 16 +- .github/workflows/release.yml | 67 +- .github/workflows/review.yml | 36 + .github/workflows/scripts.yml | 66 ++ .github/workflows/security.yml | 5 +- .gitignore | 1 + .pre-commit-config.yaml | 68 ++ CLAUDE.md | 10 +- CONTRIBUTING.md | 24 + PSScriptAnalyzerSettings.psd1 | 56 ++ README.md | 7 +- build/Assert-Coverage.Tests.ps1 | 74 ++ build/Assert-Coverage.ps1 | 69 +- docs/RELEASING.md | 20 +- docs/security/openssf-best-practices.md | 61 ++ docs/security/scorecard.md | 71 ++ docs/security/threat-model.md | 12 +- eslint.config.mjs | 53 ++ package-lock.json | 1074 +++++++++++++++++++++++ package.json | 18 + 26 files changed, 2005 insertions(+), 51 deletions(-) create mode 100644 .github/scripts/review-gate.cjs create mode 100644 .github/scripts/review-gate.test.cjs create mode 100644 .github/workflows/review.yml create mode 100644 .github/workflows/scripts.yml create mode 100644 .pre-commit-config.yaml create mode 100644 PSScriptAnalyzerSettings.psd1 create mode 100644 build/Assert-Coverage.Tests.ps1 create mode 100644 docs/security/openssf-best-practices.md create mode 100644 docs/security/scorecard.md create mode 100644 eslint.config.mjs create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ce95f83..46b0895 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,3 +21,8 @@ - [ ] No secrets, credentials, or personal data - [ ] New dependencies are justified below, or none were added - [ ] Substantial AI assistance, if any, is noted here +- [ ] Automated review recorded for the current head commit; maintainer read it and resolved or answered every finding + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 70ef038..50c91ce 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,19 @@ # updates are not delayed by the cooldown. version: 2 updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + cooldown: + default-days: 7 + groups: + tooling: + patterns: ["*"] + commit-message: + prefix: build + # Always: keeps SHA-pinned actions and reusable workflows current (K22-CI-11). - package-ecosystem: github-actions directory: / diff --git a/.github/scripts/review-gate.cjs b/.github/scripts/review-gate.cjs new file mode 100644 index 0000000..b43b20f --- /dev/null +++ b/.github/scripts/review-gate.cjs @@ -0,0 +1,74 @@ +// Copyright (c) 2026 James Maes (KofTwentyTwo) +// SPDX-License-Identifier: MIT + +"use strict"; + +const acknowledgement = "Maintainer acknowledgement: I read the review and resolved or answered every finding."; + +/** Accepts only the latest maintainer-owned record for the current commit. */ +function evaluateReviewRecord(comments, sha, maintainer) +{ + const marker = ``; + const records = comments.filter(comment => + comment.user?.login === maintainer + && comment.user?.type === "User" + && typeof comment.body === "string" + && comment.body.includes(marker)); + records.sort((left, right) => + String(right.updated_at).localeCompare(String(left.updated_at)) || right.id - left.id); + const record = records[0]; + if(!record) + { + return { passed: false, reason: "No maintainer-owned automated review record exists for the current PR commit." }; + } + const lines = record.body.split(/\r?\n/).map(line => line.trim()); + const field = name => + { + const values = lines.filter(line => line.startsWith(`${name}:`)); + return values.length === 1 ? values[0].slice(name.length + 1).trim() : null; + }; + const passed = lines.includes(marker) + && ["Codex", "Claude", "Copilot"].includes(field("Tool")) + && ["none", "resolved"].includes(field("Findings")) + && (field("Summary")?.length ?? 0) >= 20 + && lines.includes(acknowledgement); + return { passed, reason: passed ? "Current commit reviewed and acknowledged by the maintainer." + : "The latest review record is incomplete or still has unresolved findings." }; +} + + + +/** Publishes a check on the PR head without ever fetching or executing its code. */ +async function run({ github, context, core }) +{ + const { owner, repo } = context.repo; + const pullNumber = context.payload.pull_request?.number ?? context.payload.issue?.number; + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber }); + if(pull.state !== "open" || pull.base.ref !== "main") + { + core.info("Only open PRs targeting main require a review record."); + return; + } + const sha = pull.head.sha; + // Start failed: an API error must never leave an apparently successful gate. + const { data: check } = await github.rest.checks.create({ + owner, repo, name: "review / automated", head_sha: sha, + status: "completed", conclusion: "failure", + output: { title: "Automated review required", summary: "Waiting for validation of the current commit review record." }, + }); + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pullNumber, per_page: 100, + }); + const result = evaluateReviewRecord(comments, sha, "KofTwentyTwo"); + await github.rest.checks.update({ + owner, repo, check_run_id: check.id, + status: "completed", conclusion: result.passed ? "success" : "failure", + output: { title: result.passed ? "Automated review recorded" : "Automated review required", summary: result.reason }, + }); + if(!result.passed) + { + core.setFailed(result.reason); + } +} + +module.exports = { evaluateReviewRecord, run }; diff --git a/.github/scripts/review-gate.test.cjs b/.github/scripts/review-gate.test.cjs new file mode 100644 index 0000000..cc4b477 --- /dev/null +++ b/.github/scripts/review-gate.test.cjs @@ -0,0 +1,144 @@ +// Copyright (c) 2026 James Maes (KofTwentyTwo) +// SPDX-License-Identifier: MIT + +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { evaluateReviewRecord, run } = require("./review-gate.cjs"); +const sha = "a".repeat(40); + +/** A maintainer-recorded review of the current commit. */ +function record(overrides = {}) +{ + return { + id: 1, updated_at: "2026-10-10T12:00:00Z", user: { login: "KofTwentyTwo", type: "User" }, + body: `\nTool: Codex\nFindings: none\n` + + "Summary: Reviewed input validation, workflow permissions, and regression coverage.\n" + + "Maintainer acknowledgement: I read the review and resolved or answered every finding.", + ...overrides, + }; +} + +test("accepts a current complete record from the maintainer", () => +{ + assert.equal(evaluateReviewRecord([record()], sha, "KofTwentyTwo").passed, true); +}); + +test("rejects stale commits, outsiders, bots, missing acknowledgement and unresolved findings", () => +{ + const cases = [ + [], + [record({ body: record().body.replace(sha, "b".repeat(40)) })], + [record({ user: { login: "contributor", type: "User" } })], + [record({ user: { login: "KofTwentyTwo", type: "Bot" } })], + [record({ body: record().body.replace("Maintainer acknowledgement:", "Pending acknowledgement:") })], + [record({ body: record().body.replace("Findings: none", "Findings: unresolved") })], + [record({ body: record().body.replace("Summary:", "No summary:") })], + [record({ body: record().body + "\nFindings: unresolved" })], + [record({ body: record().body + "\nTool: Unknown" })], + [record({ body: record().body.replace("Tool: Codex", "Tool: Unknown") })], + [record({ body: record().body.replace("Summary: Reviewed input validation, workflow permissions, and regression coverage.", "Summary: Too short.") })], + ]; + for(const comments of cases) + { + assert.equal(evaluateReviewRecord(comments, sha, "KofTwentyTwo").passed, false); + } +}); + +test("a newer unresolved record overrides an older passing record", () => +{ + const latest = record({ id: 2, updated_at: "2026-10-10T13:00:00Z", body: record().body.replace("Findings: none", "Findings: unresolved") }); + assert.equal(evaluateReviewRecord([record(), latest], sha, "KofTwentyTwo").passed, false); +}); + +test("an edited record supersedes the previous review", () => +{ + const updated = record({ updated_at: "2026-10-10T14:00:00Z", body: record().body.replace("Findings: none", "Findings: resolved") }); + const previous = record({ id: 2, updated_at: "2026-10-10T13:00:00Z", body: record().body.replace("Findings: none", "Findings: unresolved") }); + assert.equal(evaluateReviewRecord([previous, updated], sha, "KofTwentyTwo").passed, true); +}); + +test("publishes to the live PR head and paginates review comments", async () => +{ + const writes = []; + const github = { + rest: { + pulls: { get: async () => ({ data: { state: "open", base: { ref: "main" }, head: { sha } } }) }, + issues: { listComments: "comments" }, + checks: { + create: async input => + { + writes.push(input); return { data: { id: 9 } }; + }, + update: async input => + { + writes.push(input); + }, + }, + }, + paginate: async (method, input) => + { + assert.equal(method, "comments"); + assert.equal(input.issue_number, 7); + return [record()]; + }, + }; + await run({ github, context: { repo: { owner: "KofTwentyTwo", repo: "AppKit" }, payload: { issue: { number: 7 } } }, + core: { setFailed: assert.fail, info: () => + {} } }); + assert.equal(writes[0].head_sha, sha); + assert.equal(writes[0].conclusion, "failure"); + assert.equal(writes[1].check_run_id, 9); + assert.equal(writes[1].conclusion, "success"); +}); + +test("an API failure leaves the current head check failed", async () => +{ + const writes = []; + const github = { + rest: { + pulls: { get: async () => ({ data: { state: "open", base: { ref: "main" }, head: { sha } } }) }, + issues: { listComments: "comments" }, + checks: { + create: async input => + { + writes.push(input); return { data: { id: 9 } }; + }, + update: async input => + { + writes.push(input); + }, + }, + }, + paginate: async () => + { + throw new Error("API unavailable"); + }, + }; + await assert.rejects(run({ github, + context: { repo: { owner: "KofTwentyTwo", repo: "AppKit" }, payload: { issue: { number: 7 } } }, + core: { setFailed: assert.fail, info: () => + {} }, + }), /API unavailable/); + assert.equal(writes.length, 1); + assert.equal(writes[0].head_sha, sha); + assert.equal(writes[0].conclusion, "failure"); +}); + +test("ignores closed PRs and PRs targeting another branch", async () => +{ + for(const pull of [{ state: "closed", base: { ref: "main" } }, { state: "open", base: { ref: "other" } }]) + { + let ignored = false; + await run({ + github: { rest: { pulls: { get: async () => ({ data: pull }) } } }, + context: { repo: { owner: "KofTwentyTwo", repo: "AppKit" }, payload: { issue: { number: 7 } } }, + core: { info: () => + { + ignored = true; + }, setFailed: assert.fail }, + }); + assert.equal(ignored, true); + } +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5684dc0..f417967 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,16 +7,17 @@ on: pull_request: push: branches: [main] + workflow_call: permissions: {} concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: ci: - uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release) + uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2 permissions: contents: read # check out, build, and test; nothing is written with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 85be0ae..a81d096 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,21 +8,22 @@ on: branches: [main] schedule: - cron: "41 6 * * 1" + workflow_call: permissions: {} concurrency: - group: codeql-${{ github.ref }} + group: codeql-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: codeql: - uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release) + uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2 permissions: contents: read # check out the code security-events: write # upload CodeQL results to code scanning with: - languages: '["csharp", "actions"]' + languages: '["csharp", "actions", "javascript-typescript"]' build-mode: manual build-command: dotnet build AppKit.slnx -p:Platform=x64 runs-on: windows-latest diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml index fb813c3..2b3fd16 100644 --- a/.github/workflows/pr-gates.yml +++ b/.github/workflows/pr-gates.yml @@ -13,8 +13,22 @@ concurrency: cancel-in-progress: true jobs: + review-tests: + name: review / tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test review record validation + run: node --test .github/scripts/review-gate.test.cjs + pr: - uses: KofTwentyTwo/standards/.github/workflows/pr.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release) + uses: KofTwentyTwo/standards/.github/workflows/pr.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2 permissions: contents: read # read the commits being merged pull-requests: read # read the pull request title and dependency diff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9291aa2..60cd630 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,8 +28,72 @@ concurrency: cancel-in-progress: false jobs: + verify: + name: verify + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout main and tag history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Require the tagged commit to be on main + shell: bash + run: | + set -euo pipefail + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + - name: Require NuGet publishing configuration + env: + NUGET_USER: ${{ vars.NUGET_USER }} + run: test -n "$NUGET_USER" + + gates: + needs: verify + uses: ./.github/workflows/ci.yml + permissions: + contents: read + + security: + needs: verify + uses: ./.github/workflows/security.yml + permissions: + contents: read + security-events: write + + codeql: + needs: verify + uses: ./.github/workflows/codeql.yml + permissions: + contents: read + security-events: write + + review-tests: + name: review / tests + needs: verify + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test review validation + run: node --test .github/scripts/review-gate.test.cjs + + scripts: + needs: verify + uses: ./.github/workflows/scripts.yml + permissions: + contents: read + release: - uses: KofTwentyTwo/standards/.github/workflows/release-nuget.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release) + needs: [verify, gates, security, codeql, review-tests, scripts] + uses: KofTwentyTwo/standards/.github/workflows/release-nuget.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2 permissions: contents: write # create and publish the GitHub release id-token: write # sign attestations @@ -44,7 +108,6 @@ jobs: publish: name: publish (nuget.org) needs: release - if: vars.NUGET_USER != '' runs-on: ubuntu-24.04 timeout-minutes: 15 environment: release diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml new file mode 100644 index 0000000..3361c1b --- /dev/null +++ b/.github/workflows/review.yml @@ -0,0 +1,36 @@ +# Single-maintainer automated review record (K22-SDLC-12 / EX-0001). +# Privileged events execute only code from the trusted workflow commit, never a PR head. +name: review + +on: + issue_comment: + types: [created, edited, deleted] + +permissions: {} + +concurrency: + group: review-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + record: + if: github.event.issue.pull_request + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + issues: read + checks: write # publish the required check on the live PR head + steps: + - name: Checkout trusted gate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - name: Verify the current commit review record + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { run } = require('./.github/scripts/review-gate.cjs'); + await run({ github, context, core }); diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml new file mode 100644 index 0000000..56975c4 --- /dev/null +++ b/.github/workflows/scripts.yml @@ -0,0 +1,66 @@ +# K22-CODE-TS / K22-CODE-SH: repository tooling is checked before merges and releases. +name: scripts + +on: + pull_request: + push: + branches: [main] + workflow_call: + +permissions: {} + +jobs: + javascript: + name: javascript + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + - name: Install locked development tools without lifecycle scripts + run: npm ci --ignore-scripts + - name: Lint JavaScript + run: npm run lint + - name: Test review validation + run: npm test + - name: Verify dependency signatures + run: npm audit signatures + + powershell: + name: powershell + runs-on: windows-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install pinned analyzer and check scripts + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force + Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 + Invoke-ScriptAnalyzer -Path build -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit + - name: Verify coverage gate rejection paths + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force + Import-Module Pester -RequiredVersion 5.7.1 + $testConfiguration = New-PesterConfiguration + $testConfiguration.Run.Path = 'build/Assert-Coverage.Tests.ps1' + $testConfiguration.Run.Exit = $true + $testConfiguration.TestResult.Enabled = $true + $testConfiguration.TestResult.OutputPath = 'TestResults/coverage-gate.xml' + Invoke-Pester -Configuration $testConfiguration diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 79d936c..36f0a5a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -10,16 +10,17 @@ on: schedule: - cron: "23 6 * * 1" workflow_dispatch: + workflow_call: permissions: {} concurrency: - group: security-${{ github.ref }} + group: security-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: security: - uses: KofTwentyTwo/standards/.github/workflows/security.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release) + uses: KofTwentyTwo/standards/.github/workflows/security.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2 permissions: contents: read # check out the code and its history security-events: write # upload Trivy and zizmor SARIF to code scanning diff --git a/.gitignore b/.gitignore index 22e151b..0af82fe 100644 --- a/.gitignore +++ b/.gitignore @@ -292,6 +292,7 @@ FakesAssemblies/ # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ +testResults.xml # Visual Studio 6 build log *.plg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7de635c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,68 @@ +# KofTwentyTwo pre-commit configuration. Works with pre-commit and prek. +# Copy to the repository root as .pre-commit-config.yaml, then install both hook +# types once per clone: +# +# pre-commit install --hook-type pre-commit --hook-type commit-msg +# +# Every external hook is pinned to a full commit SHA with the release in a comment +# (`pre-commit autoupdate --freeze` keeps that format). The hooks are the local half +# of the CI gates in standards/ci-cd.md; CI runs the same tools again. +default_install_hook_types: [pre-commit, commit-msg] +default_stages: [pre-commit] + +repos: + # File hygiene. + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 + hooks: + - id: check-added-large-files + args: [--maxkb=1024] + - id: check-case-conflict + - id: check-merge-conflict + - id: check-yaml + - id: check-json + - id: check-toml + - id: detect-private-key + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: no-commit-to-branch + args: [--branch, main] + + # Secrets (K22-CI-20): staged changes only, so it stays fast. + - repo: https://github.com/gitleaks/gitleaks + rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # frozen: v8.30.1 + hooks: + - id: gitleaks + + # Workflow security (K22-CI-23). + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: fa412071e4f5d44d44f9e365f4676f9df92456a2 # frozen: v1.30.1 + hooks: + - id: zizmor + args: [--min-severity, medium] + - repo: https://github.com/rhysd/actionlint + rev: 914e7df21a07ef503a81201c76d2b11c789d3fca # frozen: v1.7.12 + hooks: + - id: actionlint + + # Commit message: Conventional Commits header (K22-SDLC commit rules). + - repo: https://github.com/compilerla/conventional-pre-commit + rev: 91ab4bf57e58b32adf1a122681f6ebe164d081c8 # frozen: v4.4.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [--strict, build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] + + # Commit message: DCO sign-off (`git commit -s`). pygrep with --negate fails when + # no line of the message matches. + - repo: local + hooks: + - id: dco-sign-off + name: DCO sign-off present (git commit -s) + language: pygrep + entry: '^Signed-off-by: .+ <.+@.+>$' + args: [--negate] + stages: [commit-msg] diff --git a/CLAUDE.md b/CLAUDE.md index b9e36c2..e7035f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,13 +14,18 @@ dotnet test tests/KofTwentyTwo.AppKit.Tests --settings coverage.runsettings --co ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages KofTwentyTwo.AppKit,KofTwentyTwo.AppKit.Updates dotnet test tests/KofTwentyTwo.AppKit.UiTests # launches both sample apps on the desktop dotnet format AppKit.slnx --verify-no-changes --severity warn --no-restore +npm ci --ignore-scripts +npm run lint +npm test +Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 +Invoke-ScriptAnalyzer -Path build -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit pwsh ../standards/tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly ``` ## Rules - GitHub Flow: topic branch `/` → PR to `main` → squash merge. No `dev` branch. - Conventional Commit titles, `git commit -s` (DCO), signed commits. Agents never merge, + Conventional Commit titles, `git commit -S -s` (signature and DCO). Agents never merge, tag, release, or change repository settings. - Zero warnings; 100% line coverage for `KofTwentyTwo.AppKit` and `KofTwentyTwo.AppKit.Updates`. - Testable logic lives in those two packages; WinUI/WPF packages hold views and adapters only. @@ -35,3 +40,6 @@ pwsh ../standards/tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly solution only after checking it did not write merge-conflict markers into them. - Attack-surface changes update `docs/security/threat-model.md`; expensive-to-reverse decisions get an ADR in `docs/adr/`. +- Significant AI contributions include a model-naming `Co-Authored-By` trailer and + disclosure in the PR. The maintainer acknowledges the current-commit automated + review before merging; follow `docs/security/scorecard.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad73a79..27b92cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,16 @@ dotnet build AppKit.slnx -p:Platform=x64 -warnaserror dotnet test tests/KofTwentyTwo.AppKit.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages KofTwentyTwo.AppKit,KofTwentyTwo.AppKit.Updates +# Generated input tests (also included in the unit suite); Node 24 for repository tools +dotnet test tests/KofTwentyTwo.AppKit.Tests --filter FullyQualifiedName~InputProperties +npm ci --ignore-scripts +npm run lint +npm test +Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 +Invoke-ScriptAnalyzer -Path build -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit +Import-Module Pester -RequiredVersion 5.7.1 +Invoke-Pester -Path build/Assert-Coverage.Tests.ps1 -CI + # End-to-end UI tests: FlaUI launches both sample apps (needs an interactive desktop) dotnet test tests/KofTwentyTwo.AppKit.UiTests @@ -60,6 +70,9 @@ pre-commit install --hook-type pre-commit --hook-type commit-msg message on `main`. 4. Every required check must pass, and every review thread must be resolved, before the pull request merges. +5. With one maintainer, record an automated review of the **current head commit** and + acknowledge its findings before merging. Follow the [review record instructions](docs/security/scorecard.md#recording-an-automated-review). + CodeQL is also required. A new commit invalidates the previous review record. ## What makes a contribution acceptable @@ -89,6 +102,15 @@ pre-commit install --hook-type pre-commit --hook-type commit-msg | `ci / build-test` | Locked restore, zero-warning build, unit tests, 100% coverage gate, packages pack | | `ci / format` | `dotnet format --verify-no-changes --severity warn` | | `ci / ui-tests` | FlaUI end-to-end tests of both sample apps | +| `review / tests` | Review gate rejects stale, incomplete, and unauthorized records | +| `scripts / javascript`, `scripts / powershell` | Shared lint rules, review tests, and tooling dependency signatures | +| `review / automated` | Current commit has a maintainer-acknowledged automated review (enable after bootstrap) | + +The four FsCheck properties in `InputProperties.cs` each generate 500 cases during +the normal unit run. They exercise settings repair, malformed bytes, JSON round trips, +and bounded log tails. A failure reports a shrunk counterexample and replay seed; +reproduce it with the failing property's `Replay` option, then retain a fixed regression +test. FsCheck is a test-only dependency; it is not shipped in AppKit packages. ## Sign-off (Developer Certificate of Origin) @@ -109,6 +131,8 @@ branch, run `git rebase --signoff main` and force-push the branch. AI coding tools are welcome. You remain the author: you must understand, test, and be able to explain every line you submit, and you sign it off as your own. Note substantial AI assistance in the pull request description. +Include a `Co-Authored-By:` trailer naming the model for significant AI contributions +(K22-AGENT-40 / K22-AI-02). ## License diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..cf3f495 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,56 @@ +# KofTwentyTwo PowerShell analysis (K22-CODE-SH-05). CI runs: +# Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit +# and `Invoke-Formatter` with the same settings for layout. PowerShell supports the +# Kingsrook layout fully: next-line (Allman) braces and 3-space indentation. +@{ + Severity = @('Error', 'Warning', 'Information') + IncludeDefaultRules = $true + ExcludeRules = @() + Rules = @{ + PSPlaceOpenBrace = @{ + Enable = $true + OnSameLine = $false + NewLineAfter = $true + IgnoreOneLineBlock = $true + } + PSPlaceCloseBrace = @{ + Enable = $true + NewLineAfter = $true + IgnoreOneLineBlock = $true + NoEmptyLineBefore = $true + } + PSUseConsistentIndentation = @{ + Enable = $true + IndentationSize = 3 + Kind = 'space' + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + } + PSUseConsistentWhitespace = @{ + Enable = $true + CheckInnerBrace = $true + # Kingsrook writes if(...) with no space; the rule cannot require that, so it + # is not checked either way. + CheckOpenParen = $false + CheckOpenBrace = $false + # Kingsrook aligns the `=` of consecutive assignments (PSAlignAssignmentStatement + # below); CheckOperator would then flag every aligned line, so it is off. + CheckOperator = $false + CheckPipe = $true + CheckSeparator = $true + CheckParameter = $true + } + PSAlignAssignmentStatement = @{ + Enable = $true + CheckHashtable = $true + } + PSUseCorrectCasing = @{ Enable = $true } + PSAvoidUsingCmdletAliases = @{ Enable = $true } + PSAvoidUsingWriteHost = @{ Enable = $true } + PSProvideCommentHelp = @{ + Enable = $true + ExportedOnly = $false + BlockComment = $true + Placement = 'before' + } + } +} diff --git a/README.md b/README.md index 6df03ae..f3a2b18 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ shipped in a 1.0 release first. | --- | --- | | **Tier** | Product ([scope](https://github.com/KofTwentyTwo/standards/blob/main/policies/README.md#scope)) | | **Standards** | Adopting [KofTwentyTwo standards](https://github.com/KofTwentyTwo/standards) (pre-v1.0); this README will say *Conforms to KofTwentyTwo standards v1.0* once the conformance checker passes | -| **Latest release** | [Releases](https://github.com/KofTwentyTwo/AppKit/releases/latest) (none yet) | +| **Latest release** | [v0.1.0](https://github.com/KofTwentyTwo/AppKit/releases/tag/v0.1.0) | ## Packages @@ -149,6 +149,11 @@ Only the latest released minor version receives fixes, including security fixes; Report vulnerabilities privately; see [SECURITY.md](SECURITY.md). The attack surface is described in the [threat model](docs/security/threat-model.md). +Repository-practice findings and the single-maintainer review policy are documented +in [the Scorecard assessment](docs/security/scorecard.md). The +[OpenSSF Best Practices worksheet](docs/security/openssf-best-practices.md) tracks +evidence and outstanding owner attestations; no certification is claimed yet. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). Releases: [docs/RELEASING.md](docs/RELEASING.md). diff --git a/build/Assert-Coverage.Tests.ps1 b/build/Assert-Coverage.Tests.ps1 new file mode 100644 index 0000000..8b4bf68 --- /dev/null +++ b/build/Assert-Coverage.Tests.ps1 @@ -0,0 +1,74 @@ +#Requires -Version 7.4 +# Copyright (c) 2026 James Maes (KofTwentyTwo) +# SPDX-License-Identifier: MIT + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Continue Pester commands so their Allman scriptblocks stay part of the call. +BeforeAll ` +{ + $script:CoverageGate = Join-Path $PSScriptRoot 'Assert-Coverage.ps1' + + <# + .SYNOPSIS + Writes a synthetic report so tests can exercise release-gate exit codes. + #> + function Write-CoverageReport + { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Directory, + [Parameter(Mandatory)] [ValidateRange(0, 1)] [double] $Rate + ) + + $rateText = $Rate.ToString([Globalization.CultureInfo]::InvariantCulture) + $secondLineHits = if ($Rate -eq 1) { 1 } else { 0 } + $report = @" + + + +"@ + Set-Content -LiteralPath (Join-Path $Directory 'coverage.cobertura.xml') -Value $report + } +} + +Describe 'Required coverage gate' ` +{ + BeforeEach ` + { + $script:ReportsDirectory = Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $script:ReportsDirectory | Out-Null + } + + It 'rejects a run that produced no report' ` + { + $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n" + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'No coverage.cobertura.xml' + } + + It 'rejects an absent required package' ` + { + Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1 + $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Missing 2>&1) -join "`n" + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'Missing.*not found' + } + + It 'rejects coverage below the required percentage' ` + { + Write-CoverageReport -Directory $script:ReportsDirectory -Rate 0.5 + $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n" + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'Coverage gate FAILED' + } + + It 'accepts full coverage of the required package' ` + { + Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1 + $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n" + $LASTEXITCODE | Should -Be 0 + $output | Should -Match 'Coverage gate passed' + } +} diff --git a/build/Assert-Coverage.ps1 b/build/Assert-Coverage.ps1 index 1f7924c..b1950a8 100644 --- a/build/Assert-Coverage.ps1 +++ b/build/Assert-Coverage.ps1 @@ -1,3 +1,4 @@ +#Requires -Version 7.4 <# .SYNOPSIS Fails when any named assembly is below the required line coverage. @@ -13,51 +14,59 @@ #> [CmdletBinding()] param( - [Parameter(Mandatory)] [string] $ResultsDirectory, - [Parameter(Mandatory)] [string[]] $Packages, - [ValidateRange(0, 100)] [double] $MinimumPercent = 100 + [Parameter(Mandatory)] [string] $ResultsDirectory, + [Parameter(Mandatory)] [string[]] $Packages, + [ValidateRange(0, 100)] [double] $MinimumPercent = 100 ) +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # `pwsh -File` passes "A,B" as one string; accept that as well as a real array. $Packages = @($Packages | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) $files = @(Get-ChildItem -Path $ResultsDirectory -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue) -if ($files.Count -eq 0) { - Write-Error "No coverage.cobertura.xml found under '$ResultsDirectory'. Did the coverage collector run?" -ErrorAction Continue - exit 1 +if ($files.Count -eq 0) +{ + Write-Error "No coverage.cobertura.xml found under '$ResultsDirectory'. Did the coverage collector run?" -ErrorAction Continue + exit 1 } $reports = foreach ($file in $files) { [xml](Get-Content -LiteralPath $file.FullName -Raw) } $required = $MinimumPercent / 100 $failed = $false -foreach ($name in $Packages) { - $rates = foreach ($doc in $reports) { - $package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name } - if ($package) { [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture) } - } - if (-not $rates) { - Write-Error "Package '$name' not found in any coverage report." -ErrorAction Continue - $failed = $true - continue - } +foreach ($name in $Packages) +{ + $rates = foreach ($doc in $reports) + { + $package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name } + if ($package) { [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture) } + } + if (-not $rates) + { + Write-Error "Package '$name' not found in any coverage report." -ErrorAction Continue + $failed = $true + continue + } - $best = ($rates | Measure-Object -Maximum).Maximum - Write-Host ("{0} line coverage: {1}% (required {2}%)" -f $name, [math]::Round($best * 100, 2), $MinimumPercent) - if ($best -lt $required) { - foreach ($doc in $reports) { - $package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name } - foreach ($class in @($package.classes.class)) { - $missed = @($class.lines.line) | Where-Object { $_.hits -eq '0' } | ForEach-Object { $_.number } - if ($missed) { Write-Host " uncovered: $($class.filename) lines $($missed -join ', ')" } - } - } - Write-Error "Coverage gate FAILED: $name is below $MinimumPercent%." -ErrorAction Continue - $failed = $true - } + $best = ($rates | Measure-Object -Maximum).Maximum + Write-Output ("{0} line coverage: {1}% (required {2}%)" -f $name, [math]::Round($best * 100, 2), $MinimumPercent) + if ($best -lt $required) + { + foreach ($doc in $reports) + { + $package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name } + foreach ($class in @($package.classes.class)) + { + $missed = @($class.lines.line) | Where-Object { $_.hits -eq '0' } | ForEach-Object { $_.number } + if ($missed) { Write-Output " uncovered: $($class.filename) lines $($missed -join ', ')" } + } + } + Write-Error "Coverage gate FAILED: $name is below $MinimumPercent%." -ErrorAction Continue + $failed = $true + } } if ($failed) { exit 1 } -Write-Host "Coverage gate passed." +Write-Output "Coverage gate passed." diff --git a/docs/RELEASING.md b/docs/RELEASING.md index ce6e5e0..6700cff 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -6,9 +6,8 @@ Pushing a protected `v*` tag on `main` runs [`.github/workflows/release.yml`](.. which calls the shared `release-nuget.yml` workflow in KofTwentyTwo/standards. All four packages always release together under one version. -> **Status:** the shared workflows are pinned to a commit on KofTwentyTwo/standards -> `main` while the standards are pre-release; they move to a release tag's commit once -> KofTwentyTwo/standards publishes `v1.0.0` (Dependabot proposes the update). +The shared workflows are pinned to the published standards `v0.1.2` commit. +Dependabot proposes updates; review the workflow changes before moving the pin. ## Versioning @@ -30,10 +29,12 @@ are `-dev`. Bump the prefix right after each release, in a pull request. ## What a release does -The shared workflow (SLSA Build L3: the build definition lives in KofTwentyTwo/standards, -not here): +The caller first verifies that the tagged commit belongs to `main`, then reruns +build, unit tests, coverage, formatting, UI tests, security scans, CodeQL and script +checks. All must pass before publication. The shared workflow then builds the +release separately (SLSA Build L3: its build definition lives in KofTwentyTwo/standards): -1. Validates the tag and re-runs every gate that guards `main`: locked restore, +1. Validates strict SemVer and repeats locked restore, zero-warning build, unit tests, and the 100% coverage gate. 2. Packs the four packages and their symbol packages. 3. Generates a CycloneDX 1.6 SBOM per package (Syft, from `packages.lock.json`) and @@ -55,8 +56,8 @@ nuget.org through **trusted publishing** (OIDC; no API key is stored anywhere). - Workflow file: `release.yml` - Environment: `release` 2. **Repository variable** `NUGET_USER` (Settings → Secrets and variables → Actions → - Variables): your nuget.org profile name. Without it the `publish` job is skipped and - the release still succeeds on GitHub. + Variables): your nuget.org profile name. Missing configuration fails preflight + before a GitHub release is created. 3. **Environment** `release` (Settings → Environments) with a deployment rule that allows only tags matching `v*`. @@ -66,6 +67,9 @@ nuget.org through **trusted publishing** (OIDC; no API key is stored anywhere). `pwsh ../standards/tools/Test-RepoConformance.ps1 -Repository KofTwentyTwo/AppKit -LocalPath .` 2. For a minor or major release, open a release checklist issue from the standards template and complete it (security assessment, threat model review, scan results). + Update [CHANGELOG.md](../CHANGELOG.md) with user-facing changes, upgrade impact, + and advisory IDs for any publicly known vulnerability fixed. Include or link those + notes in the GitHub release; generated PR lists alone may omit important changes. 3. Tag `main` and push the tag: ```powershell diff --git a/docs/security/openssf-best-practices.md b/docs/security/openssf-best-practices.md new file mode 100644 index 0000000..ff1301e --- /dev/null +++ b/docs/security/openssf-best-practices.md @@ -0,0 +1,61 @@ +# OpenSSF Best Practices assessment + +Prepared 2026-10-10 for AppKit's first release. This is an evidence worksheet, not +a badge or completed certification. Use the current +[passing criteria](https://www.bestpractices.dev/en/criteria/0) when submitting. + +## Register the project + +The owner signs in at [bestpractices.dev](https://www.bestpractices.dev/en/projects/new) +and registers **AppKit** with repository and project URL +`https://github.com/KofTwentyTwo/AppKit`, language C#, and MIT license. No credential +belongs in this repository. After registration, keep the public project URL here +and add the site's generated badge to README. An in-progress entry is not a passing +badge; mark only evidenced criteria as met. + +## Evidence worksheet + +`Ready` means repository evidence is available after these changes merge. `Owner` +requires a truthful maintainer attestation or interpretation of applicability; +it must not be inferred from documentation alone. `Pending` needs more work. +Criterion names below match the passing-level form; check its details for each entry. + +| Criteria | Status | Evidence / next step | +| --- | --- | --- | +| `description_good`, `interact`, `documentation_basics`, `documentation_interface` | Ready | [README](../../README.md), package READMEs, XML API comments, runnable WinUI/WPF samples. | +| `contribution`, `contribution_requirements`, `english` | Ready | [CONTRIBUTING](../../CONTRIBUTING.md), PR template, English issues and documentation. | +| `floss_license`, `floss_license_osi`, `license_location` | Ready | Root MIT [LICENSE](../../LICENSE) and SPDX headers; NuGet manifests carry MIT. | +| `sites_https`, `discussion` | Ready | HTTPS GitHub repository, issues, PR discussions, and nuget.org downloads. | +| `maintained` | Ready | First release 2026-10-10, active maintainer and support policy. The badge criterion differs from Scorecard's 90-day heuristic. | +| `repo_public`, `repo_track`, `repo_interim`, `repo_distributed` | Ready | Public Git repository, signed history, topic branches and PRs between tagged releases. | +| `version_unique`, `version_semver`, `version_tags` | Ready | Signed `v0.1.0`, four versioned packages, [release procedure](../RELEASING.md). | +| `release_notes` | Pending | [CHANGELOG](../../CHANGELOG.md) describes the initial release. Publish it, then link/include those notes in the v0.1.0 GitHub release; existing generated notes mainly describe standards adoption. | +| `release_notes_vulns` | Owner | Confirm whether any publicly known vulnerability was fixed; select N/A only if there were none. Include advisory IDs in future affected releases. | +| `report_process`, `report_tracker`, `report_archive` | Ready | Public GitHub Issues, bug template, searchable issue responses. | +| `report_responses`, `enhancement_responses` | Owner | No 2–12 month reporting history exists yet. Apply the form's details honestly; maintain timely acknowledgements instead of inventing historical reports. | +| `vulnerability_report_process`, `vulnerability_report_private` | Ready | [SECURITY](../../SECURITY.md) links GitHub private vulnerability reporting. | +| `vulnerability_report_response` | Owner | Policy targets seven-day acknowledgement. Confirm actual report history; a target is not proof of every response time. | +| `build`, `build_common_tools` | Ready | .NET SDK and MSBuild; locked restore and zero-warning build documented and run in CI. | +| `build_floss_tools` | Owner | .NET/MSBuild are open source, but full WinUI builds require Windows SDK/platform tooling. Verify licenses and applicability; explain a justified SHOULD exception if necessary. | +| `test`, `test_invocation`, `test_most`, `test_continuous_integration` | Ready | xUnit/Coverlet/FlaUI, standard `dotnet test`, required CI, 100% core/update line coverage. UI coverage is separately scoped by ADR-0003. | +| `test_policy`, `tests_are_added`, `tests_documented_added` | Ready | CONTRIBUTING requires behavior/regression tests; recent source/tests and generated-input properties provide evidence. | +| `warnings`, `warnings_fixed`, `warnings_strict` | Ready | TreatWarningsAsErrors, .editorconfig, .NET and Meziantou analyzers, format gate; local solution build has zero warnings. | +| `know_secure_design`, `know_common_errors` | Owner | Read the criteria details and personally attest knowledge. [Threat model](threat-model.md) documents boundaries, hostile inputs, dependency risks and mitigations, but cannot attest a person's knowledge. | +| `crypto_published`, `crypto_call`, `crypto_working`, `crypto_weaknesses` | Owner | AppKit delegates secrets to Windows Credential Manager and delivery to HTTPS/Velopack. Review upstream algorithms/defaults; it implements no cryptographic algorithm. | +| `crypto_floss`, `crypto_keylength`, `crypto_pfs`, `crypto_random` | Owner | Clarify applicability of OS-managed DPAPI and TLS. The Windows credential backend is platform-specific; do not assert a FLOSS equivalent or configurable key lengths without evidence. | +| `crypto_password_storage` | Owner | AppKit stores host-provided secrets, not passwords to authenticate external users. Review the criterion and use N/A with that explanation if applicable. | +| `delivery_mitm`, `delivery_unsigned` | Ready | GitHub/NuGet HTTPS, signed tags, SHA256SUMS and verified build provenance. README documents attestation verification. | +| `vulnerabilities_fixed_60_days`, `vulnerabilities_critical_fixed` | Owner | Confirm all publicly known runtime vulnerabilities and their age; public alerts here concern process controls. Recheck advisories/dependency scans before submitting. | +| `no_leaked_credentials` | Owner | Secret scanning and read-only workflow tokens provide controls. Review scan results/history before attesting no valid credential leak. | +| `static_analysis`, `static_analysis_common_vulnerabilities`, `static_analysis_often` | Ready | Required CodeQL security-extended C#/Actions scans, .NET/Meziantou analyzers, security workflow on PRs/main and weekly. | +| `static_analysis_fixed` | Owner | Confirm no unresolved medium/high exploitable static findings. Record Scorecard process exceptions separately in [scorecard.md](scorecard.md). | +| `dynamic_analysis`, `dynamic_analysis_enable_assertions` | Ready | xUnit/FlaUI and four FsCheck properties with 500 generated cases each; assertions, shrinking, and replay seed on failure. | +| `dynamic_analysis_unsafe` | Owner | Managed C# production code includes native Windows interop and Velopack dependencies. Review whether this criterion applies to project-produced code before choosing N/A. | +| `dynamic_analysis_fixed` | Owner | Confirm findings and remediation history; a green test run alone cannot establish the absence of every vulnerability. | + +## Submission and verification + +Complete the owner rows, resolve pending release notes, and provide public evidence +URLs from merged `main` for the form's required explanations. After submitting, +record the public project ID and actual badge level here. Rerun Scorecard on `main` +and inspect alert #5. Do not disable the check or claim passing status from this draft. diff --git a/docs/security/scorecard.md b/docs/security/scorecard.md new file mode 100644 index 0000000..ce0889b --- /dev/null +++ b/docs/security/scorecard.md @@ -0,0 +1,71 @@ +# Scorecard findings and review controls + +AppKit retains the single-maintainer policy in +[K22-REPO-20](https://github.com/KofTwentyTwo/standards/blob/main/standards/repository.md) +and [K22-SDLC-12 / EX-0001](https://github.com/KofTwentyTwo/standards/blob/main/policies/sdlc.md). +OpenSSF Scorecard's independent human-review criteria exceed that policy. The +following findings are tracked separately from exploitable code vulnerabilities. + +## Findings assessed on 2026-10-10 + +Baseline: `main` at `839fb72bec464223133b405df85bb01608d25360`. +Sources: GitHub alert instances, the live `protect-main` ruleset, PR #2 review history, +repository files, and [Scorecard check definitions](https://github.com/ossf/scorecard/blob/main/docs/checks.md). + +| Alert | Evidence and disposition | Revisit when | +| --- | --- | --- | +| [#4 Code-Review](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/4) | No independent approval on either assessed changeset. Accepted single-maintainer limitation; AI reviews cannot satisfy Scorecard. Require an acknowledged automated review plus CodeQL instead. PR #2 had no automated review record in the inspected comments/reviews; fix the process for future PRs. | An independent maintainer joins; check review records on every PR. | +| [#3 Maintained](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/3) | Created 2026-10-09; not archived. Scorecard requires over 90 days of history. Continue maintenance; no artificial commits or scanner suppression. | 2027-01-08, after 90 days have elapsed. | +| [#1 Branch-Protection](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/1) | Main requires PRs, resolved threads, 11 passing checks, up-to-date branches, signed commits, linear history, and no force pushes/deletion or bypass actors. Zero human approvals and disabled CODEOWNERS/last-push approval match the one-maintainer exception. | An independent maintainer joins; enable one independent approval and last-push approval, then assess CODEOWNERS enforcement. | +| [#2 Fuzzing](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/2) | Add four real FsCheck properties to the existing CI unit suite. The pinned action uses Scorecard v5.5.0, whose [C# detector](https://github.com/ossf/scorecard/blob/v5.5.0/checks/raw/fuzzing.go) recognizes `using FsCheck.Xunit;`. Local tests do not close the remote alert; rerun Scorecard after merge and verify detection. | The implementation reaches main, or parsing/input contracts change. | +| [#5 CII-Best-Practices](https://github.com/KofTwentyTwo/AppKit/security/code-scanning/5) | No registered badge project. The [assessment](openssf-best-practices.md) prepares evidence and identifies owner attestations. Do not claim a badge until the public project entry exists. | The owner registers and completes the assessment. | + +No alert was dismissed or scanner setting weakened while preparing these changes. +If accepting the two review limitations in GitHub, use `won't fix` with the +single-maintainer exception and a link to this published document; do not label them +false positives. Any age-warning dismissal must say that maintenance cannot yet be +assessed and include the revisit date. Keep fuzzing and badge alerts open until verified. + +## Recording an automated review + +Run Codex, Claude, or Copilot against the PR diff and relevant source/tests at its +current head SHA. Resolve or answer every finding, rerun affected checks, and repeat +the review after changes. The maintainer must read the result. As `KofTwentyTwo`, post +a separate PR comment using the following format; replace placeholders with real evidence: + +```markdown + +Tool: Codex +Findings: none +Summary: Describe the actual review scope, outcome, and any resolved findings. +Maintainer acknowledgement: I read the review and resolved or answered every finding. +``` + +Use `Findings: resolved` after addressing findings, or `Findings: unresolved` to block +the check. Include findings and their resolution below the summary. The latest +maintainer-owned record for that SHA controls the result. Editing or deleting comments +reruns validation. Other contributors and bots cannot attest for the maintainer. + +The gate validates the record's author, commit, result, and acknowledgement. It +cannot prove the quality of the AI review or replace independent human review. +Keep each `Tool`, `Findings`, and `Summary` field unique; conflicting/duplicate fields +fail validation rather than allowing an old passing result to hide unresolved findings. + +## Enabling the required checks + +1. Review, test, and merge the bootstrap PR normally under the existing 11 checks; + record its automated review manually. The new comment workflow is unavailable + until it exists on the default branch. +2. On a subsequent PR, post a genuine review record and verify `review / automated` + is attached to the **PR head**, succeeds for its SHA, and fails for an incomplete + or unresolved record. `review / tests` must also pass. +3. Add both contexts to `protect-main`'s required checks with the GitHub Actions + integration, preserving all existing rules and the empty bypass list. +4. Verify a new commit cannot merge with the previous record. It has no passing + review check until a new record is posted; do not reuse the old SHA. + +The workflow runs only on PR comment events and checks out `github.workflow_sha`, +the trusted default-branch workflow commit. It never checks out a PR head, executes +comment text, uses a PAT, or shares caches/artifacts with untrusted code. Its write +permission is limited to publishing check runs. Adding another maintainer requires +updating the record-author policy and the tests as well as branch protections. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 4f9450c..b0a0a52 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -71,7 +71,7 @@ flowchart LR | T3 | Update flow | Denial of service | A failing or slow update check crashes or blocks the app | Medium | Medium | Every update member is guarded and returns errors as values; the check runs after startup | Mitigated | | T4 | Secrets | Information disclosure | A token leaks through logs, settings, or memory | Medium | High | Secrets only in the Credential Manager; never in settings or logs; the pinned plaintext buffer is cleared after writing | Mitigated | | T5 | settings.json | Tampering | A crafted settings file sets out-of-range values or crashes parsing | Low | Low | Source-generated JSON parsing inside a catch-all; values sanitized; failure means defaults | Mitigated | -| T6 | Log files | Tampering / denial of service | A huge or crafted log file hangs the log viewer | Low | Low | The viewer reads only the last 2,000 lines; the entry regex has a match timeout | Mitigated | +| T6 | Log files | Tampering / denial of service | A huge or crafted log file hangs the log viewer | Low | Low | The viewer retains the last 2,000 lines and the entry regex has a match timeout, but reading still scans the whole file; neither bound limits individual line size | Partially mitigated | | T7 | UI thread | Denial of service | An exception escaping an async handler kills the app | Medium | Medium | Crash net logs and handles dispatcher exceptions; one-dialog-at-a-time guard prevents the stowed-exception crash | Mitigated | | T8 | Data folder | Elevation of privilege | `_DATA_DIR` points the app at another location | Low | Low | The variable is set by the same user; the app gains no rights it did not have | Accepted | | T9 | Dependencies | Tampering | A compromised package in AppKit's graph | Low | High | Central versions, lock files with locked restore, dependency review, Trivy and OSV-Scanner in CI, Dependabot with a cooldown | Mitigated | @@ -88,6 +88,16 @@ flowchart LR ## 8. Review log +Generated-input regression coverage for T5 and T6 lives in +`tests/KofTwentyTwo.AppKit.Tests/InputProperties.cs`. Four FsCheck properties each +run 500 cases in the normal unit suite, checking repair invariants, arbitrary JSON +and UTF-8 bytes, persistence, and bounded log tails. These bounded tests do not +establish resilience against unlimited-size files or replace native dependency testing. + +Repository change controls and their single-maintainer limitation are assessed in +[scorecard.md](scorecard.md). The automated review-record check validates an +acknowledged review of the current PR commit; it does not replace human review. + | Date | Version | Reviewer | Changes | | --- | --- | --- | --- | | 2026-10-09 | 0.1.0-dev | James Maes | Initial model | diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b676f95 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 James Maes (KofTwentyTwo) +// SPDX-License-Identifier: MIT + +import js from "@eslint/js"; +import stylistic from "@stylistic/eslint-plugin"; + +// JavaScript-only repository tools use the shared K22-CODE-TS layout and lint +// rules. TypeScript type checking does not apply to these CommonJS scripts. +export default [ + { ignores: ["artifacts/**", "node_modules/**"] }, + js.configs.recommended, + { + files: [".github/scripts/*.cjs", "eslint.config.mjs"], + plugins: { "@stylistic": stylistic }, + rules: { + "@stylistic/indent": ["error", 3, { SwitchCase: 1 }], + "@stylistic/brace-style": ["error", "allman", { allowSingleLine: false }], + "@stylistic/keyword-spacing": ["error", { + before: true, + after: true, + overrides: { + if: { after: false }, + for: { after: false }, + while: { after: false }, + switch: { after: false }, + catch: { after: false }, + }, + }], + "@stylistic/no-multiple-empty-lines": ["error", { max: 3, maxBOF: 0, maxEOF: 0 }], + "@stylistic/lines-between-class-members": ["error", "always", { exceptAfterSingleLine: true }], + "@stylistic/semi": ["error", "always"], + "@stylistic/quotes": ["error", "double", { avoidEscape: true }], + "@stylistic/comma-dangle": ["error", "always-multiline"], + "@stylistic/eol-last": ["error", "always"], + "@stylistic/no-tabs": "error", + "@stylistic/no-trailing-spaces": "error", + "@stylistic/operator-linebreak": ["error", "before"], + "@stylistic/dot-location": ["error", "property"], + curly: ["error", "all"], + eqeqeq: ["error", "always"], + "no-console": "error", + "no-warning-comments": ["error", { terms: ["todo", "fixme"], location: "start" }], + camelcase: ["error", { properties: "never" }], + }, + }, + { + files: [".github/scripts/*.cjs"], + languageOptions: { + sourceType: "commonjs", + globals: { require: "readonly", module: "readonly" }, + }, + }, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..02ba372 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1074 @@ +{ + "name": "appkit-repository-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appkit-repository-tools", + "devDependencies": { + "@eslint/js": "10.0.1", + "@stylistic/eslint-plugin": "5.10.0", + "eslint": "10.12.0" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e8a48cb --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "appkit-repository-tools", + "private": true, + "description": "Lint and test the repository review gate; no shipped application dependencies.", + "engines": { + "node": ">=24.0.0" + }, + "scripts": { + "lint": "eslint .github/scripts eslint.config.mjs --max-warnings 0", + "test": "node --test .github/scripts/review-gate.test.cjs" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@stylistic/eslint-plugin": "5.10.0", + "eslint": "10.12.0" + }, + "packageManager": "npm@11.16.0" +} -- 2.54.0 From 8768281f69dc6d3781b47fde2327b60641c74bf3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 10 Oct 2026 12:58:47 -0500 Subject: [PATCH 04/12] fix(logging): bound log viewer reads and single-line allocations Seek directly to at most 1 MiB of trailing input plus a four-byte BOM header. Preserve UTF-8 and BOM-marked UTF-16/UTF-32 code-unit alignment, cap queue preallocation, and disclose omitted input in both framework log viewers. Keep existing read/display signatures available for binary compatibility. Validation: 130 unit tests and 12 sample UI tests pass; core and updates retain 100% line coverage. Zero-warning solution build and full formatting verification pass (workspace-loading warnings remain). Regression tests cover 128 MiB input, a 4 MiB single line, byte limits and all supported encodings. Refs: #18 Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 6 + docs/security/threat-model.md | 8 +- src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs | 2 +- src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs | 2 +- .../Logging/LogSnapshot.cs | 6 +- src/KofTwentyTwo.AppKit/Logging/LogTail.cs | 71 ++++++++++- .../KofTwentyTwo.AppKit.Tests/LoggingTests.cs | 113 ++++++++++++++++++ 7 files changed, 198 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cba328f..5e0bea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Release versions follow [Semantic Versioning](https://semver.org/). All four App packages release together. Unreleased changes are listed before tagging; published versions retain their original package contents. +## Unreleased + +- Bound each log-viewer refresh to 1 MiB of trailing input plus the encoding header, + including huge single lines. Preserve supported BOM encodings and show a notice + when older input is omitted. Existing two-argument log-reader calls remain compatible. + ## 0.1.0 — 2026-10-10 Initial public release for .NET 10 Windows desktop applications: diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index b0a0a52..c048f82 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -71,7 +71,7 @@ flowchart LR | T3 | Update flow | Denial of service | A failing or slow update check crashes or blocks the app | Medium | Medium | Every update member is guarded and returns errors as values; the check runs after startup | Mitigated | | T4 | Secrets | Information disclosure | A token leaks through logs, settings, or memory | Medium | High | Secrets only in the Credential Manager; never in settings or logs; the pinned plaintext buffer is cleared after writing | Mitigated | | T5 | settings.json | Tampering | A crafted settings file sets out-of-range values or crashes parsing | Low | Low | Source-generated JSON parsing inside a catch-all; values sanitized; failure means defaults | Mitigated | -| T6 | Log files | Tampering / denial of service | A huge or crafted log file hangs the log viewer | Low | Low | The viewer retains the last 2,000 lines and the entry regex has a match timeout, but reading still scans the whole file; neither bound limits individual line size | Partially mitigated | +| T6 | Log files | Tampering / denial of service | A huge or crafted log file hangs the log viewer | Low | Low | Each refresh reads at most the last 1 MiB plus a four-byte encoding header, retains at most 2,000 lines, and discloses truncation; the byte limit also bounds a single line and the entry regex has a match timeout | Mitigated | | T7 | UI thread | Denial of service | An exception escaping an async handler kills the app | Medium | Medium | Crash net logs and handles dispatcher exceptions; one-dialog-at-a-time guard prevents the stowed-exception crash | Mitigated | | T8 | Data folder | Elevation of privilege | `_DATA_DIR` points the app at another location | Low | Low | The variable is set by the same user; the app gains no rights it did not have | Accepted | | T9 | Dependencies | Tampering | A compromised package in AppKit's graph | Low | High | Central versions, lock files with locked restore, dependency review, Trivy and OSV-Scanner in CI, Dependabot with a cooldown | Mitigated | @@ -92,7 +92,11 @@ Generated-input regression coverage for T5 and T6 lives in `tests/KofTwentyTwo.AppKit.Tests/InputProperties.cs`. Four FsCheck properties each run 500 cases in the normal unit suite, checking repair invariants, arbitrary JSON and UTF-8 bytes, persistence, and bounded log tails. These bounded tests do not -establish resilience against unlimited-size files or replace native dependency testing. +replace native dependency testing. `LoggingTests.cs` additionally verifies a 128 MiB +file, a 4 MiB single line, smaller explicit byte budgets, empty input, and BOM-marked +UTF-8/UTF-16/UTF-32 tails in both byte orders. Refreshes seek directly to a bounded +window; they do not scan older content. A partial first visible line is identified +by the viewer's truncation notice. Repository change controls and their single-maintainer limitation are assessed in [scorecard.md](scorecard.md). The automated review-record check validates an diff --git a/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs b/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs index 28f94b3..50fd616 100644 --- a/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs +++ b/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs @@ -147,7 +147,7 @@ public sealed partial class LogWindow : Window LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath); _lastLength = snapshot.Length; bool errorsOnly = _errorsOnly.IsChecked == true; - _logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly); + _logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly, snapshot.IsTruncated); _scroll.UpdateLayout(); _scroll.ChangeView(null, _scroll.ScrollableHeight, null, disableAnimation: true); diff --git a/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs b/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs index 1750be9..8427178 100644 --- a/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs +++ b/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs @@ -135,7 +135,7 @@ public sealed class LogWindow : Window LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath); _lastLength = snapshot.Length; bool errorsOnly = _errorsOnly.IsChecked == true; - _logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly); + _logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly, snapshot.IsTruncated); _logText.ScrollToEnd(); } } diff --git a/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs b/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs index 17678e0..30ce5e6 100644 --- a/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs +++ b/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs @@ -8,4 +8,8 @@ namespace KofTwentyTwo.AppKit.Logging; /// The last lines of a log file and the file length they were read at. /// The tail, oldest first. /// File length in bytes when read; 0 when the file is absent. -public sealed record LogSnapshot(IReadOnlyList Lines, long Length); +public sealed record LogSnapshot(IReadOnlyList Lines, long Length) +{ + /// True when the input byte limit omitted a prefix of the file. + public bool IsTruncated { get; init; } +} diff --git a/src/KofTwentyTwo.AppKit/Logging/LogTail.cs b/src/KofTwentyTwo.AppKit/Logging/LogTail.cs index a3e4a9b..aad250c 100644 --- a/src/KofTwentyTwo.AppKit/Logging/LogTail.cs +++ b/src/KofTwentyTwo.AppKit/Logging/LogTail.cs @@ -3,6 +3,7 @@ * SPDX-License-Identifier: MIT */ +using System.Text; using System.Text.RegularExpressions; @@ -19,13 +20,31 @@ public static partial class LogTail + /// Maximum trailing input per read; also bounds a single decoded line. + public const int DefaultMaxBytes = 1024 * 1024; + + + /// /// The last lines of , opened /// shared so the logger can keep appending while it is read. /// public static LogSnapshot Read(string? path, int maxLines = DefaultMaxLines) + => Read(path, maxLines, DefaultMaxBytes); + + + + /// + /// Reads at most trailing bytes plus a four-byte encoding + /// header. UTF-8 and BOM-marked UTF-16/UTF-32 are supported. A skipped prefix may + /// leave a partial first line; identifies it. + /// The byte budget must be between four bytes and . + /// + public static LogSnapshot Read(string? path, int maxLines, int maxBytes) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLines); + ArgumentOutOfRangeException.ThrowIfLessThan(maxBytes, 4); + ArgumentOutOfRangeException.ThrowIfGreaterThan(maxBytes, DefaultMaxBytes); try { if(string.IsNullOrWhiteSpace(path) || !File.Exists(path)) @@ -33,11 +52,20 @@ public static partial class LogTail return new LogSnapshot([], 0); } - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: 1); long length = stream.Length; - using var reader = new StreamReader(stream); + Span header = stackalloc byte[4]; + int headerLength = stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false); + (Encoding encoding, int preamble, int width) = DetectEncoding(header[..headerLength]); + long start = Math.Max(preamble, length - maxBytes); + // Round forward to a code-unit boundary without exceeding the input budget. + start += (width - ((start - preamble) % width)) % width; + stream.Position = start; + byte[] bytes = new byte[(int)(length - start)]; + stream.ReadExactly(bytes); + using var reader = new StringReader(encoding.GetString(bytes)); - var tail = new Queue(maxLines); + var tail = new Queue(Math.Min(maxLines, DefaultMaxLines)); while(reader.ReadLine() is string line) { if(tail.Count == maxLines) @@ -46,7 +74,7 @@ public static partial class LogTail } tail.Enqueue(line); } - return new LogSnapshot([.. tail], length); + return new LogSnapshot([.. tail], length) { IsTruncated = start > preamble }; } catch(Exception ex) { @@ -56,6 +84,30 @@ public static partial class LogTail + /// Preserves the BOM detection supported by StreamReader, including UTF-32 before UTF-16. + private static (Encoding Encoding, int Preamble, int Width) DetectEncoding(ReadOnlySpan header) + { + if(header.StartsWith([0xFF, 0xFE, 0x00, 0x00])) + { + return (Encoding.UTF32, 4, 4); + } + if(header.StartsWith([0x00, 0x00, 0xFE, 0xFF])) + { + return (new UTF32Encoding(bigEndian: true, byteOrderMark: true), 4, 4); + } + if(header.StartsWith([0xFF, 0xFE])) + { + return (Encoding.Unicode, 2, 2); + } + if(header.StartsWith([0xFE, 0xFF])) + { + return (Encoding.BigEndianUnicode, 2, 2); + } + return header.StartsWith([0xEF, 0xBB, 0xBF]) ? (Encoding.UTF8, 3, 1) : (Encoding.UTF8, 0, 1); + } + + + /// /// Keeps [ERROR] entries including their continuation lines: exception text spans /// lines until the next timestamped entry starts. @@ -83,11 +135,20 @@ public static partial class LogTail /// The text a viewer shows for , with friendly empty states. public static string ToDisplayText(IReadOnlyList lines, bool errorsOnly) + => ToDisplayText(lines, errorsOnly, isTruncated: false); + + + + /// Shows when the input byte limit hides older entries or cuts the first visible line. + public static string ToDisplayText(IReadOnlyList lines, bool errorsOnly, bool isTruncated) { ArgumentNullException.ThrowIfNull(lines); - return lines.Count == 0 + string text = lines.Count == 0 ? (errorsOnly ? "No errors logged." : "No log entries yet.") : string.Join(Environment.NewLine, lines); + return isTruncated + ? "Showing a bounded log tail; older entries and the start of the first line may be omitted." + Environment.NewLine + text + : text; } diff --git a/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs b/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs index c0ecc6a..0ae0118 100644 --- a/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs +++ b/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs @@ -3,6 +3,7 @@ * SPDX-License-Identifier: MIT */ +using System.Text; using KofTwentyTwo.AppKit.Logging; @@ -206,6 +207,103 @@ public sealed class LogTailTests : IDisposable LogSnapshot snapshot = LogTail.Read(path, maxLines: 2); Assert.Equal(["3", "4"], snapshot.Lines); Assert.Equal(new FileInfo(path).Length, snapshot.Length); + Assert.False(snapshot.IsTruncated); + } + + + + /// Seeking to the bounded tail preserves the final entries of a huge sparse file. + [Fact] + public void Read_HugeFile_ReturnsOrderedTailWithinDefaultBudget() + { + string path = _temp.File("huge.log"); + const long Length = 128 * 1024 * 1024; + byte[] ending = Encoding.UTF8.GetBytes("\nolder\nlast one\nlast two\n"); + using(var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read)) + { + stream.SetLength(Length); + stream.Position = Length - ending.Length; + stream.Write(ending); + } + + LogSnapshot snapshot = LogTail.Read(path, maxLines: 2); + Assert.Equal(["last one", "last two"], snapshot.Lines); + Assert.Equal(Length, snapshot.Length); + Assert.True(snapshot.IsTruncated); + } + + + + /// The byte budget bounds a single line and avoids allocating a queue for an arbitrary line count. + [Fact] + public void Read_HugeSingleLine_BoundsTextAndQueueAllocation() + { + string path = _temp.File("one-line.log"); + const int Length = LogTail.DefaultMaxBytes * 4; + using(var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read)) + { + stream.SetLength(Length); + stream.Position = Length - 1; + stream.WriteByte((byte)'x'); + } + + LogSnapshot snapshot = LogTail.Read(path, maxLines: int.MaxValue); + string line = Assert.Single(snapshot.Lines); + Assert.Equal(LogTail.DefaultMaxBytes, line.Length); + Assert.EndsWith("x", line, StringComparison.Ordinal); + Assert.Equal(Length, snapshot.Length); + Assert.True(snapshot.IsTruncated); + Assert.Equal(["\0\0\0x"], LogTail.Read(path, maxLines: 1, maxBytes: 4).Lines); + } + + + + /// BOM detection and truncated code-unit alignment preserve UTF-8, UTF-16 and UTF-32 in both byte orders. + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public void Read_SupportedEncodings_PreservesTailAndAlignment(int format) + { + Encoding encoding = format switch + { + 0 => new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + 1 => new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), + 2 => Encoding.Unicode, + 3 => Encoding.BigEndianUnicode, + 4 => Encoding.UTF32, + _ => new UTF32Encoding(bigEndian: true, byteOrderMark: true), + }; + string path = _temp.File("encoded.log"); + const string Tail = "one π\ntwo λ\n"; + File.WriteAllText(path, "skip prefix\n" + Tail, encoding); + + LogSnapshot full = LogTail.Read(path); + Assert.Equal(File.ReadAllLines(path), full.Lines, StringComparer.Ordinal); + Assert.False(full.IsTruncated); + LogSnapshot bounded = LogTail.Read(path, maxLines: 2, maxBytes: encoding.GetByteCount(Tail) + 1); + Assert.Equal(["one π", "two λ"], bounded.Lines); + Assert.Equal(new FileInfo(path).Length, bounded.Length); + Assert.True(bounded.IsTruncated); + } + + + + /// Empty files and BOM-only files contain no phantom entries. + [Fact] + public void Read_EmptyOrPreambleOnly_IsEmpty() + { + string path = _temp.File("empty.log"); + File.WriteAllBytes(path, []); + Assert.Empty(LogTail.Read(path).Lines); + File.WriteAllBytes(path, Encoding.UTF32.GetPreamble()); + LogSnapshot snapshot = LogTail.Read(path); + Assert.Empty(snapshot.Lines); + Assert.Equal(4, snapshot.Length); + Assert.False(snapshot.IsTruncated); } @@ -229,6 +327,8 @@ public sealed class LogTailTests : IDisposable public void Read_RejectsNonPositiveLineCounts() { Assert.Throws(() => LogTail.Read("x", 0)); + Assert.Throws(() => LogTail.Read("x", 1, 3)); + Assert.Throws(() => LogTail.Read("x", 1, LogTail.DefaultMaxBytes + 1)); } @@ -264,6 +364,19 @@ public sealed class LogTailTests : IDisposable + /// Both viewer modes disclose when the byte limit omitted input, including a partial first line. + [Theory] + [InlineData(false, "tail")] + [InlineData(true, "No errors logged.")] + public void ToDisplayText_TruncatedInput_ShowsNotice(bool errorsOnly, string expected) + { + string text = LogTail.ToDisplayText(errorsOnly ? [] : ["tail"], errorsOnly, isTruncated: true); + Assert.StartsWith("Showing a bounded log tail;", text, StringComparison.Ordinal); + Assert.EndsWith(expected, text, StringComparison.Ordinal); + } + + + /// NullArguments: throw. [Fact] public void NullArguments_Throw() -- 2.54.0 From 7f70c31517bf405c8c830d8a7c89afe04f912435 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 10 Oct 2026 13:07:45 -0500 Subject: [PATCH 05/12] feat(logging): preserve structured templates, fields, and scopes Adapt existing IActivityLog sinks to standard ILogger without adding interface requirements. Store original templates, rendered messages, typed JSON scalar fields, event identity and async scopes. Preserve full exception text, daily retention, failure containment, and thread-safe writes. Migrate update events, framework crash nets and samples to source-generated logging. Pin Microsoft.Extensions.Logging.Abstractions 10.0.12 (MIT) rather than implementing template parsing. Advance the unreleased minor to 0.2.0 and document legacy API compatibility and explicit scalar/JSON value policy. Validation: 146 unit tests, 12 sample UI tests, 100% core/updates coverage, zero-warning build, formatting and hooks passed. Dependency scan reported no known vulnerabilities. Starter migration and public rollout remain #5. Refs: #5, #8 Co-Authored-By: Codex (GPT-6) Signed-off-by: James Maes --- CHANGELOG.md | 7 +- Directory.Build.props | 2 +- Directory.Packages.props | 1 + README.md | 3 + coverage.runsettings | 2 +- docs/security/threat-model.md | 6 + samples/AppKit.Sample.WinUI/App.xaml.cs | 12 +- .../AppKit.Sample.WinUI/MainWindow.xaml.cs | 17 +- .../AppKit.Sample.WinUI/packages.lock.json | 25 +- samples/AppKit.Sample.Wpf/MainWindow.xaml.cs | 17 +- samples/AppKit.Sample.Wpf/Program.cs | 14 +- samples/AppKit.Sample.Wpf/packages.lock.json | 25 +- .../UpdateCoordinator.cs | 47 +++- .../packages.lock.json | 19 +- src/KofTwentyTwo.AppKit.WinUI/WinUIShell.cs | 11 +- .../packages.lock.json | 21 +- src/KofTwentyTwo.AppKit.Wpf/WpfShell.cs | 11 +- .../packages.lock.json | 21 +- .../KofTwentyTwo.AppKit.csproj | 1 + .../Logging/ActivityLogExtensions.cs | 25 ++ .../Logging/ActivityLogLogger.cs | 159 +++++++++++ .../Logging/IActivityLog.cs | 4 +- src/KofTwentyTwo.AppKit/README.md | 38 ++- src/KofTwentyTwo.AppKit/packages.lock.json | 14 + .../StructuredLoggingTests.cs | 266 ++++++++++++++++++ .../KofTwentyTwo.AppKit.Tests/UpdatesTests.cs | 27 +- .../packages.lock.json | 21 +- tools/BrandTool/packages.lock.json | 19 +- 28 files changed, 790 insertions(+), 45 deletions(-) create mode 100644 src/KofTwentyTwo.AppKit/Logging/ActivityLogExtensions.cs create mode 100644 src/KofTwentyTwo.AppKit/Logging/ActivityLogLogger.cs create mode 100644 tests/KofTwentyTwo.AppKit.Tests/StructuredLoggingTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0bea6..1a6953f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,16 @@ Release versions follow [Semantic Versioning](https://semver.org/). All four App packages release together. Unreleased changes are listed before tagging; published versions retain their original package contents. -## Unreleased +## Unreleased — 0.2.0 - Bound each log-viewer refresh to 1 MiB of trailing input plus the encoding header, including huge single lines. Preserve supported BOM encodings and show a notice when older input is omitted. Existing two-argument log-reader calls remain compatible. +- Add an `ILogger` adapter for activity logs with original message templates, typed + scalar fields, event IDs, and asynchronous scopes stored as JSON. Existing + `IActivityLog` methods and third-party implementations remain compatible. Foundation + update/crash events and samples use source-generated log methods. The official + `Microsoft.Extensions.Logging.Abstractions` package is a new runtime dependency. ## 0.1.0 — 2026-10-10 diff --git a/Directory.Build.props b/Directory.Build.props index c21a4f7..7cea129 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -54,7 +54,7 @@ -p:Version=, which overrides both parts. SourceLink appends the git commit as "+" on AssemblyInformationalVersion. --> - 0.1.1 + 0.2.0 dev diff --git a/Directory.Packages.props b/Directory.Packages.props index aa1e9ca..fcd2d1d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/README.md b/README.md index f3a2b18..f85ad01 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,9 @@ in [the Scorecard assessment](docs/security/scorecard.md). The [OpenSSF Best Practices worksheet](docs/security/openssf-best-practices.md) tracks evidence and outstanding owner attestations; no certification is claimed yet. +Unreleased 0.2.0 adds [structured logging](src/KofTwentyTwo.AppKit/README.md#structured-logging) +and bounded log reads; these capabilities are not part of the published 0.1.0 packages. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). Releases: [docs/RELEASING.md](docs/RELEASING.md). diff --git a/coverage.runsettings b/coverage.runsettings index de28395..bfd506b 100644 --- a/coverage.runsettings +++ b/coverage.runsettings @@ -1,7 +1,7 @@