From 059576fcd90d2ee5e165915a4b931f9f3af001b8 Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Wed, 12 Aug 2026 15:06:32 +0200 Subject: [PATCH 1/5] chore(harness): bootstrap the PR-B quality-gate coverage slice #1403 was filed about root lists; cross-lane work found two further defects on the same gate, and all three are now acceptance boxes. The PR gate never scans .llm/tools and skips entirely when nothing else changed, and its range is two-dot so a stale base enumerates other lanes' merged work. Baselines at this base: arch:check exit 0, quality:scan:repo exit 0. Refs #1403 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R1uTFgh4emCPxSs7m72Pqf --- .../slices/pr-b-1403/implement.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 .llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/implement.md diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/implement.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/implement.md new file mode 100644 index 0000000000..00da49741f --- /dev/null +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/implement.md @@ -0,0 +1,182 @@ +use harness + +# PR-B — #1403: make `quality:gate` informative, in all three of the ways it currently is not + +You are the **implementation agent** for the p0 of the 0.0.6 internals quality rail. The plan passed a +formal PLAN-EVAL (cycle 5, `PASS`); your job is to implement it, not to redesign it. + +Your orchestrator is a Claude Opus 5 high session in `/home/codex/repos/netscript-006-internals`. It holds +merge authority and owns the draft → ready flip. + +## SKILL + +- `netscript-harness` — run artifacts, slice discipline, commit trail. +- `netscript-doctrine` — `arch:check`'s role, archetypes, fitness gates. **Read before touching + `check-doctrine.ts`.** +- `netscript-tools` — scoped wrappers; what is a verdict and what is not. +- `netscript-pr` — branch/PR/label mechanics, closing keywords, the fenced `acceptance-evidence` block. +- `rtk` — prefix read-heavy `git`/`gh`/`grep`. + +## Identity + +| Field | Value | +| --- | --- | +| Worktree | `/home/codex/repos/ns006-qualitygate` | +| Branch | `fix/1403-quality-gate-coverage` | +| Base | `3c9dc1f39` (= `origin/main`) | +| Slice dir | `.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/` | +| Closes | #1403 | +| Route | Codex · gpt-5.6-sol · **low** | +| Plan | `plan-quality-rail.md` revision 4 (`PASS`), slices **B1–B3** in `worklog.md` § Design | + +Measured at your base: `deno task arch:check` exit **0**, `deno task quality:scan:repo` exit **0**. +Both green, so any red you produce is yours. + +## What is actually wrong — three independent defects, one gate + +#1403 was filed about **root lists**. Cross-lane work then found two more, and all three are now acceptance +boxes. A fix for one alone leaves a gate that looks covered and is not — which is the class this issue exists +to close, so do not stop at the first. + +### Defect 1 — the curated root list omits a package + +`deno.json`'s `arch:check` is `deps:check` plus **16** hand-listed `check-doctrine.ts --root` invocations in +one shell string. `packages/plugin-streams-core` is the only `plugin-*-core` package absent — an omission, +not a decision. + +### Defect 2 — the PR gate never scans `.llm/tools/**`, and skips entirely when nothing else changed + +`.github/workflows/code-quality.yml:36-42`: + +```bash +mapfile -t files < <(git diff --name-only --diff-filter=ACMR "$BASE" "$SHA" -- packages plugins) +args=(); for file in "${files[@]}"; do args+=(--changed-file "$file"); done +if ((${#args[@]})); then deno task quality:scan --pretty "${args[@]}"; fi +``` + +The pathspec is `-- packages plugins`, so a `.llm/tools/**` change can never enter the set; and +`if ((${#args[@]}))` means an empty set runs **no command** and reports success. Every PR in this rail — +including this one — is `.llm/tools`-only, so that step has been reporting success having executed nothing. + +### Defect 3 — the range is two-dot, so a stale base scans other lanes' merged work + +Same line: the two SHAs are passed as separate arguments, which is two-dot semantics — a literal tree +comparison. On PR #1539 that enumerated **nine** already-merged files belonging to other PRs and **zero** +lines of the PR under review. Audited across `main`, this is the **only** affected site: + +```text +surface-diff.yml:54 "$BASE_SHA...$HEAD_SHA" three-dot — safe +ci.yml:142 "$BASE_SHA...$HEAD_SHA" three-dot — safe +e2e-cli.yml:140 "$BASE_SHA...$HEAD_SHA" three-dot — safe +code-quality.yml:39 "$BASE" "$SHA" TWO-DOT — fix this one +``` + +`A...B` diffs from the **merge-base**, so a stale base self-heals — it is always a former `main` commit and +therefore an ancestor. Demonstrated on identical inputs: `cd24e1679 2a4102600` → 9 files; +`cd24e1679...2a4102600` → 0 files. + +**#1564** owns this root cause across consumers. **You fix only `code-quality.yml:39`.** Do not touch the +three safe sites; do not widen into #1564. + +## Contract + +### C1 — one transition to discovered roots, not two (rail `R-6`) + +Introduce `discoverDoctrineRoots()` in `.llm/tools/fitness/check-doctrine.ts` returning the **final** root +set, and repoint `arch:check` at it **in one step**. No interim list, no checked-in root data file. Two +earlier PLAN-EVAL cycles rejected a two-step version; do not reintroduce it. + +### C2 — the selector is the 36 top-level units, not every workspace member (rail `R-4`) + +Expanded top-level `packages/*` + `plugins/*` — **30 + 6 = 36**. Root `deno.json`'s workspace list also +includes `packages/cli/e2e`, `examples/*` and `apps/*`; those are **not** doctrine roots. +`packages/cli/e2e` is **excluded**, and that exclusion must be **stated in the doctrine document**, not left +implicit in code. + +### C3 — the coverage test must not ask the implementation what to expect + +Derive the expected set **independently** — enumerate publishable units from the filesystem or the workspace +list — then assert `discoverDoctrineRoots()` equals it. A test that computes its expectation *by calling the +function under test* cannot fail; PLAN-EVAL cycle 3 caught exactly that and it is a blocking defect here. + +### C4 — the PR gate must execute on a `.llm/tools`-only diff + +Widen the changed-file computation so `.llm/tools/**` is in scope, and make the **empty set** fail closed or +report "not scanned" explicitly — never silently green. Prove both red-first. + +### C5 — the range becomes three-dot + +One character at `code-quality.yml:39`, plus a fixture proving a stale recorded base no longer admits foreign +already-merged files. + +### C6 — surfaced findings are triaged, never fixed here (rail `R-8`) + +Running the repaired gate over newly covered surfaces will surface real findings. **Do not fix any of them.** +Write them into a triage list in your slice dir with file, line, rule, and a one-line assessment, and say +plainly how many there are. #1403 box 5 requires exactly this; a diff that "helpfully" cleans them up fails +the slice. If a finding is severe enough that you think it must be fixed now, say so and stop — the +orchestrator decides. + +## Acceptance mapping + +#1403 has **8** boxes (5 original + 3 added today; read them from the live issue). Provide a fenced +`acceptance-evidence` block using **`box-index: 1..8`** — **not** exact box text. `acceptanceCheckboxes` +keeps only each checkbox's **first raw line, backticks preserved**, so any box that wraps is unmatchable by +exact text and you cannot see the wrapping. This cost PR #1560 a full failed IMPL-EVAL cycle earlier today. + +## Gates — deliverables, not a checklist. Paste real output with exit codes. + +| # | Gate | Command | +| --- | --- | --- | +| 1 | fitness + quality tests | `deno test --allow-read --allow-env --allow-write --allow-run .llm/tools/fitness/ .llm/tools/quality/` | +| 2 | doctrine (curated) | `deno task arch:check` — must stay **exit 0** | +| 3 | quality gate | `deno task quality:gate` — exit 0 | +| 4 | repo quality scan | `deno task quality:scan:repo` — must stay **exit 0** (it is green at your base; PR-E fixed it) | +| 5 | scoped check / lint / fmt | `.llm/tools/run-deno-{check,lint,fmt}.ts --root .llm/tools --ext ts` | +| 6 | **asset-barrel freshness** | `deno task gen:assets-barrel`, then `git status --porcelain` **must be empty** | +| 7 | new-workflow sanity | confirm `code-quality.yml` still parses | + +Gate 6 is mandatory and non-obvious: tool sources are embedded as strings in +`packages/cli/src/kernel/assets/*.generated.ts`, so editing a bundled tool makes them stale and reds +`ci.yml`'s `quality` job. This cost PR-E a CI cycle. The empty `git status` on a **second** run is also your +idempotence proof. + +Run **all** gates before you report done, so the head is final when the orchestrator flips to ready — that +flip triggers the formal IMPL-EVAL, and a commit landing after it invalidates the verdict. + +## PR mechanics + +1. First commit is the slice-dir bootstrap; open the **draft PR** in that same session, comment per slice. +2. Slice artifacts (`worklog.md`, `context-pack.md`, `drift.md`) updated in the **same commit** as the code + they describe. +3. `## Scope` carries `Closes #1403` on its own line. Reference `#1564` **without** a closing keyword — it + owns the shared root cause and stays open. +4. Labels: `type:fix`, `area:tooling`, `area:packages`, `priority:p0`, `status:impl`, milestone `0.0.6`. + Exactly one `status:`. +5. **Leave the PR draft.** The flip is the orchestrator's action. +6. **State gate claims as evidence, not buckets.** If a scaffold tier reports SUCCESS, say which step number + ran and whether step 2 was "Skipped by policy" — `scaffold-runtime: SUCCESS` is not a provable claim. + Likewise do **not** cite `quality:gate` as coverage of your own diff; on a `.llm/tools` change it is + precisely the defect you are fixing. +7. Resolve commit hashes in a separate shell step and paste literal values. + +## Boundaries + +- Touch only `.llm/tools/fitness/**`, `.llm/tools/quality/**`, `deno.json` tasks, + `.github/workflows/code-quality.yml`, `docs/architecture/doctrine/` for the C2 exclusion statement, and + your slice dir. +- Do **not** change `arch:check:repo`'s behaviour, the A14 rule, or the doctrine verdict table — that is + PR-C (#1380), which consumes your `discoverDoctrineRoots()` unchanged. +- Do **not** add export-awareness, allowance issue-links, `--max-allow` wiring, or docs-fence scanning — + that is PR-D (#1549). +- Do **not** touch `surface-diff.yml`, `ci.yml` or `e2e-cli.yml`; all three are already three-dot. +- Do **not** fix findings the repaired gate surfaces (C6). +- Do **not** add `deno-lint-ignore`, `@ts-ignore`, `as any`, `as unknown as`, or `quality-allow:`. +- Do **not** merge, flip to ready, or apply `status:ready-merge` / `status:impl-eval` / `impl-eval:skip`. + +## Escalate instead of going idle + +If a gate is red and you cannot green it, or a contract here is wrong, write it in your slice `drift.md`, +post it as a PR comment, and continue with what is not blocked. On this lane escalation has three times found +the orchestrator's brief wrong rather than the code — missing `--allow-write`, then `--allow-run`, then the +asset-barrel gate. Raising it is the expected behaviour, not a failure. From 190c114451043555e023917449798cc99ff289c8 Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Wed, 12 Aug 2026 15:10:53 +0200 Subject: [PATCH 2/5] test(quality): prove gate coverage gaps red --- .../slices/pr-b-1403/context-pack.md | 21 +++++++ .../slices/pr-b-1403/drift.md | 3 + .../slices/pr-b-1403/worklog.md | 55 ++++++++++++++++ .llm/tools/fitness/check-doctrine_test.ts | 32 ++++++++++ .../quality/changed-source-files_test.ts | 63 +++++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 .llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md create mode 100644 .llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md create mode 100644 .llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md create mode 100644 .llm/tools/fitness/check-doctrine_test.ts create mode 100644 .llm/tools/quality/changed-source-files_test.ts diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md new file mode 100644 index 0000000000..93bedd7e91 --- /dev/null +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md @@ -0,0 +1,21 @@ +# Context pack: PR-B #1403 + +## Current state + +- Draft PR #1570 is open from bootstrap commit `059576fcd`. +- The formal quality-rail plan revision 4 passed PLAN-EVAL cycle 5. +- RED-first fixtures cover B1 and B3 and currently fail because both planned contracts are absent. + +## Locked implementation + +- Discover exactly the 36 top-level package/plugin units; never use every workspace member. +- `packages/cli/e2e` is nested and excluded, with the reason written into doctrine. +- `arch:check` consumes discovery in the same change that removes the curated task list. +- The PR selector includes `packages`, `plugins`, and `.llm/tools`, reports empty explicitly, and + diffs `BASE...HEAD`. +- Findings are triaged only; package/plugin source is out of scope. + +## Next + +Implement B1/B2/B3, run slice gates, update these artifacts in the same commits, push, and comment +on draft PR #1570 after each slice. diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md new file mode 100644 index 0000000000..781c8775bd --- /dev/null +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md @@ -0,0 +1,3 @@ +# Drift: PR-B #1403 + +Append-only. No drift from the passed plan at bootstrap or RED-first fixture time. diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md new file mode 100644 index 0000000000..99f62d7010 --- /dev/null +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md @@ -0,0 +1,55 @@ +# Worklog: PR-B #1403 quality-gate coverage + +## Identity + +- Worktree: `/home/codex/repos/ns006-qualitygate` +- Branch: `fix/1403-quality-gate-coverage` +- Base: `3c9dc1f3907c605d2d30d76f5a20ade1e4754736` +- Draft PR: #1570 +- Route: Codex · GPT-5.6 Sol · low +- PLAN-EVAL: PASS, quality-rail revision 4, cycle 5 + +## Design + +The parent orchestration worklog is authoritative. This leaf executes its locked B1–B3 slices: + +1. B1 — export `discoverDoctrineRoots()` with the final 36-unit top-level `packages/*` + + `plugins/*` selector and compare it with an independently enumerated expected set. +2. B2 — repoint `arch:check` to that function in one transition; state why nested + `packages/cli/e2e` is outside doctrine-root scope. +3. B3 — make changed-file selection include `.llm/tools/**`, report an empty set as not scanned, + and use three-dot merge-base semantics; triage findings without source fixes. + +No package/plugin public surface changes; archetype and jsr-audit are N/A. Doctrine A14 and F-19 +govern the gate-truth changes. + +## RED-first evidence + +Command: + +```text +deno test --allow-read --allow-env --allow-write --allow-run \ + .llm/tools/fitness/check-doctrine_test.ts \ + .llm/tools/quality/changed-source-files_test.ts +``` + +Exit **1**. Type checking reports both missing contracts: + +```text +TS2307: Cannot find module '.llm/tools/quality/changed-source-files.ts'. +TS2305: check-doctrine.ts has no exported member 'discoverDoctrineRoots'. +``` + +This single committed fixture set proves the doctrine selector and PR changed-file behavior red +before either implementation exists. The `.llm/tools`-only and stale-base cases are explicit test +fixtures, not inferred from the final implementation. + +## Reconcile notes + +- Bootstrap: live issue #1403 has 8 acceptance boxes; draft PR #1570 carries `Closes #1403`, a + non-closing reference to #1564, the required labels, exactly one `status:impl`, and milestone + 0.0.6. + +## Gates + +Pending final implementation head. diff --git a/.llm/tools/fitness/check-doctrine_test.ts b/.llm/tools/fitness/check-doctrine_test.ts new file mode 100644 index 0000000000..0009d7a68d --- /dev/null +++ b/.llm/tools/fitness/check-doctrine_test.ts @@ -0,0 +1,32 @@ +import { assertEquals } from '@std/assert'; +import { join } from '@std/path'; +import { discoverDoctrineRoots } from './check-doctrine.ts'; + +async function expectedDoctrineRoots(repoRoot: string): Promise { + const roots: string[] = []; + for (const parent of ['packages', 'plugins']) { + for await (const entry of Deno.readDir(join(repoRoot, parent))) { + if (!entry.isDirectory) continue; + try { + const config = JSON.parse( + await Deno.readTextFile(join(repoRoot, parent, entry.name, 'deno.json')), + ) as { name?: string }; + if (config.name) roots.push(`${parent}/${entry.name}`); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + } + } + return roots.sort(); +} + +Deno.test('doctrine root discovery equals the independently enumerated publishable top-level units', async () => { + const repoRoot = Deno.cwd(); + const expected = await expectedDoctrineRoots(repoRoot); + const actual = await discoverDoctrineRoots(repoRoot); + + assertEquals(expected.length, 36); + assertEquals(actual, expected); + assertEquals(actual.includes('packages/plugin-streams-core'), true); + assertEquals(actual.includes('packages/cli/e2e'), false); +}); diff --git a/.llm/tools/quality/changed-source-files_test.ts b/.llm/tools/quality/changed-source-files_test.ts new file mode 100644 index 0000000000..625f97d59f --- /dev/null +++ b/.llm/tools/quality/changed-source-files_test.ts @@ -0,0 +1,63 @@ +import { assertEquals } from '@std/assert'; +import { join } from '@std/path'; +import { collectChangedSourceFiles } from './changed-source-files.ts'; + +async function git(cwd: string, ...args: string[]): Promise { + const output = await new Deno.Command('git', { cwd, args }).output(); + if (!output.success) throw new Error(new TextDecoder().decode(output.stderr)); + return new TextDecoder().decode(output.stdout).trim(); +} + +async function write(root: string, path: string, text: string): Promise { + const target = join(root, path); + await Deno.mkdir(join(target, '..'), { recursive: true }); + await Deno.writeTextFile(target, text); +} + +Deno.test('changed-source selector includes .llm/tools-only diffs and reports an empty set', async () => { + const root = await Deno.makeTempDir(); + await git(root, 'init', '-q'); + await git(root, 'config', 'user.email', 'fixture@example.test'); + await git(root, 'config', 'user.name', 'Fixture'); + await write(root, 'README.md', 'base\n'); + await git(root, 'add', '.'); + await git(root, 'commit', '-qm', 'base'); + const base = await git(root, 'rev-parse', 'HEAD'); + + await write(root, '.llm/tools/quality/new-rule.ts', 'const unsafe: any = 1;\n'); + await git(root, 'add', '.'); + await git(root, 'commit', '-qm', 'tool change'); + const head = await git(root, 'rev-parse', 'HEAD'); + + assertEquals(await collectChangedSourceFiles(root, base, head), [ + '.llm/tools/quality/new-rule.ts', + ]); + assertEquals(await collectChangedSourceFiles(root, head, head), []); +}); + +Deno.test('changed-source selector uses merge-base and excludes foreign merged files', async () => { + const root = await Deno.makeTempDir(); + await git(root, 'init', '-q'); + await git(root, 'config', 'user.email', 'fixture@example.test'); + await git(root, 'config', 'user.name', 'Fixture'); + await write(root, 'README.md', 'base\n'); + await git(root, 'add', '.'); + await git(root, 'commit', '-qm', 'common base'); + const common = await git(root, 'rev-parse', 'HEAD'); + + await git(root, 'switch', '-qc', 'feature', common); + await write(root, '.llm/tools/quality/owned.ts', 'export const owned = true;\n'); + await git(root, 'add', '.'); + await git(root, 'commit', '-qm', 'owned change'); + const head = await git(root, 'rev-parse', 'HEAD'); + + await git(root, 'switch', '-q', 'master'); + await write(root, 'packages/foreign/mod.ts', 'export const foreign = true;\n'); + await git(root, 'add', '.'); + await git(root, 'commit', '-qm', 'foreign merged change'); + const recordedBase = await git(root, 'rev-parse', 'HEAD'); + + assertEquals(await collectChangedSourceFiles(root, recordedBase, head), [ + '.llm/tools/quality/owned.ts', + ]); +}); From ca52c3a8fb6ec483d017bb697dac25fbd918b4c5 Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Wed, 12 Aug 2026 15:15:46 +0200 Subject: [PATCH 3/5] fix(quality): discover and scan owned gate surfaces --- .github/workflows/code-quality.yml | 7 +- .../slices/pr-b-1403/context-pack.md | 5 + .../slices/pr-b-1403/drift.md | 22 + .../slices/pr-b-1403/triage.md | 19 + .../slices/pr-b-1403/worklog.md | 12 +- .llm/tools/fitness/check-doctrine.ts | 1133 +++++++++-------- .llm/tools/quality/changed-source-files.ts | 39 + .../quality/changed-source-files_test.ts | 28 +- .llm/tools/quality/scan-code-quality.ts | 4 + deno.json | 2 +- .../09-anti-patterns-and-fitness-functions.md | 4 + .../kernel/assets/agent-tools.generated.ts | 4 +- 12 files changed, 725 insertions(+), 554 deletions(-) create mode 100644 .llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md create mode 100644 .llm/tools/quality/changed-source-files.ts diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 992c3501c5..2adbf8b91c 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -9,7 +9,7 @@ on: paths: - 'packages/**' - 'plugins/**' - - '.llm/tools/quality/**' + - '.llm/tools/**' - 'deno.json' push: branches: [main] @@ -36,10 +36,11 @@ jobs: - name: Scan changed source files shell: bash run: | - mapfile -t files < <(git diff --name-only --diff-filter=ACMR "${{ github.event.pull_request.base.sha }}" "${{ github.sha }}" -- packages plugins) + changed_files=$(deno run --allow-run .llm/tools/quality/changed-source-files.ts "${{ github.event.pull_request.base.sha }}" "${{ github.sha }}") + mapfile -t files <<< "$changed_files" args=() for file in "${files[@]}"; do args+=(--changed-file "$file"); done - if ((${#args[@]})); then deno task quality:scan --pretty "${args[@]}"; fi + deno task quality:scan --pretty "${args[@]}" - run: deno task arch:check - name: Lint changed publish surfaces run: | diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md index 93bedd7e91..f71d7ace40 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md @@ -5,6 +5,9 @@ - Draft PR #1570 is open from bootstrap commit `059576fcd`. - The formal quality-rail plan revision 4 passed PLAN-EVAL cycle 5. - RED-first fixtures cover B1 and B3 and currently fail because both planned contracts are absent. +- B1/B3 implementations make the focused fixtures green. B2 exposes the passed-plan contradiction + recorded as `drift.md` D-1: the final 36 roots contain 54 known A14 failures while this slice is + forbidden to change A14 and is required to keep `arch:check` green. ## Locked implementation @@ -14,6 +17,8 @@ - The PR selector includes `packages`, `plugins`, and `.llm/tools`, reports empty explicitly, and diffs `BASE...HEAD`. - Findings are triaged only; package/plugin source is out of scope. +- `triage.md` records exactly 1 actionable `plugin-streams-core` doctrine finding; the focused + quality scan is green with zero findings and zero allowances. ## Next diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md index 781c8775bd..4a2a519325 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md @@ -1,3 +1,25 @@ # Drift: PR-B #1403 Append-only. No drift from the passed plan at bootstrap or RED-first fixture time. + +## D-1 — significant: final 36-root selector conflicts with required green `arch:check` + +After implementing the locked R-6 transition, `deno task arch:check` exits **1**. Discovery reaches +all 36 intended roots, but 54 pre-existing A14 findings become blocking: 52 under `packages/cli`, +one under `packages/database`, and one under `packages/mcp`. This is the same known population the +passed plan records inside the baseline `arch:check:repo` result (55 total = 54 A14 + root-level +A1). + +The brief simultaneously requires the final 36-root selector, requires `arch:check` to remain exit +0, forbids changing A14 (PR-C #1380 owns it), and forbids fixing surfaced findings. Those conditions +cannot all hold. No suppression or source fix was applied. B1/B3 continue; B2's final gate is +escalated to the orchestrator. + +## D-2 — minor: mandatory root formatter has unrelated pre-existing red + +The exact scoped check and lint wrappers over `.llm/tools --ext ts` pass. After formatting every +PR-B-owned TypeScript file, the exact format wrapper still exits **1** solely for the pre-existing, +out-of-scope `.llm/tools/harness/extract-verdict.ts`. PR-B boundaries allow changes only under the +fitness and quality tool subtrees, so this slice does not edit that file. A focused format check of +all owned TypeScript is green; the root-wrapper residue is escalated rather than folded into this +PR. diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md new file mode 100644 index 0000000000..9bad113b7f --- /dev/null +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md @@ -0,0 +1,19 @@ +# Newly surfaced findings triage + +The repaired scans surfaced **1 actionable finding** in `packages/plugin-streams-core`. No finding is +fixed in PR-B. + +| File | Line | Rule | Assessment | +| --- | ---: | --- | --- | +| `packages/plugin-streams-core/src/application/durable-stream-producer-supervisor.ts` | 501 | `A8/AP-1/F-1` | The file is 515 lines, crossing the doctrine's 500-line warning threshold. This is pre-existing decomposition debt and should be handled in a package-owned follow-up, not in the gate-coverage PR. | + +Focused quality scan evidence: + +```text +deno task quality:scan --pretty --root packages/plugin-streams-core +exit 0; findings=0; allowCount=0 +``` + +Focused doctrine evidence reports the one warning above and one informational A9 reminder that the +package has no `docs/architecture.md`. The A9 record is informational rather than an actionable +finding, so it is not counted in the triage total. diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md index 99f62d7010..870b5e92e7 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md @@ -52,4 +52,14 @@ fixtures, not inferred from the final implementation. ## Gates -Pending final implementation head. +- `deno task arch:check` after the 36-root transition — exit **1**, 54 known pre-existing A14 + findings. See `drift.md` D-1; no findings fixed or suppressed. +- `deno task quality:scan --pretty --root packages/plugin-streams-core` — exit **0**, 0 findings, + 0 allowances. +- Focused doctrine result for `plugin-streams-core` — 0 FAIL, 1 WARN, 1 INFO. The single actionable + warning is recorded in `triage.md`; no package source was edited. +- Scoped check wrapper (`--root .llm/tools --ext ts`) — exit **0**, 287 files, 0 diagnostics. +- Scoped lint wrapper (`--root .llm/tools --ext ts`) — exit **0**, 287 files, 0 diagnostics. +- Scoped format wrapper (`--root .llm/tools --ext ts`) — exit **1** solely for pre-existing, + out-of-scope `.llm/tools/harness/extract-verdict.ts`; every owned TS file passes. See D-2. +- `code-quality.yml` — `@std/yaml` parse exit **0**; draft workflow policy tests 3/3 pass. diff --git a/.llm/tools/fitness/check-doctrine.ts b/.llm/tools/fitness/check-doctrine.ts index 7be155320a..94b7b3f257 100644 --- a/.llm/tools/fitness/check-doctrine.ts +++ b/.llm/tools/fitness/check-doctrine.ts @@ -23,642 +23,683 @@ import { walk } from 'jsr:@std/fs@^1.0.0/walk'; import { parseArgs } from 'jsr:@std/cli@^1.0.0/parse-args'; import { join, relative } from 'jsr:@std/path@^1.0.0'; -const args = parseArgs(Deno.args, { - string: ['root', 'out'], - boolean: ['text'], - default: { root: '.', text: false }, -}); -const ROOT = args.root as string; - -interface Finding { - ref: string; // doctrine reference, e.g. A4, AP-15, F-16 - level: 'PASS' | 'WARN' | 'FAIL' | 'INFO'; - message: string; - path?: string; - line?: number; -} -const findings: Finding[] = []; - -async function exists(p: string) { - try { - await Deno.stat(p); - return true; - } catch { - return false; +/** Discovers every top-level package and plugin unit governed by the doctrine. */ +export async function discoverDoctrineRoots(repoRoot: string = Deno.cwd()): Promise { + const roots: string[] = []; + for (const parent of ['packages', 'plugins']) { + for await (const entry of Deno.readDir(join(repoRoot, parent))) { + if (!entry.isDirectory) continue; + try { + const config = JSON.parse( + await Deno.readTextFile(join(repoRoot, parent, entry.name, 'deno.json')), + ) as { name?: string }; + if (config.name) roots.push(`${parent}/${entry.name}`); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + } } + return roots.sort(); } -async function readText(p: string) { - try { - return await Deno.readTextFile(p); - } catch { - return ''; +async function main(): Promise { + const args = parseArgs(Deno.args, { + string: ['root', 'out'], + boolean: ['text', 'all-roots'], + default: { root: '.', text: false, 'all-roots': false }, + }); + + if (args['all-roots']) { + let failed = false; + for (const root of await discoverDoctrineRoots()) { + const status = await new Deno.Command(Deno.execPath(), { + args: ['run', '--allow-read', import.meta.filename!, '--root', root], + stdout: 'inherit', + stderr: 'inherit', + }).spawn().status; + failed ||= !status.success; + } + if (failed) Deno.exit(1); + return; + } + const ROOT = args.root as string; + + interface Finding { + ref: string; // doctrine reference, e.g. A4, AP-15, F-16 + level: 'PASS' | 'WARN' | 'FAIL' | 'INFO'; + message: string; + path?: string; + line?: number; + } + const findings: Finding[] = []; + + async function exists(p: string) { + try { + await Deno.stat(p); + return true; + } catch { + return false; + } } -} -function stripStringLiterals(line: string) { - return line.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, '""'); -} + async function readText(p: string) { + try { + return await Deno.readTextFile(p); + } catch { + return ''; + } + } -function isTestPath(repoPath: string) { - const normalized = repoPath.replaceAll('\\', '/'); - return normalized.includes('/tests/') || - normalized.endsWith('_test.ts') || - normalized.endsWith('.test.ts'); -} + function stripStringLiterals(line: string) { + return line.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, '""'); + } -// ───────────────────────────────────────────────────────────────────────── -// A1 / A2 — public types first, simple over easy at boundaries -// (Mechanical proxy: mod.ts has @module + every export has explicit return type) -// ───────────────────────────────────────────────────────────────────────── -const modPath = join(ROOT, 'mod.ts'); -if (await exists(modPath)) { - const text = await readText(modPath); - if (!/@module\b/.test(text.slice(0, 4096))) { + function isTestPath(repoPath: string) { + const normalized = repoPath.replaceAll('\\', '/'); + return normalized.includes('/tests/') || + normalized.endsWith('_test.ts') || + normalized.endsWith('.test.ts'); + } + + // ───────────────────────────────────────────────────────────────────────── + // A1 / A2 — public types first, simple over easy at boundaries + // (Mechanical proxy: mod.ts has @module + every export has explicit return type) + // ───────────────────────────────────────────────────────────────────────── + const modPath = join(ROOT, 'mod.ts'); + if (await exists(modPath)) { + const text = await readText(modPath); + if (!/@module\b/.test(text.slice(0, 4096))) { + findings.push({ + ref: 'A1', + level: 'FAIL', + message: 'mod.ts must lead with `@module` JSDoc block (Public Types First)', + path: 'mod.ts', + }); + } + if (text.length > 200 * 80) { + findings.push({ + ref: 'A2', + level: 'WARN', + message: 'mod.ts is too large; barrels must stay ≤ 200 lines (Simple over Easy)', + path: 'mod.ts', + }); + } + // Wildcard re-exports from internal layers + for ( + const m of text.matchAll( + /^export\s+\*\s+from\s+['"](\.\/(?:src\/)?(?:internal|adapters|application|runtime|state|domain)\/[^'"]*)['"]/gm, + ) + ) { + findings.push({ + ref: 'A1', + level: 'WARN', + message: `mod.ts wildcard re-exports internal layer ${ + m[1] + } — curate via src/public/mod.ts instead`, + path: 'mod.ts', + }); + } + } else { findings.push({ ref: 'A1', level: 'FAIL', - message: 'mod.ts must lead with `@module` JSDoc block (Public Types First)', - path: 'mod.ts', + message: 'mod.ts missing — required canonical entrypoint', }); } - if (text.length > 200 * 80) { - findings.push({ - ref: 'A2', - level: 'WARN', - message: 'mod.ts is too large; barrels must stay ≤ 200 lines (Simple over Easy)', - path: 'mod.ts', - }); + + // ───────────────────────────────────────────────────────────────────────── + // A3 — 80% path is one chained call (proxy: README has copy-pasteable example) + // ───────────────────────────────────────────────────────────────────────── + const readmeText = await readText(join(ROOT, 'README.md')); + if (readmeText) { + const codeFences = (readmeText.match(/```ts/g) || []).length + + (readmeText.match(/```typescript/g) || []).length; + if (codeFences < 2) { + findings.push({ + ref: 'A3', + level: 'WARN', + message: + `README has only ${codeFences} TS code fences — needs ≥ 2 (basic + advanced) for the 80% path`, + }); + } } - // Wildcard re-exports from internal layers - for ( - const m of text.matchAll( - /^export\s+\*\s+from\s+['"](\.\/(?:src\/)?(?:internal|adapters|application|runtime|state|domain)\/[^'"]*)['"]/gm, - ) - ) { - findings.push({ - ref: 'A1', - level: 'WARN', - message: `mod.ts wildcard re-exports internal layer ${ - m[1] - } — curate via src/public/mod.ts instead`, - path: 'mod.ts', - }); + + // ───────────────────────────────────────────────────────────────────────── + // A4 — Base classes are stub-only contracts + // Heuristic: any `export abstract class` MUST declare ≥ 1 abstract member, and + // concrete implementations MUST live in a sibling `*.default.ts` or `*.impl.ts` + // rather than the base file. An abstract member is an abstract method, an + // abstract accessor, OR an `abstract readonly` identity field — doctrine file + // 03 ("The stub-only rule") explicitly counts `abstract readonly id/kind/...` + // fields as the contract a spine base imposes on its subtypes. + // Exception: a class with a `protected constructor` is a deliberate layer-2 + // abstract (doctrine file 03, "Spine versus layer-2 abstracts" / R-BASE-L2) — + // a non-instantiable sub-base that may carry concrete shared behavior. The + // stub-only rule applies to the spine, not to layer-2 abstracts, so a + // protected-ctor base is not flagged for "no abstract members". + // ───────────────────────────────────────────────────────────────────────── + const tsFiles: string[] = []; + for await ( + const entry of walk(ROOT, { + match: [/\.ts$/], + skip: [ + /node_modules/, + /_test\.ts$/, + /\.test\.ts$/, + /tests\//, + /examples\//, + /src\/scaffold\/templates\//, + /_fresh/, + /\.deploy/, + ], + }) + ) tsFiles.push(entry.path); + + for (const f of tsFiles) { + const text = await readText(f); + for (const m of text.matchAll(/export\s+abstract\s+class\s+(\w+)([^{]*)\{([\s\S]*?)\n\}/gm)) { + const [, cls, , body] = m; + // Abstract method (incl. generic `<` and async), abstract accessor + // (`abstract get/set foo()`), or abstract field (`abstract readonly axis:`, + // `abstract name:`, `abstract foo?:`) — all satisfy the stub-only contract. + const hasAbstract = /\babstract\s+(?:async\s+)?\w+\s*(); + for (const f of tsFiles) { + const text = await readText(f); + for (const m of text.matchAll(/class\s+(\w+)\s+extends\s+(\w+)/g)) { + extendsMap.set(m[1], m[2]); + } + } + for (const [cls, parent] of extendsMap) { + let depth = 1; + let cur = parent; + const seen = new Set([cls]); + while (extendsMap.has(cur) && !seen.has(cur)) { + seen.add(cur); + cur = extendsMap.get(cur)!; + depth++; + if (depth >= 3) { + findings.push({ + ref: 'A5/AP-5/F-4', + level: 'WARN', + message: + `class ${cls} sits ${depth}+ levels deep in inheritance chain — prefer composition`, + }); + break; + } + } } -} -// ───────────────────────────────────────────────────────────────────────── -// A4 — Base classes are stub-only contracts -// Heuristic: any `export abstract class` MUST declare ≥ 1 abstract member, and -// concrete implementations MUST live in a sibling `*.default.ts` or `*.impl.ts` -// rather than the base file. An abstract member is an abstract method, an -// abstract accessor, OR an `abstract readonly` identity field — doctrine file -// 03 ("The stub-only rule") explicitly counts `abstract readonly id/kind/...` -// fields as the contract a spine base imposes on its subtypes. -// Exception: a class with a `protected constructor` is a deliberate layer-2 -// abstract (doctrine file 03, "Spine versus layer-2 abstracts" / R-BASE-L2) — -// a non-instantiable sub-base that may carry concrete shared behavior. The -// stub-only rule applies to the spine, not to layer-2 abstracts, so a -// protected-ctor base is not flagged for "no abstract members". -// ───────────────────────────────────────────────────────────────────────── -const tsFiles: string[] = []; -for await ( - const entry of walk(ROOT, { - match: [/\.ts$/], - skip: [ - /node_modules/, - /_test\.ts$/, - /\.test\.ts$/, - /tests\//, - /examples\//, - /src\/scaffold\/templates\//, - /_fresh/, - /\.deploy/, - ], - }) -) tsFiles.push(entry.path); - -for (const f of tsFiles) { - const text = await readText(f); - for (const m of text.matchAll(/export\s+abstract\s+class\s+(\w+)([^{]*)\{([\s\S]*?)\n\}/gm)) { - const [, cls, , body] = m; - // Abstract method (incl. generic `<` and async), abstract accessor - // (`abstract get/set foo()`), or abstract field (`abstract readonly axis:`, - // `abstract name:`, `abstract foo?:`) — all satisfy the stub-only contract. - const hasAbstract = /\babstract\s+(?:async\s+)?\w+\s*(); -for (const f of tsFiles) { - const text = await readText(f); - for (const m of text.matchAll(/class\s+(\w+)\s+extends\s+(\w+)/g)) { - extendsMap.set(m[1], m[2]); - } -} -for (const [cls, parent] of extendsMap) { - let depth = 1; - let cur = parent; - const seen = new Set([cls]); - while (extendsMap.has(cur) && !seen.has(cur)) { - seen.add(cur); - cur = extendsMap.get(cur)!; - depth++; - if (depth >= 3) { + // ───────────────────────────────────────────────────────────────────────── + // A8 — One concern per folder, one reason per file. + // F-16 cardinality (>12 children). + // AP-1 / F-1 mega files (>500 lines for application/runtime; >300 for domain). + // ───────────────────────────────────────────────────────────────────────── + for (const f of tsFiles) { + const text = await readText(f); + const lines = text.split(/\r?\n/).length; + const rel = relative(ROOT, f); + let cap = 500; + if (rel.includes('/domain/') || rel.endsWith('schemas.ts') || rel.endsWith('types.ts')) { + cap = 300; + } + if (rel.includes('/runtime/') || rel.includes('/application/')) cap = 500; + if (lines > cap) { findings.push({ - ref: 'A5/AP-5/F-4', + ref: 'A8/AP-1/F-1', level: 'WARN', - message: - `class ${cls} sits ${depth}+ levels deep in inheritance chain — prefer composition`, + message: `file is ${lines} lines (cap ${cap}) — split into smaller single-reason files`, + path: rel, }); - break; } } -} - -// ───────────────────────────────────────────────────────────────────────── -// A6 / A7 — Helpers must be justified; std/web first. -// AP-16 / F-11 forbidden generic folder names. -// ───────────────────────────────────────────────────────────────────────── -const FORBIDDEN_DIRS = new Set(['utils', 'helpers', 'common', 'lib', 'interfaces']); -for await ( - const entry of walk(ROOT, { - includeDirs: true, - includeFiles: false, - skip: [/node_modules/, /_fresh/, /\.deploy/, /docs/, /examples/, /tests/, /\.git/], - }) -) { - const seg = entry.path.split('/').pop()!; - if (FORBIDDEN_DIRS.has(seg) && entry.path !== ROOT) { - findings.push({ - ref: 'AP-16/F-11', - level: 'WARN', - message: - `forbidden folder name '${seg}' — split into domain/, application/, or adapters/ aligned to a real concern`, - path: relative(ROOT, entry.path), - }); - } -} -// Inline result contracts: local Result/Option-style types are allowed when -// they are package-specific boundary contracts. They should stay intentional -// and documented instead of being forced through a removed shared package. -for (const f of tsFiles) { - const text = await readText(f); - if ( - /export\s+type\s+(Result|Either|Option|Maybe)\b/.test(text) + const childCounts = new Map(); + for await ( + const entry of walk(ROOT, { + includeDirs: true, + includeFiles: true, + skip: [/node_modules/, /\.git/, /_fresh/, /\.deploy/, /docs/, /examples/, /tests/], + }) ) { - findings.push({ - ref: 'A1/A2/A7', - level: 'WARN', - message: - `exports Result/Either/Option-style contract — keep it package-specific, documented, and inline unless multiple real consumers justify a shared contract`, - path: relative(ROOT, f), - }); - } -} - -// ───────────────────────────────────────────────────────────────────────── -// A8 — One concern per folder, one reason per file. -// F-16 cardinality (>12 children). -// AP-1 / F-1 mega files (>500 lines for application/runtime; >300 for domain). -// ───────────────────────────────────────────────────────────────────────── -for (const f of tsFiles) { - const text = await readText(f); - const lines = text.split(/\r?\n/).length; - const rel = relative(ROOT, f); - let cap = 500; - if (rel.includes('/domain/') || rel.endsWith('schemas.ts') || rel.endsWith('types.ts')) cap = 300; - if (rel.includes('/runtime/') || rel.includes('/application/')) cap = 500; - if (lines > cap) { - findings.push({ - ref: 'A8/AP-1/F-1', - level: 'WARN', - message: `file is ${lines} lines (cap ${cap}) — split into smaller single-reason files`, - path: rel, - }); - } -} -const childCounts = new Map(); -for await ( - const entry of walk(ROOT, { - includeDirs: true, - includeFiles: true, - skip: [/node_modules/, /\.git/, /_fresh/, /\.deploy/, /docs/, /examples/, /tests/], - }) -) { - const parent = entry.path.split('/').slice(0, -1).join('/'); - if (parent.startsWith(ROOT)) { - childCounts.set(parent, (childCounts.get(parent) ?? 0) + 1); + const parent = entry.path.split('/').slice(0, -1).join('/'); + if (parent.startsWith(ROOT)) { + childCounts.set(parent, (childCounts.get(parent) ?? 0) + 1); + } } -} -for (const [path, count] of childCounts) { - if (count > 12) { - findings.push({ - ref: 'F-16', - level: 'WARN', - message: `directory has ${count} immediate children; doctrine cap is 12`, - path: relative(ROOT, path), - }); + for (const [path, count] of childCounts) { + if (count > 12) { + findings.push({ + ref: 'F-16', + level: 'WARN', + message: `directory has ${count} immediate children; doctrine cap is 12`, + path: relative(ROOT, path), + }); + } } -} -// ───────────────────────────────────────────────────────────────────────── -// A9 — Archetype drives package shape. -// Mechanical check: deno.json description SHOULD declare archetype in its docs/architecture.md. -// ───────────────────────────────────────────────────────────────────────── -const archDocPath = join(ROOT, 'docs/architecture.md'); -if (await exists(archDocPath)) { - const t = await readText(archDocPath); - if (!/Archetype\s*[:#-]\s*\d/.test(t)) { + // ───────────────────────────────────────────────────────────────────────── + // A9 — Archetype drives package shape. + // Mechanical check: deno.json description SHOULD declare archetype in its docs/architecture.md. + // ───────────────────────────────────────────────────────────────────────── + const archDocPath = join(ROOT, 'docs/architecture.md'); + if (await exists(archDocPath)) { + const t = await readText(archDocPath); + if (!/Archetype\s*[:#-]\s*\d/.test(t)) { + findings.push({ + ref: 'A9', + level: 'WARN', + message: 'docs/architecture.md must declare archetype number (1–6)', + path: 'docs/architecture.md', + }); + } + } else { + // INFO only — small contract packages may skip docs/ findings.push({ ref: 'A9', - level: 'WARN', - message: 'docs/architecture.md must declare archetype number (1–6)', - path: 'docs/architecture.md', - }); - } -} else { - // INFO only — small contract packages may skip docs/ - findings.push({ - ref: 'A9', - level: 'INFO', - message: 'docs/architecture.md missing — required when public symbols > 25', - }); -} - -// ───────────────────────────────────────────────────────────────────────── -// A10 — Composition root over container. Detect global mutable singletons. -// AP-11: module-level `let` exporting mutable state. -// ───────────────────────────────────────────────────────────────────────── -for (const f of tsFiles) { - const text = await readText(f); - for (const m of text.matchAll(/^export\s+let\s+(\w+)/gm)) { - findings.push({ - ref: 'A10/AP-11', - level: 'FAIL', - message: `module-level \`export let ${ - m[1] - }\` — global mutable state forbidden; use composition root`, - path: relative(ROOT, f), + level: 'INFO', + message: 'docs/architecture.md missing — required when public symbols > 25', }); } -} - -// ───────────────────────────────────────────────────────────────────────── -// A12 — Durable workflows are state machines. -// (Semantic — INFO only when package name contains saga/workflow/trigger/worker) -// ───────────────────────────────────────────────────────────────────────── -const pkgName = ROOT.split('/').pop() || ''; -if (/sagas?|workflow|triggers?|workers?/i.test(pkgName)) { - findings.push({ - ref: 'A12', - level: 'INFO', - message: - 'package implements durable workflow concepts — verify state machine model is documented in docs/architecture.md', - }); -} -// ───────────────────────────────────────────────────────────────────────── -// A13 — Crash boundaries explicit. Detect raw `process.exit` / `Deno.exit` -// outside of `bin/`. -// ───────────────────────────────────────────────────────────────────────── -for (const f of tsFiles) { - const text = await readText(f); - if (/(?:Deno\.exit|process\.exit)\s*\(/.test(text) && !relative(ROOT, f).startsWith('bin/')) { - findings.push({ - ref: 'A13', - level: 'WARN', - message: - `Deno.exit/process.exit outside bin/ — crash boundaries must be explicit, throw a typed error instead`, - path: relative(ROOT, f), - }); + // ───────────────────────────────────────────────────────────────────────── + // A10 — Composition root over container. Detect global mutable singletons. + // AP-11: module-level `let` exporting mutable state. + // ───────────────────────────────────────────────────────────────────────── + for (const f of tsFiles) { + const text = await readText(f); + for (const m of text.matchAll(/^export\s+let\s+(\w+)/gm)) { + findings.push({ + ref: 'A10/AP-11', + level: 'FAIL', + message: `module-level \`export let ${ + m[1] + }\` — global mutable state forbidden; use composition root`, + path: relative(ROOT, f), + }); + } } -} -// ───────────────────────────────────────────────────────────────────────── -// A14 — Tests preserve doctrine. Detect Jest leftovers / forbidden patterns. -// ───────────────────────────────────────────────────────────────────────── -const testFiles: string[] = []; -for await ( - const entry of walk(ROOT, { - match: [/_test\.ts$/, /\.test\.ts$/], - skip: [/node_modules/], - }) -) testFiles.push(entry.path); -for (const f of testFiles) { - const text = await readText(f); - // Match only *bare* Jest/Vitest globals, never method invocations: a leading - // `.` or word char means it is a method call (e.g. the `defineAiTool(...) - // .describe(...)` fluent tool builder), not a forbidden test global. - if (/(?: "', - path: relative(ROOT, f), + 'package implements durable workflow concepts — verify state machine model is documented in docs/architecture.md', }); } -} -// ───────────────────────────────────────────────────────────────────────── -// AP-15 / F-12 — `IFoo` Hungarian prefix -// ───────────────────────────────────────────────────────────────────────── -for (const f of tsFiles) { - const text = await readText(f); - text.split(/\r?\n/).forEach((line, i) => { - for (const m of line.matchAll(/\b(?:interface|type)\s+(I[A-Z][A-Za-z0-9_]*)\b/g)) { + // ───────────────────────────────────────────────────────────────────────── + // A13 — Crash boundaries explicit. Detect raw `process.exit` / `Deno.exit` + // outside of `bin/`. + // ───────────────────────────────────────────────────────────────────────── + for (const f of tsFiles) { + const text = await readText(f); + if (/(?:Deno\.exit|process\.exit)\s*\(/.test(text) && !relative(ROOT, f).startsWith('bin/')) { findings.push({ - ref: 'AP-15/F-12', - level: 'FAIL', - message: `forbidden I-prefix declaration ${m[1]}`, + ref: 'A13', + level: 'WARN', + message: + `Deno.exit/process.exit outside bin/ — crash boundaries must be explicit, throw a typed error instead`, path: relative(ROOT, f), - line: i + 1, }); } - }); -} + } -// ───────────────────────────────────────────────────────────────────────── -// F-5 / F-6 — `default` export hurts public-surface docs and JSR publishability -// ───────────────────────────────────────────────────────────────────────── -for (const f of tsFiles) { - const text = await readText(f); - text.split(/\r?\n/).forEach((line, i) => { - if (/^export\s+default\b/.test(line)) { + // ───────────────────────────────────────────────────────────────────────── + // A14 — Tests preserve doctrine. Detect Jest leftovers / forbidden patterns. + // ───────────────────────────────────────────────────────────────────────── + const testFiles: string[] = []; + for await ( + const entry of walk(ROOT, { + match: [/_test\.ts$/, /\.test\.ts$/], + skip: [/node_modules/], + }) + ) testFiles.push(entry.path); + for (const f of testFiles) { + const text = await readText(f); + // Match only *bare* Jest/Vitest globals, never method invocations: a leading + // `.` or word char means it is a method call (e.g. the `defineAiTool(...) + // .describe(...)` fluent tool builder), not a forbidden test global. + if (/(? { if ( - /^export\s+(?:async\s+)?function[^{(]*:\s*any\b/.test(line) || - /^export\s+(?:async\s+)?function[^{(]*\([^)]*:\s*any\b/.test(line) || - /^export\s+(?:type|interface)\s+\w+[^=]*=[^=]*\bany\b/.test(line) + /Deno\.test\s*\(\s*["']\s*(?:should work|happy path|basic|works|test\s*\d+)["']/i.test(text) ) { findings.push({ - ref: 'A1/F-5', + ref: 'A14', level: 'WARN', - message: '`any` in exported declaration — use `unknown` or a specific type', + message: + 'test name lacks behavioural specificity — name as ": "', path: relative(ROOT, f), - line: i + 1, }); } - }); -} - -// ───────────────────────────────────────────────────────────────────────── -// AS7 auth doctrine gates — public surface, port factories, casts, and contracts. -// These are intentionally scoped to the finished auth layer so broad historical -// doctrine debt elsewhere in the repo does not redline this slice. -// ───────────────────────────────────────────────────────────────────────── -const AUTH_SURFACE_ROOTS = [ - 'packages/plugin-auth-core', - 'packages/auth-workos', - 'packages/auth-better-auth', - 'packages/auth-kv-oauth', - 'plugins/auth', - 'packages/service/src/auth', -]; - -const AUTH_BACKEND_FACTORIES = [ - { - path: 'packages/auth-workos/src/workos-backend.ts', - name: 'createWorkosBackend', - returnType: 'AuthBackendPort', - }, - { - path: 'packages/auth-better-auth/src/better-auth-backend.ts', - name: 'createBetterAuthBackend', - returnType: 'AuthBackendPort', - }, - { - path: 'packages/auth-kv-oauth/src/backend.ts', - name: 'createKvOAuthBackend', - returnType: 'Promise', - }, -]; - -const authScanFiles: string[] = []; -for await ( - const entry of walk(ROOT, { - match: [/\.ts$/], - skip: [/node_modules/, /src\/scaffold\/templates\//, /_fresh/, /\.deploy/], - }) -) authScanFiles.push(entry.path); - -const authFiles = authScanFiles - .map((path) => ({ path, repoPath: relative('.', path) })) - .filter((file) => AUTH_SURFACE_ROOTS.some((root) => file.repoPath.startsWith(`${root}/`))); - -if (authFiles.length > 0) { - const contractTestPath = 'packages/plugin-auth-core/src/contracts/v1/auth.contract_test.ts'; - if (!(await exists(contractTestPath))) { - findings.push({ - ref: 'AS7/F-AUTH-CONTRACT', - level: 'FAIL', - message: 'auth oRPC contract compile-time regression test is missing', - path: contractTestPath, - }); } - for (const file of authFiles) { - const text = await readText(file.path); - const lines = text.split(/\r?\n/); - for (const [index, line] of lines.entries()) { - const lineNumber = index + 1; - const codeLine = stripStringLiterals(line.replace(/\/\/.*$/, '')); - const isAllowedContractCast = file.repoPath === - 'packages/plugin-auth-core/src/contracts/v1/auth.contract.ts' && - /\}\s+as\s+unknown\s+as\s+Parameters\s*<\s*typeof\s+oc\.errors\s*>\s*\[0\]/.test( - codeLine, - ); - const isAllowedRouterAny = file.repoPath === 'plugins/auth/services/src/router.ts' && - (/\bas\s+any\b/.test(codeLine) || /:\s*any\b/.test(codeLine)); - if ( - !/^\s*(?:\*|\/\*|\/\/|import\b|export\s+\{)/.test(line) && - !/^\s*(?:type\s+)?[A-Za-z0-9_]+\s+as\s+[A-Za-z0-9_]+,?\s*$/.test(line) && - /\bas\s+(?!const\b)(?:unknown\s+as\s+|never\b|any\b|[A-Za-z_{[(])/.test(codeLine) && - !isTestPath(file.repoPath) && - !isAllowedContractCast && - !isAllowedRouterAny - ) { + // ───────────────────────────────────────────────────────────────────────── + // AP-15 / F-12 — `IFoo` Hungarian prefix + // ───────────────────────────────────────────────────────────────────────── + for (const f of tsFiles) { + const text = await readText(f); + text.split(/\r?\n/).forEach((line, i) => { + for (const m of line.matchAll(/\b(?:interface|type)\s+(I[A-Z][A-Za-z0-9_]*)\b/g)) { findings.push({ - ref: 'AS7/F-AUTH-CAST', + ref: 'AP-15/F-12', level: 'FAIL', - message: - 'auth layer permits only the centralized contract cast and the router any exemplar', - path: file.repoPath, - line: lineNumber, + message: `forbidden I-prefix declaration ${m[1]}`, + path: relative(ROOT, f), + line: i + 1, }); } - if (/@ts-(?:ignore|expect-error|nocheck|check)\b/.test(line) && !isTestPath(file.repoPath)) { + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // F-5 / F-6 — `default` export hurts public-surface docs and JSR publishability + // ───────────────────────────────────────────────────────────────────────── + for (const f of tsFiles) { + const text = await readText(f); + text.split(/\r?\n/).forEach((line, i) => { + if (/^export\s+default\b/.test(line)) { findings.push({ - ref: 'AS7/F-AUTH-CAST', - level: 'FAIL', - message: 'auth layer must not use @ts-* directives', - path: file.repoPath, - line: lineNumber, + ref: 'F-5/F-6', + level: 'WARN', + message: '`export default` — JSR penalises (no auto-doc); use named exports', + path: relative(ROOT, f), + line: i + 1, }); } - if (/&\s*Record\s*<\s*string\s*,\s*unknown\s*>/.test(codeLine)) { + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // A1 / F-5 — `any` in published surface + // ───────────────────────────────────────────────────────────────────────── + for (const f of tsFiles) { + if (relative(ROOT, f).startsWith('src/internal/')) continue; + const text = await readText(f); + text.split(/\r?\n/).forEach((line, i) => { + if ( + /^export\s+(?:async\s+)?function[^{(]*:\s*any\b/.test(line) || + /^export\s+(?:async\s+)?function[^{(]*\([^)]*:\s*any\b/.test(line) || + /^export\s+(?:type|interface)\s+\w+[^=]*=[^=]*\bany\b/.test(line) + ) { findings.push({ - ref: 'AS7/F-AUTH-CAST', - level: 'FAIL', - message: 'auth layer must not widen contract types with & Record', - path: file.repoPath, - line: lineNumber, + ref: 'A1/F-5', + level: 'WARN', + message: '`any` in exported declaration — use `unknown` or a specific type', + path: relative(ROOT, f), + line: i + 1, }); } - if (/from\s+['"]@netscript\/[^'"]+\/src\//.test(codeLine)) { - findings.push({ - ref: 'AS7/F-AUTH-IMPORT', - level: 'FAIL', - message: 'auth layer must import internal packages through public entrypoints/subpaths', - path: file.repoPath, - line: lineNumber, - }); + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // AS7 auth doctrine gates — public surface, port factories, casts, and contracts. + // These are intentionally scoped to the finished auth layer so broad historical + // doctrine debt elsewhere in the repo does not redline this slice. + // ───────────────────────────────────────────────────────────────────────── + const AUTH_SURFACE_ROOTS = [ + 'packages/plugin-auth-core', + 'packages/auth-workos', + 'packages/auth-better-auth', + 'packages/auth-kv-oauth', + 'plugins/auth', + 'packages/service/src/auth', + ]; + + const AUTH_BACKEND_FACTORIES = [ + { + path: 'packages/auth-workos/src/workos-backend.ts', + name: 'createWorkosBackend', + returnType: 'AuthBackendPort', + }, + { + path: 'packages/auth-better-auth/src/better-auth-backend.ts', + name: 'createBetterAuthBackend', + returnType: 'AuthBackendPort', + }, + { + path: 'packages/auth-kv-oauth/src/backend.ts', + name: 'createKvOAuthBackend', + returnType: 'Promise', + }, + ]; + + const authScanFiles: string[] = []; + for await ( + const entry of walk(ROOT, { + match: [/\.ts$/], + skip: [/node_modules/, /src\/scaffold\/templates\//, /_fresh/, /\.deploy/], + }) + ) authScanFiles.push(entry.path); + + const authFiles = authScanFiles + .map((path) => ({ path, repoPath: relative('.', path) })) + .filter((file) => AUTH_SURFACE_ROOTS.some((root) => file.repoPath.startsWith(`${root}/`))); + + if (authFiles.length > 0) { + const contractTestPath = 'packages/plugin-auth-core/src/contracts/v1/auth.contract_test.ts'; + if (!(await exists(contractTestPath))) { + findings.push({ + ref: 'AS7/F-AUTH-CONTRACT', + level: 'FAIL', + message: 'auth oRPC contract compile-time regression test is missing', + path: contractTestPath, + }); + } + + for (const file of authFiles) { + const text = await readText(file.path); + const lines = text.split(/\r?\n/); + for (const [index, line] of lines.entries()) { + const lineNumber = index + 1; + const codeLine = stripStringLiterals(line.replace(/\/\/.*$/, '')); + const isAllowedContractCast = file.repoPath === + 'packages/plugin-auth-core/src/contracts/v1/auth.contract.ts' && + /\}\s+as\s+unknown\s+as\s+Parameters\s*<\s*typeof\s+oc\.errors\s*>\s*\[0\]/.test( + codeLine, + ); + const isAllowedRouterAny = file.repoPath === 'plugins/auth/services/src/router.ts' && + (/\bas\s+any\b/.test(codeLine) || /:\s*any\b/.test(codeLine)); + if ( + !/^\s*(?:\*|\/\*|\/\/|import\b|export\s+\{)/.test(line) && + !/^\s*(?:type\s+)?[A-Za-z0-9_]+\s+as\s+[A-Za-z0-9_]+,?\s*$/.test(line) && + /\bas\s+(?!const\b)(?:unknown\s+as\s+|never\b|any\b|[A-Za-z_{[(])/.test(codeLine) && + !isTestPath(file.repoPath) && + !isAllowedContractCast && + !isAllowedRouterAny + ) { + findings.push({ + ref: 'AS7/F-AUTH-CAST', + level: 'FAIL', + message: + 'auth layer permits only the centralized contract cast and the router any exemplar', + path: file.repoPath, + line: lineNumber, + }); + } + if ( + /@ts-(?:ignore|expect-error|nocheck|check)\b/.test(line) && !isTestPath(file.repoPath) + ) { + findings.push({ + ref: 'AS7/F-AUTH-CAST', + level: 'FAIL', + message: 'auth layer must not use @ts-* directives', + path: file.repoPath, + line: lineNumber, + }); + } + if (/&\s*Record\s*<\s*string\s*,\s*unknown\s*>/.test(codeLine)) { + findings.push({ + ref: 'AS7/F-AUTH-CAST', + level: 'FAIL', + message: 'auth layer must not widen contract types with & Record', + path: file.repoPath, + line: lineNumber, + }); + } + if (/from\s+['"]@netscript\/[^'"]+\/src\//.test(codeLine)) { + findings.push({ + ref: 'AS7/F-AUTH-IMPORT', + level: 'FAIL', + message: 'auth layer must import internal packages through public entrypoints/subpaths', + path: file.repoPath, + line: lineNumber, + }); + } + if (/\babstract\s+class\b/.test(codeLine)) { + findings.push({ + ref: 'AS7/F-AUTH-INHERITANCE', + level: 'FAIL', + message: 'auth backend and port layer uses structural ports, not inheritance', + path: file.repoPath, + line: lineNumber, + }); + } } - if (/\babstract\s+class\b/.test(codeLine)) { + } + + for (const factory of AUTH_BACKEND_FACTORIES) { + const factoryText = await readText(factory.path); + const declaration = new RegExp( + `export\\s+(?:async\\s+)?function\\s+${factory.name}\\s*\\([^)]*\\)\\s*:\\s*${ + factory.returnType.replace(/[()<>]/g, String.raw`\$&`) + }`, + 's', + ); + if (!declaration.test(factoryText)) { findings.push({ - ref: 'AS7/F-AUTH-INHERITANCE', + ref: 'AS7/F-AUTH-BACKEND-FACTORY', level: 'FAIL', - message: 'auth backend and port layer uses structural ports, not inheritance', - path: file.repoPath, - line: lineNumber, + message: + `${factory.name} must declare : ${factory.returnType} so backend factories satisfy AuthBackendPort without return casts`, + path: factory.path, }); } } } - for (const factory of AUTH_BACKEND_FACTORIES) { - const factoryText = await readText(factory.path); - const declaration = new RegExp( - `export\\s+(?:async\\s+)?function\\s+${factory.name}\\s*\\([^)]*\\)\\s*:\\s*${ - factory.returnType.replace(/[()<>]/g, String.raw`\$&`) - }`, - 's', - ); - if (!declaration.test(factoryText)) { - findings.push({ - ref: 'AS7/F-AUTH-BACKEND-FACTORY', - level: 'FAIL', - message: - `${factory.name} must declare : ${factory.returnType} so backend factories satisfy AuthBackendPort without return casts`, - path: factory.path, - }); - } + // ───────────────────────────────────────────────────────────────────────── + // Roll-up + // ───────────────────────────────────────────────────────────────────────── + const summary = { + root: ROOT, + pkg: pkgName, + totals: { + fail: findings.filter((f) => f.level === 'FAIL').length, + warn: findings.filter((f) => f.level === 'WARN').length, + info: findings.filter((f) => f.level === 'INFO').length, + }, + findings, + }; + + if (args.out) { + await Deno.mkdir((args.out as string).split('/').slice(0, -1).join('/'), { recursive: true }); + await Deno.writeTextFile(args.out as string, JSON.stringify(summary, null, 2)); } -} - -// ───────────────────────────────────────────────────────────────────────── -// Roll-up -// ───────────────────────────────────────────────────────────────────────── -const summary = { - root: ROOT, - pkg: pkgName, - totals: { - fail: findings.filter((f) => f.level === 'FAIL').length, - warn: findings.filter((f) => f.level === 'WARN').length, - info: findings.filter((f) => f.level === 'INFO').length, - }, - findings, -}; - -if (args.out) { - await Deno.mkdir((args.out as string).split('/').slice(0, -1).join('/'), { recursive: true }); - await Deno.writeTextFile(args.out as string, JSON.stringify(summary, null, 2)); -} -if (args.text || !args.out) { - console.log(`# Doctrine readiness — ${pkgName}`); - console.log( - ` FAIL=${summary.totals.fail} WARN=${summary.totals.warn} INFO=${summary.totals.info}`, - ); - for (const f of findings) { + if (args.text || !args.out) { + console.log(`# Doctrine readiness — ${pkgName}`); console.log( - ` ${f.level} ${f.ref}: ${f.message}${ - f.path ? ` (${f.path}${f.line ? ':' + f.line : ''})` : '' - }`, + ` FAIL=${summary.totals.fail} WARN=${summary.totals.warn} INFO=${summary.totals.info}`, ); + for (const f of findings) { + console.log( + ` ${f.level} ${f.ref}: ${f.message}${ + f.path ? ` (${f.path}${f.line ? ':' + f.line : ''})` : '' + }`, + ); + } } + + if (summary.totals.fail > 0) Deno.exit(1); } -if (summary.totals.fail > 0) Deno.exit(1); +if (import.meta.main) await main(); diff --git a/.llm/tools/quality/changed-source-files.ts b/.llm/tools/quality/changed-source-files.ts new file mode 100644 index 0000000000..fbef47198c --- /dev/null +++ b/.llm/tools/quality/changed-source-files.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env -S deno run --allow-run +/** Source paths whose changes belong to the pull-request quality scan. */ +export async function collectChangedSourceFiles( + repoRoot: string, + base: string, + head: string, +): Promise { + const output = await new Deno.Command('git', { + cwd: repoRoot, + args: [ + 'diff', + '--name-only', + '--diff-filter=ACMR', + `${base}...${head}`, + '--', + 'packages', + 'plugins', + '.llm/tools', + ], + }).output(); + if (!output.success) { + throw new Error(new TextDecoder().decode(output.stderr).trim()); + } + return new TextDecoder().decode(output.stdout).split(/\r?\n/).filter(Boolean).sort(); +} + +if (import.meta.main) { + const [base, head] = Deno.args; + if (!base || !head) { + console.error('usage: changed-source-files.ts '); + Deno.exit(2); + } + const files = await collectChangedSourceFiles(Deno.cwd(), base, head); + if (files.length === 0) { + console.error('not scanned: no changed source files matched packages, plugins, or .llm/tools'); + Deno.exit(2); + } + console.log(files.join('\n')); +} diff --git a/.llm/tools/quality/changed-source-files_test.ts b/.llm/tools/quality/changed-source-files_test.ts index 625f97d59f..671d3cf112 100644 --- a/.llm/tools/quality/changed-source-files_test.ts +++ b/.llm/tools/quality/changed-source-files_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from '@std/assert'; +import { assertEquals, assertStringIncludes } from '@std/assert'; import { join } from '@std/path'; import { collectChangedSourceFiles } from './changed-source-files.ts'; @@ -33,6 +33,22 @@ Deno.test('changed-source selector includes .llm/tools-only diffs and reports an '.llm/tools/quality/new-rule.ts', ]); assertEquals(await collectChangedSourceFiles(root, head, head), []); + + const empty = await new Deno.Command(Deno.execPath(), { + cwd: root, + args: [ + 'run', + '--allow-run', + join(Deno.cwd(), '.llm/tools/quality/changed-source-files.ts'), + head, + head, + ], + }).output(); + assertEquals(empty.code, 2); + assertEquals( + new TextDecoder().decode(empty.stderr).trim(), + 'not scanned: no changed source files matched packages, plugins, or .llm/tools', + ); }); Deno.test('changed-source selector uses merge-base and excludes foreign merged files', async () => { @@ -61,3 +77,13 @@ Deno.test('changed-source selector uses merge-base and excludes foreign merged f '.llm/tools/quality/owned.ts', ]); }); + +Deno.test('code-quality workflow executes the selector for every .llm/tools change', async () => { + const workflow = await Deno.readTextFile('.github/workflows/code-quality.yml'); + assertStringIncludes(workflow, "- '.llm/tools/**'"); + assertStringIncludes( + workflow, + 'changed_files=$(deno run --allow-run .llm/tools/quality/changed-source-files.ts', + ); + assertStringIncludes(workflow, 'deno task quality:scan --pretty "${args[@]}"'); +}); diff --git a/.llm/tools/quality/scan-code-quality.ts b/.llm/tools/quality/scan-code-quality.ts index e651553265..27464de15a 100644 --- a/.llm/tools/quality/scan-code-quality.ts +++ b/.llm/tools/quality/scan-code-quality.ts @@ -15,6 +15,10 @@ export interface QualityFinding { } const PLUGIN_NAMES = ['ai', 'auth', 'sagas', 'streams', 'triggers', 'workers']; +// Root policy: the default `quality:scan` half of `quality:gate` covers CLI +// host code plus first-party plugins. Package-wide auditing, including the +// publishable plugin-*-core packages, is the explicit `quality:scan:repo` task. +// The companion `arch:check` independently evaluates every doctrine root. const DEFAULT_ROOTS = ['packages/cli/src', 'plugins']; const EMPTY_TAINT: Set = new Set(); diff --git a/deno.json b/deno.json index f55f939c70..902b580fc2 100644 --- a/deno.json +++ b/deno.json @@ -156,7 +156,7 @@ }, "docs:readme:check": "deno run --no-lock --allow-read .llm/tools/validation/check-readme-standard.ts --pretty", "docs:tagline:check": "deno run --no-lock --allow-read .llm/tools/validation/check-jsr-tagline-length.ts --pretty", - "arch:check": "deno task deps:check && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root packages/plugin-auth-core && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root packages/auth-workos && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root packages/auth-better-auth && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root packages/auth-kv-oauth && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root plugins/auth && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root packages/plugin && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root plugins/workers && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root plugins/sagas && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root plugins/triggers && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root plugins/streams && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root packages/plugin-sagas-core && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root packages/plugin-triggers-core && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root packages/plugin-workers-core && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root packages/plugin-ai-core && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root packages/ai && deno run --allow-read .llm/tools/fitness/check-doctrine.ts --root plugins/ai", + "arch:check": "deno task deps:check && deno run --allow-read --allow-run .llm/tools/fitness/check-doctrine.ts --all-roots", "arch:check:repo": "deno run --allow-read .llm/tools/fitness/check-doctrine.ts" }, "imports": { diff --git a/docs/architecture/doctrine/09-anti-patterns-and-fitness-functions.md b/docs/architecture/doctrine/09-anti-patterns-and-fitness-functions.md index 3c8d0fbeca..2cb611310e 100644 --- a/docs/architecture/doctrine/09-anti-patterns-and-fitness-functions.md +++ b/docs/architecture/doctrine/09-anti-patterns-and-fitness-functions.md @@ -335,6 +335,10 @@ owned or current-wave package/plugin roots, exclude generated output (`.generate client trees, scaffold scratch), and exclude future-wave packages that the current plan does not own. +Doctrine-root discovery covers the top-level units under `packages/*` and `plugins/*`. The nested +`packages/cli/e2e` workspace member is an end-to-end harness for the CLI, not an independently +published top-level doctrine unit, so it is intentionally excluded from that root set. + Raw root `deno check .`, `deno lint`, or `deno fmt --check` over scratch workspaces, copied templates, generated output, Markdown, or legacy line-ending-only drift is not a package-quality verdict source unless the plan explicitly owns a repo-wide normalization pass. For ordinary diff --git a/packages/cli/src/kernel/assets/agent-tools.generated.ts b/packages/cli/src/kernel/assets/agent-tools.generated.ts index f74ee0781e..c8c727d393 100644 --- a/packages/cli/src/kernel/assets/agent-tools.generated.ts +++ b/packages/cli/src/kernel/assets/agent-tools.generated.ts @@ -17,7 +17,7 @@ const EMBEDDED_AGENT_TOOL_STATIC_FILES: Readonly> = { 'validation/check-aspire-host-ports.ts': "#!/usr/bin/env -S deno run --allow-read\n/**\n * Reject a scaffold default that pins an Aspire **host** port.\n *\n * Aspire's `port` option is the host (proxy) port, not the port the process\n * binds. A pinned host port is a machine-global reservation that\n * `aspire start --isolated` cannot randomise away, so two workspaces scaffolded\n * from the same template collide by construction and the dashboard can\n * advertise a URL owned by another instance (#952).\n *\n * The generators are allowed to *emit* a pin — a config entry that carries\n * `HostPort` opts into one deliberately. What is forbidden is a **scaffold\n * default** that pins one without the developer asking: a literal `Port:` or\n * `HostPort:` in the `appsettings.json` the scaffold writes, or a\n * `withHttpEndpoint({ port: ... })` emitted from a source position\n * that is not driven by config.\n *\n * A deliberate exception may carry an inline `aspire-host-port-ok: `\n * marker; an empty reason is itself a failure.\n */\nimport { walk } from 'jsr:@std/fs@^1/walk';\nimport { relative } from 'jsr:@std/path@^1';\n\nconst DEFAULT_ROOTS = ['packages/cli/src'] as const;\nconst SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.template', '.json']);\nconst ALLOW_MARKER = 'aspire-host-port-ok:';\nconst GENERATED_STATE_DIR = /[\\\\/](?:\\.data|\\.git|node_modules)(?:[\\\\/]|$)/;\n\n/**\n * `withHttpEndpoint({ port: 8010 ... })` — a numeric literal in the host-port\n * slot. An interpolation (`${...}`) is config-driven and therefore fine.\n */\nconst LITERAL_HOST_PORT = /withHttpEndpoint\\(\\s*\\{[^}]*\\bport:\\s*\\d/;\n\n/**\n * A scaffold writing a `Port:` / `HostPort:` key into a resource entry it\n * composes for `appsettings.json`.\n *\n * The pre-fix defect was `Port: appProxyPort` and `Port: options.servicePort` —\n * identifiers, not numeric literals — so matching only literals would look past\n * exactly the shape that shipped. Any *unconditional* write of the key is the\n * defect; a conditional opt-in\n * (`...(opts.hostPort ? { HostPort: opts.hostPort } : {})`) is the fix, and is\n * recognised by the ternary on the same line.\n */\nconst ENTRY_PORT_KEY = /\\b(?:Host)?Port:\\s*\\S/;\nconst JSON_PORT_KEY = /\"(?:Host)?Port\"\\s*:\\s*\\d/;\nconst CONDITIONAL_WRITE = /\\?/;\n\n/** Files that compose resource entries for the scaffolded `appsettings.json`. */\nconst SCAFFOLD_ENTRY_FILES = [\n 'packages/cli/src/kernel/application/scaffold/render-ts-apphost.ts',\n 'packages/cli/src/kernel/templates/aspire/generate-appsettings.ts',\n];\n\n/** One place a scaffold default pins a host port. */\nexport interface HostPortFinding {\n readonly path: string;\n readonly line: number;\n readonly text: string;\n readonly message: string;\n}\n\n/** A pin that carries an explicit `aspire-host-port-ok:` justification. */\nexport interface HostPortAllowance {\n readonly path: string;\n readonly line: number;\n readonly reason: string;\n}\n\n/** Result of one scan over the configured roots. */\nexport interface HostPortScanResult {\n readonly scannedFiles: number;\n readonly findings: readonly HostPortFinding[];\n readonly allowances: readonly HostPortAllowance[];\n}\n\nfunction normalized(path: string): string {\n return path.replaceAll('\\\\', '/');\n}\n\nfunction isTestPath(path: string): boolean {\n const value = `/${normalized(path)}`;\n return value.includes('/tests/') || value.includes('_test.') || value.includes('/fixtures/');\n}\n\nfunction allowanceReason(line: string): string | undefined {\n const index = line.indexOf(ALLOW_MARKER);\n if (index === -1) return undefined;\n return line.slice(index + ALLOW_MARKER.length).trim();\n}\n\n/**\n * Scans one file's content for scaffold defaults that pin an Aspire host port.\n *\n * @param path - Repo-relative path, used for reporting and rule selection\n * @param content - Full file text\n */\nexport function scanContent(\n path: string,\n content: string,\n): { findings: HostPortFinding[]; allowances: HostPortAllowance[] } {\n const findings: HostPortFinding[] = [];\n const allowances: HostPortAllowance[] = [];\n const normalizedPath = normalized(path);\n const checksEntryPorts = SCAFFOLD_ENTRY_FILES.includes(normalizedPath);\n const checksGeneratedJson = normalizedPath.endsWith('/aspire/appsettings.json');\n\n content.split('\\n').forEach((text, index) => {\n const hitsEndpoint = LITERAL_HOST_PORT.test(text);\n const hitsEntry = checksEntryPorts &&\n ENTRY_PORT_KEY.test(text) &&\n !CONDITIONAL_WRITE.test(text);\n const hitsGeneratedJson = checksGeneratedJson && JSON_PORT_KEY.test(text);\n if (!hitsEndpoint && !hitsEntry && !hitsGeneratedJson) return;\n\n const line = index + 1;\n const reason = allowanceReason(text);\n if (reason !== undefined) {\n if (reason.length > 0) {\n allowances.push({ path, line, reason });\n return;\n }\n findings.push({\n path,\n line,\n text: text.trim(),\n message: `\\`${ALLOW_MARKER}\\` marker has an empty reason.`,\n });\n return;\n }\n\n findings.push({\n path,\n line,\n text: text.trim(),\n message: hitsEndpoint\n ? 'Generated `withHttpEndpoint` pins a literal host port. Drive it from the ' +\n 'resource entry (`HostPort`) so `aspire start --isolated` can allocate.'\n : 'Scaffold writes a literal host port into appsettings.json. Leave it unset so ' +\n 'Aspire allocates the host and target ports.',\n });\n });\n\n return { findings, allowances };\n}\n\n/**\n * Walks the given roots and reports every scaffold default that pins a host port.\n *\n * @param roots - Repo-relative directories to scan\n */\nexport async function scanHostPorts(\n roots: readonly string[] = DEFAULT_ROOTS,\n): Promise {\n const findings: HostPortFinding[] = [];\n const allowances: HostPortAllowance[] = [];\n let scannedFiles = 0;\n\n for (const root of roots) {\n for await (\n const entry of walk(root, {\n includeDirs: false,\n skip: [GENERATED_STATE_DIR],\n })\n ) {\n const path = normalized(relative('.', entry.path));\n if (![...SOURCE_EXTENSIONS].some((suffix) => path.endsWith(suffix))) continue;\n if (path.includes('/node_modules/') || isTestPath(path)) continue;\n\n scannedFiles += 1;\n const result = scanContent(path, await Deno.readTextFile(entry.path));\n findings.push(...result.findings);\n allowances.push(...result.allowances);\n }\n }\n\n return { scannedFiles, findings, allowances };\n}\n\nif (import.meta.main) {\n if (Deno.args.includes('--help') || Deno.args.includes('-h')) {\n console.log([\n 'Usage:',\n ' deno run --allow-read check-aspire-host-ports.ts [root ...] [--pretty]',\n '',\n 'Roots default to packages/cli/src. Pass a generated project root to validate the scaffold',\n 'that consumers actually received.',\n ].join('\\n'));\n Deno.exit(0);\n }\n const pretty = Deno.args.includes('--pretty');\n const roots = Deno.args.filter((arg) => !arg.startsWith('-'));\n const result = await scanHostPorts(roots.length > 0 ? roots : DEFAULT_ROOTS);\n\n if (pretty) {\n console.log(`Scanned ${result.scannedFiles} files.`);\n for (const allowance of result.allowances) {\n console.log(` allowed ${allowance.path}:${allowance.line} — ${allowance.reason}`);\n }\n for (const finding of result.findings) {\n console.log(` FAIL ${finding.path}:${finding.line} — ${finding.message}`);\n console.log(` ${finding.text}`);\n }\n console.log(result.findings.length === 0 ? 'OK — no pinned host ports.' : 'FAILED');\n } else {\n console.log(JSON.stringify(result, null, 2));\n }\n\n if (result.findings.length > 0) Deno.exit(1);\n}\n", 'quality/scan-code-quality.ts': - "import { relative, resolve } from 'jsr:@std/path@^1';\n\nexport type QualityRule =\n | 'explicit-any-ignore'\n | 'unsafe-cast'\n | 'explicit-any'\n | 'plugin-name-check'\n | 'ts-error-suppression';\n\nexport interface QualityFinding {\n readonly rule: QualityRule;\n readonly file: string;\n readonly line: number;\n readonly text: string;\n}\n\nconst PLUGIN_NAMES = ['ai', 'auth', 'sagas', 'streams', 'triggers', 'workers'];\nconst DEFAULT_ROOTS = ['packages/cli/src', 'plugins'];\nconst EMPTY_TAINT: Set = new Set();\n\n/**\n * Same-file identifiers bound to a plugin name — `const target = 'auth'` or an\n * array literal containing one. Host code that compares `plugin.name` against\n * such an identifier is the plugin-identity anti-pattern hidden behind an\n * innocent-looking extraction, so these idents are treated as plugin names.\n */\nfunction collectPluginNameIdents(lines: readonly string[]): Set {\n const names = PLUGIN_NAMES.join('|');\n const stringBind = new RegExp(\n `\\\\b(?:const|let|var)\\\\s+([A-Za-z_$][\\\\w$]*)\\\\s*(?::[^=]+)?=\\\\s*[\\\"'](?:${names})[\\\"']`,\n );\n const arrayBind = new RegExp(\n `\\\\b(?:const|let|var)\\\\s+([A-Za-z_$][\\\\w$]*)\\\\s*(?::[^=]+)?=\\\\s*\\\\[[^\\\\]]*[\\\"'](?:${names})[\\\"']`,\n );\n const tainted = new Set();\n for (const line of lines) {\n const s = stringBind.exec(line);\n if (s) tainted.add(s[1]);\n const a = arrayBind.exec(line);\n if (a) tainted.add(a[1]);\n }\n return tainted;\n}\n\nfunction ruleFor(line: string, file: string, tainted: Set): QualityRule | undefined {\n // Template/fixture source strings are data, not syntax in the scanned module.\n if (/^\\s*[`'\\\"]/.test(line)) return undefined;\n if (/deno-lint-ignore(?:-file)?\\s+no-explicit-any/.test(line)) return 'explicit-any-ignore';\n if (/@ts-(?:ignore|expect-error|nocheck)\\b/.test(line)) return 'ts-error-suppression';\n if (/\\bas\\s+unknown\\s+as\\b|\\bas\\s+any\\b|\\bas\\s+never\\b/.test(line)) return 'unsafe-cast';\n if (/(?:<|:\\s*)any(?:\\s*[,>;)\\]}]|\\b)/.test(line)) return 'explicit-any';\n // Host-side plugin identity: equality/predicate against a plugin name whether\n // written as a quoted literal OR a same-file identifier bound to one (const\n // indirection). Requiring the closing quote on literals keeps `'auth-backend'`\n // (a capability id) from matching the `auth` plugin name.\n if (file.includes('/features/plugins/')) {\n const names = PLUGIN_NAMES.join('|');\n const literalEquality = new RegExp(\n `(?:===|!==)\\\\s*[\\\"'](?:${names})[\\\"']|[\\\"'](?:${names})[\\\"']\\\\s*(?:===|!==)`,\n );\n const literalPredicate = new RegExp(\n `\\\\.(?:startsWith|endsWith|includes)\\\\(\\\\s*[\\\"'](?:${names})[\\\"']`,\n );\n if (literalEquality.test(line) || literalPredicate.test(line)) return 'plugin-name-check';\n if (tainted.size > 0) {\n const idents = [...tainted].map((id) => id.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|');\n // `.name`/`kind` compared to a tainted ident, or a predicate on it, or a\n // tainted array `.includes(plugin.name)`.\n const identEquality = new RegExp(\n `(?:\\\\.name|\\\\bkind)\\\\s*(?:===|!==)\\\\s*(?:${idents})\\\\b|\\\\b(?:${idents})\\\\s*(?:===|!==)\\\\s*(?:[\\\\w.]*\\\\.name|kind)\\\\b`,\n );\n const identPredicate = new RegExp(\n `\\\\.(?:startsWith|endsWith|includes)\\\\(\\\\s*(?:${idents})\\\\b`,\n );\n const arrayIncludes = new RegExp(\n `\\\\b(?:${idents})\\\\.includes\\\\(\\\\s*[\\\\w.]*(?:\\\\.name|kind)\\\\b`,\n );\n if (identEquality.test(line) || identPredicate.test(line) || arrayIncludes.test(line)) {\n return 'plugin-name-check';\n }\n }\n }\n return undefined;\n}\n\nfunction isTypeFixture(file: string): boolean {\n const normalized = file.replaceAll('\\\\', '/');\n return normalized.includes('/tests/type-fixtures/') && normalized.endsWith('_type.ts');\n}\n\nfunction isScannable(file: string): boolean {\n return /\\.[cm]?[jt]sx?$/.test(file) && !/(?:_test|\\.test|\\.spec)\\.[cm]?[jt]sx?$/.test(file) &&\n !file.endsWith('.generated.ts') && !isTypeFixture(file);\n}\n\nasync function collect(path: string): Promise {\n try {\n const stat = await Deno.stat(path);\n if (stat.isFile) return isScannable(path) ? [path] : [];\n } catch {\n return [];\n }\n const files: string[] = [];\n for await (const entry of Deno.readDir(path)) {\n const child = resolve(path, entry.name);\n if (entry.isDirectory) files.push(...await collect(child));\n else if (entry.isFile && isScannable(child)) files.push(child);\n }\n return files;\n}\n\n/** A reasoned `// quality-allow:` suppression the scanner honored. */\nexport interface QualityAllowance {\n readonly file: string;\n readonly line: number;\n readonly reason: string;\n}\n\n/** Full scan result: real findings plus every honored allowance (for audit). */\nexport interface QualityScan {\n readonly findings: QualityFinding[];\n readonly allowances: QualityAllowance[];\n}\n\n/** Scan selected source paths, returning findings and honored allowances. */\nexport async function scanCodeQualityDetailed(\n paths: readonly string[],\n cwd: string = Deno.cwd(),\n): Promise {\n const files = (await Promise.all(paths.map((path) => collect(resolve(cwd, path))))).flat();\n const findings: QualityFinding[] = [];\n const allowances: QualityAllowance[] = [];\n for (const file of files) {\n const lines = (await Deno.readTextFile(file)).split(/\\r?\\n/);\n const normalized = file.replaceAll('\\\\', '/');\n const tainted = normalized.includes('/features/plugins/')\n ? collectPluginNameIdents(lines)\n : EMPTY_TAINT;\n for (let index = 0; index < lines.length; index++) {\n const line = lines[index];\n const allowance = line.match(/\\/\\/\\s*quality-allow:\\s*(.+)$/);\n if (allowance?.[1].trim()) {\n // A quality-allow only suppresses a line that would otherwise fire a\n // rule — an allowance on a clean line is dead weight, not counted.\n if (ruleFor(line.replace(/\\/\\/\\s*quality-allow:.*$/, ''), normalized, tainted)) {\n allowances.push({\n file: relative(cwd, file),\n line: index + 1,\n reason: allowance[1].trim(),\n });\n }\n continue;\n }\n const rule = ruleFor(line, normalized, tainted);\n if (rule) {\n findings.push({ rule, file: relative(cwd, file), line: index + 1, text: line.trim() });\n }\n }\n }\n findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);\n return { findings, allowances };\n}\n\n/** Scan selected source paths for code-quality violations. */\nexport async function scanCodeQuality(\n paths: readonly string[],\n cwd: string = Deno.cwd(),\n): Promise {\n return (await scanCodeQualityDetailed(paths, cwd)).findings;\n}\n\nif (import.meta.main) {\n const pretty = Deno.args.includes('--pretty');\n const changed = Deno.args.flatMap((arg, index, args) =>\n arg === '--changed-file' ? [args[index + 1]] : []\n );\n const roots = Deno.args.flatMap((arg, index, args) => arg === '--root' ? [args[index + 1]] : []);\n const maxAllowArg = Deno.args.flatMap((arg, index, args) =>\n arg === '--max-allow' ? [args[index + 1]] : []\n )[0];\n const maxAllow = maxAllowArg === undefined ? undefined : Number(maxAllowArg);\n const mode = changed.length > 0 ? 'changed-files' : 'repository';\n const scanned = changed.length > 0 ? changed : roots.length > 0 ? roots : DEFAULT_ROOTS;\n const { findings, allowances } = await scanCodeQualityDetailed(scanned);\n const allowExceeded = maxAllow !== undefined && Number.isFinite(maxAllow) &&\n allowances.length > maxAllow;\n const result = {\n ok: findings.length === 0 && !allowExceeded,\n mode,\n scanned,\n findings,\n allowCount: allowances.length,\n allowances,\n ...(allowExceeded ? { allowLimitExceeded: { limit: maxAllow, count: allowances.length } } : {}),\n };\n console.log(JSON.stringify(result, null, pretty ? 2 : undefined));\n if (!result.ok) Deno.exit(1);\n}\n", + "import { relative, resolve } from 'jsr:@std/path@^1';\n\nexport type QualityRule =\n | 'explicit-any-ignore'\n | 'unsafe-cast'\n | 'explicit-any'\n | 'plugin-name-check'\n | 'ts-error-suppression';\n\nexport interface QualityFinding {\n readonly rule: QualityRule;\n readonly file: string;\n readonly line: number;\n readonly text: string;\n}\n\nconst PLUGIN_NAMES = ['ai', 'auth', 'sagas', 'streams', 'triggers', 'workers'];\n// Root policy: the default `quality:scan` half of `quality:gate` covers CLI\n// host code plus first-party plugins. Package-wide auditing, including the\n// publishable plugin-*-core packages, is the explicit `quality:scan:repo` task.\n// The companion `arch:check` independently evaluates every doctrine root.\nconst DEFAULT_ROOTS = ['packages/cli/src', 'plugins'];\nconst EMPTY_TAINT: Set = new Set();\n\n/**\n * Same-file identifiers bound to a plugin name — `const target = 'auth'` or an\n * array literal containing one. Host code that compares `plugin.name` against\n * such an identifier is the plugin-identity anti-pattern hidden behind an\n * innocent-looking extraction, so these idents are treated as plugin names.\n */\nfunction collectPluginNameIdents(lines: readonly string[]): Set {\n const names = PLUGIN_NAMES.join('|');\n const stringBind = new RegExp(\n `\\\\b(?:const|let|var)\\\\s+([A-Za-z_$][\\\\w$]*)\\\\s*(?::[^=]+)?=\\\\s*[\\\"'](?:${names})[\\\"']`,\n );\n const arrayBind = new RegExp(\n `\\\\b(?:const|let|var)\\\\s+([A-Za-z_$][\\\\w$]*)\\\\s*(?::[^=]+)?=\\\\s*\\\\[[^\\\\]]*[\\\"'](?:${names})[\\\"']`,\n );\n const tainted = new Set();\n for (const line of lines) {\n const s = stringBind.exec(line);\n if (s) tainted.add(s[1]);\n const a = arrayBind.exec(line);\n if (a) tainted.add(a[1]);\n }\n return tainted;\n}\n\nfunction ruleFor(line: string, file: string, tainted: Set): QualityRule | undefined {\n // Template/fixture source strings are data, not syntax in the scanned module.\n if (/^\\s*[`'\\\"]/.test(line)) return undefined;\n if (/deno-lint-ignore(?:-file)?\\s+no-explicit-any/.test(line)) return 'explicit-any-ignore';\n if (/@ts-(?:ignore|expect-error|nocheck)\\b/.test(line)) return 'ts-error-suppression';\n if (/\\bas\\s+unknown\\s+as\\b|\\bas\\s+any\\b|\\bas\\s+never\\b/.test(line)) return 'unsafe-cast';\n if (/(?:<|:\\s*)any(?:\\s*[,>;)\\]}]|\\b)/.test(line)) return 'explicit-any';\n // Host-side plugin identity: equality/predicate against a plugin name whether\n // written as a quoted literal OR a same-file identifier bound to one (const\n // indirection). Requiring the closing quote on literals keeps `'auth-backend'`\n // (a capability id) from matching the `auth` plugin name.\n if (file.includes('/features/plugins/')) {\n const names = PLUGIN_NAMES.join('|');\n const literalEquality = new RegExp(\n `(?:===|!==)\\\\s*[\\\"'](?:${names})[\\\"']|[\\\"'](?:${names})[\\\"']\\\\s*(?:===|!==)`,\n );\n const literalPredicate = new RegExp(\n `\\\\.(?:startsWith|endsWith|includes)\\\\(\\\\s*[\\\"'](?:${names})[\\\"']`,\n );\n if (literalEquality.test(line) || literalPredicate.test(line)) return 'plugin-name-check';\n if (tainted.size > 0) {\n const idents = [...tainted].map((id) => id.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|');\n // `.name`/`kind` compared to a tainted ident, or a predicate on it, or a\n // tainted array `.includes(plugin.name)`.\n const identEquality = new RegExp(\n `(?:\\\\.name|\\\\bkind)\\\\s*(?:===|!==)\\\\s*(?:${idents})\\\\b|\\\\b(?:${idents})\\\\s*(?:===|!==)\\\\s*(?:[\\\\w.]*\\\\.name|kind)\\\\b`,\n );\n const identPredicate = new RegExp(\n `\\\\.(?:startsWith|endsWith|includes)\\\\(\\\\s*(?:${idents})\\\\b`,\n );\n const arrayIncludes = new RegExp(\n `\\\\b(?:${idents})\\\\.includes\\\\(\\\\s*[\\\\w.]*(?:\\\\.name|kind)\\\\b`,\n );\n if (identEquality.test(line) || identPredicate.test(line) || arrayIncludes.test(line)) {\n return 'plugin-name-check';\n }\n }\n }\n return undefined;\n}\n\nfunction isTypeFixture(file: string): boolean {\n const normalized = file.replaceAll('\\\\', '/');\n return normalized.includes('/tests/type-fixtures/') && normalized.endsWith('_type.ts');\n}\n\nfunction isScannable(file: string): boolean {\n return /\\.[cm]?[jt]sx?$/.test(file) && !/(?:_test|\\.test|\\.spec)\\.[cm]?[jt]sx?$/.test(file) &&\n !file.endsWith('.generated.ts') && !isTypeFixture(file);\n}\n\nasync function collect(path: string): Promise {\n try {\n const stat = await Deno.stat(path);\n if (stat.isFile) return isScannable(path) ? [path] : [];\n } catch {\n return [];\n }\n const files: string[] = [];\n for await (const entry of Deno.readDir(path)) {\n const child = resolve(path, entry.name);\n if (entry.isDirectory) files.push(...await collect(child));\n else if (entry.isFile && isScannable(child)) files.push(child);\n }\n return files;\n}\n\n/** A reasoned `// quality-allow:` suppression the scanner honored. */\nexport interface QualityAllowance {\n readonly file: string;\n readonly line: number;\n readonly reason: string;\n}\n\n/** Full scan result: real findings plus every honored allowance (for audit). */\nexport interface QualityScan {\n readonly findings: QualityFinding[];\n readonly allowances: QualityAllowance[];\n}\n\n/** Scan selected source paths, returning findings and honored allowances. */\nexport async function scanCodeQualityDetailed(\n paths: readonly string[],\n cwd: string = Deno.cwd(),\n): Promise {\n const files = (await Promise.all(paths.map((path) => collect(resolve(cwd, path))))).flat();\n const findings: QualityFinding[] = [];\n const allowances: QualityAllowance[] = [];\n for (const file of files) {\n const lines = (await Deno.readTextFile(file)).split(/\\r?\\n/);\n const normalized = file.replaceAll('\\\\', '/');\n const tainted = normalized.includes('/features/plugins/')\n ? collectPluginNameIdents(lines)\n : EMPTY_TAINT;\n for (let index = 0; index < lines.length; index++) {\n const line = lines[index];\n const allowance = line.match(/\\/\\/\\s*quality-allow:\\s*(.+)$/);\n if (allowance?.[1].trim()) {\n // A quality-allow only suppresses a line that would otherwise fire a\n // rule — an allowance on a clean line is dead weight, not counted.\n if (ruleFor(line.replace(/\\/\\/\\s*quality-allow:.*$/, ''), normalized, tainted)) {\n allowances.push({\n file: relative(cwd, file),\n line: index + 1,\n reason: allowance[1].trim(),\n });\n }\n continue;\n }\n const rule = ruleFor(line, normalized, tainted);\n if (rule) {\n findings.push({ rule, file: relative(cwd, file), line: index + 1, text: line.trim() });\n }\n }\n }\n findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);\n return { findings, allowances };\n}\n\n/** Scan selected source paths for code-quality violations. */\nexport async function scanCodeQuality(\n paths: readonly string[],\n cwd: string = Deno.cwd(),\n): Promise {\n return (await scanCodeQualityDetailed(paths, cwd)).findings;\n}\n\nif (import.meta.main) {\n const pretty = Deno.args.includes('--pretty');\n const changed = Deno.args.flatMap((arg, index, args) =>\n arg === '--changed-file' ? [args[index + 1]] : []\n );\n const roots = Deno.args.flatMap((arg, index, args) => arg === '--root' ? [args[index + 1]] : []);\n const maxAllowArg = Deno.args.flatMap((arg, index, args) =>\n arg === '--max-allow' ? [args[index + 1]] : []\n )[0];\n const maxAllow = maxAllowArg === undefined ? undefined : Number(maxAllowArg);\n const mode = changed.length > 0 ? 'changed-files' : 'repository';\n const scanned = changed.length > 0 ? changed : roots.length > 0 ? roots : DEFAULT_ROOTS;\n const { findings, allowances } = await scanCodeQualityDetailed(scanned);\n const allowExceeded = maxAllow !== undefined && Number.isFinite(maxAllow) &&\n allowances.length > maxAllow;\n const result = {\n ok: findings.length === 0 && !allowExceeded,\n mode,\n scanned,\n findings,\n allowCount: allowances.length,\n allowances,\n ...(allowExceeded ? { allowLimitExceeded: { limit: maxAllow, count: allowances.length } } : {}),\n };\n console.log(JSON.stringify(result, null, pretty ? 2 : undefined));\n if (!result.ok) Deno.exit(1);\n}\n", 'deps/outdated.ts': "/**\n * deps/outdated.ts — structured wrapper over `deno outdated`.\n *\n * `deno outdated` has no `--json` flag, and its `--latest` view includes\n * pre-release tags (see deps/latest.ts for why that misleads). This wrapper runs\n * `deno outdated --recursive [--latest]`, parses the box-drawing table into JSON,\n * and (for the --latest view) flags rows whose \"Latest\" is a pre-release so a\n * reader does not mistake `2.3.0-dev.*` for a real release.\n *\n * Use deps/latest.ts for the authoritative \"latest stable\" decision; use this for\n * the lock-aware inventory (it surfaces transitive/locked entries latest.ts does\n * not, because it reads the resolved graph rather than declared imports).\n *\n * Usage:\n * deno run --allow-read --allow-run .llm/tools/deps/outdated.ts [--latest] [--pretty]\n */\n\ninterface Row {\n package: string;\n current: string;\n update: string;\n latest: string;\n latestIsPrerelease: boolean;\n}\n\nfunction parseArgs(argv: string[]): { latest: boolean; pretty: boolean } {\n return { latest: argv.includes('--latest'), pretty: argv.includes('--pretty') };\n}\n\nfunction parseTable(stdout: string): Row[] {\n const rows: Row[] = [];\n for (const line of stdout.split('\\n')) {\n if (!line.includes('│')) continue;\n const cells = line.split('│').map((cell) => cell.trim()).filter((cell) => cell.length > 0);\n if (cells.length < 4) continue;\n if (cells[0].toLowerCase() === 'package') continue; // header\n const [pkg, current, update, latest] = cells;\n rows.push({\n package: pkg,\n current,\n update,\n latest,\n latestIsPrerelease: latest.includes('-'),\n });\n }\n return rows;\n}\n\nasync function main() {\n const args = parseArgs(Deno.args);\n const cmdArgs = ['outdated', '--recursive'];\n if (args.latest) cmdArgs.push('--latest');\n const command = new Deno.Command('deno', { args: cmdArgs, stdout: 'piped', stderr: 'piped' });\n const output = await command.output();\n const stdout = new TextDecoder().decode(output.stdout);\n const rows = parseTable(stdout);\n const result = {\n generatedAt: new Date().toISOString(),\n mode: args.latest ? 'latest' : 'compatible',\n count: rows.length,\n prereleaseLatest: rows.filter((row) => row.latestIsPrerelease).map((row) => row.package),\n rows,\n };\n\n if (args.pretty) {\n console.log(`deps:outdated (${result.mode}) — ${rows.length} rows`);\n for (const row of rows) {\n const warn = row.latestIsPrerelease ? ' [prerelease — use deps:latest]' : '';\n console.log(` ${row.package} ${row.current} → ${row.latest}${warn}`);\n }\n } else {\n console.log(JSON.stringify(result, null, 2));\n }\n Deno.exit(output.code);\n}\n\nfunction printHelp(): void {\n console.log(\n [\n 'deps/outdated.ts — structured JSON wrapper over `deno outdated --recursive`',\n '',\n 'Usage:',\n ' deno run --allow-read --allow-run .llm/tools/deps/outdated.ts [flags]',\n '',\n 'Flags:',\n ' --latest include the --latest view (flags prerelease \"Latest\" rows)',\n ' --pretty human-readable list instead of JSON',\n ' --help, -h show this help',\n '',\n 'Output (default): JSON { generatedAt, mode, count, prereleaseLatest, rows }.',\n ].join('\\n'),\n );\n}\n\nif (import.meta.main) {\n if (Deno.args.includes('--help') || Deno.args.includes('-h')) {\n printHelp();\n Deno.exit(0);\n }\n await main();\n}\n", 'deps/why.ts': @@ -49,4 +49,4 @@ export const EMBEDDED_AGENT_TOOL_PATHS: readonly string[] = [ /** SHA-256 of canonical manifest-ordered agent-tool paths and content. */ export const EMBEDDED_AGENT_TOOL_BUNDLE_HASH: string = - '8a77b5d1d7c60776f1d31b9d79d203226efa2fc7867ef57ae9b8296e12cf068b'; + '9c453efff624aec3107bd7f128023a116099bea748e25267ed855f647fbf5c0b'; From b64550722a4fdf36139e71fd0cb9a464f0d54ab2 Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Wed, 12 Aug 2026 15:17:34 +0200 Subject: [PATCH 4/5] docs(harness): record quality-gate blocker evidence --- .../slices/pr-b-1403/context-pack.md | 5 +++-- .../slices/pr-b-1403/drift.md | 10 ++++++++++ .../slices/pr-b-1403/triage.md | 11 ++++++++--- .../slices/pr-b-1403/worklog.md | 7 +++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md index f71d7ace40..27b901c982 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md @@ -17,8 +17,9 @@ - The PR selector includes `packages`, `plugins`, and `.llm/tools`, reports empty explicitly, and diffs `BASE...HEAD`. - Findings are triaged only; package/plugin source is out of scope. -- `triage.md` records exactly 1 actionable `plugin-streams-core` doctrine finding; the focused - quality scan is green with zero findings and zero allowances. +- `triage.md` records 3 surfaced findings: 1 actionable `plugin-streams-core` doctrine warning and + 2 changed-tool scanner false positives in existing comments. The focused package quality scan is + green with zero findings and zero allowances. ## Next diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md index 4a2a519325..0240ddb01c 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md @@ -23,3 +23,13 @@ out-of-scope `.llm/tools/harness/extract-verdict.ts`. PR-B boundaries allow chan fitness and quality tool subtrees, so this slice does not edit that file. A focused format check of all owned TypeScript is green; the root-wrapper residue is escalated rather than folded into this PR. + +## D-3 — significant: repaired PR scan truthfully reds on two pre-existing comment false positives + +The exact workflow-equivalent changed-file scan at `ca52c3a8f` executes and exits **1** on +`.llm/tools/fitness/check-doctrine.ts:169` and `:237`. Both lines contain the English word “any” in +comments; neither is TypeScript `any`. They predate PR-B's semantic changes but become visible +because the repaired gate scans the changed tool file. C6 forbids fixing surfaced findings here, +and the boundaries forbid allowance comments, so both are recorded in `triage.md` without a fix. +This is evidence that C4 is no longer silently green, but it also prevents the workflow job from +being green at this head. diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md index 9bad113b7f..f035c07b21 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md @@ -1,11 +1,14 @@ # Newly surfaced findings triage -The repaired scans surfaced **1 actionable finding** in `packages/plugin-streams-core`. No finding is +The repaired scans surfaced **3 findings total**: 1 actionable doctrine warning in +`packages/plugin-streams-core` and 2 scanner false positives in the changed tool file. No finding is fixed in PR-B. | File | Line | Rule | Assessment | | --- | ---: | --- | --- | | `packages/plugin-streams-core/src/application/durable-stream-producer-supervisor.ts` | 501 | `A8/AP-1/F-1` | The file is 515 lines, crossing the doctrine's 500-line warning threshold. This is pre-existing decomposition debt and should be handled in a package-owned follow-up, not in the gate-coverage PR. | +| `.llm/tools/fitness/check-doctrine.ts` | 169 | `explicit-any` | The scanner matches the English word “any” in an existing comment (`any export abstract class`); this is not an explicit TypeScript `any`. Scanner comment-awareness is outside #1403 and must be handled separately. | +| `.llm/tools/fitness/check-doctrine.ts` | 237 | `explicit-any` | The scanner matches the English word “any” in an existing heuristic comment (`any class chain`); this is the same pre-existing false-positive class and is not suppressed here. | Focused quality scan evidence: @@ -15,5 +18,7 @@ exit 0; findings=0; allowCount=0 ``` Focused doctrine evidence reports the one warning above and one informational A9 reminder that the -package has no `docs/architecture.md`. The A9 record is informational rather than an actionable -finding, so it is not counted in the triage total. +package has no `docs/architecture.md`. The A9 record is informational rather than a finding, so it +is not counted in the triage total. The actual PR changed-file scan exits 1 on the two explicitly +listed false positives, proving the `.llm/tools` workflow path executes rather than silently +succeeding. diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md index 870b5e92e7..1061d7d7ac 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md @@ -63,3 +63,10 @@ fixtures, not inferred from the final implementation. - Scoped format wrapper (`--root .llm/tools --ext ts`) — exit **1** solely for pre-existing, out-of-scope `.llm/tools/harness/extract-verdict.ts`; every owned TS file passes. See D-2. - `code-quality.yml` — `@std/yaml` parse exit **0**; draft workflow policy tests 3/3 pass. +- Workflow-equivalent changed-file scan at `ca52c3a8f` — exit **1** on 2 pre-existing comment false + positives, both recorded in `triage.md`; see D-3. +- `deno task quality:gate` — exit **1**: default quality scan is green, then the discovered-root + doctrine half fails on the 54 known A14 findings from D-1. +- `deno task quality:scan:repo` — exit **0**, 0 findings, 8 allowances. +- `deno task gen:assets-barrel` second run — exit **0** and `git status --porcelain` empty; + generated assets are fresh and idempotent. From 48fcef4382f90ea33c70ebd99089990ea163528e Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Wed, 12 Aug 2026 15:27:27 +0200 Subject: [PATCH 5/5] fix(fitness): resolve A14 identifier origins --- .../slices/pr-b-1403/context-pack.md | 18 ++++-- .../slices/pr-b-1403/drift.md | 22 ++++++++ .../slices/pr-b-1403/triage.md | 23 +++++--- .../slices/pr-b-1403/worklog.md | 36 ++++++++++++ .llm/tools/fitness/check-doctrine.ts | 56 +++++++++++++++++-- .llm/tools/fitness/check-doctrine_test.ts | 42 +++++++++++++- .llm/tools/quality/scan-code-quality.ts | 3 +- deno.json | 2 +- .../kernel/assets/agent-tools.generated.ts | 4 +- 9 files changed, 183 insertions(+), 23 deletions(-) diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md index 27b901c982..f0198199b1 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/context-pack.md @@ -17,11 +17,19 @@ - The PR selector includes `packages`, `plugins`, and `.llm/tools`, reports empty explicitly, and diffs `BASE...HEAD`. - Findings are triaged only; package/plugin source is out of scope. -- `triage.md` records 3 surfaced findings: 1 actionable `plugin-streams-core` doctrine warning and - 2 changed-tool scanner false positives in existing comments. The focused package quality scan is - green with zero findings and zero allowances. +- `triage.md` records 1 actionable `plugin-streams-core` doctrine warning plus a 2-entry temporary + #1549 allowance register for changed-tool comment false positives. The focused package quality + scan is green with zero findings and zero allowances. + +## Orchestrator decisions applied + +- R-5 moved into PR-B. A14 is lexical-origin-aware and retains a synthetic unresolved RED case. +- The two tool-comment false positives now have reversible #1549 per-line allowances; current + triage is 1 actionable package finding plus a 2-entry temporary allowance register. +- Final wrapper roots are the owned `.llm/tools/quality` and `.llm/tools/fitness` trees. ## Next -Implement B1/B2/B3, run slice gates, update these artifacts in the same commits, push, and comment -on draft PR #1570 after each slice. +Commit and push the resolved B1–B3 implementation with generated assets, rerun the final-head +idempotence/status check, and update draft PR #1570. The orchestrator then re-syncs against main and +owns the ready transition plus separate-session IMPL-EVAL. diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md index 0240ddb01c..6718274c15 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/drift.md @@ -33,3 +33,25 @@ because the repaired gate scans the changed tool file. C6 forbids fixing surface and the boundaries forbid allowance comments, so both are recorded in `triage.md` without a fix. This is evidence that C4 is no longer silently green, but it also prevents the workflow job from being green at this head. + +## D-4 — orchestrator resolution: R-5 moves from PR-C to PR-B + +The orchestrator confirmed D-1 was a plan-ordering defect. The 36-root transition and A14 +origin-awareness are a matched pair, so R-5 now lands in PR-B. `resolveIdentifierOrigin()` performs +lexical import and local-binding collection and returns `imported | locally-bound | unresolved`; +A14 fires only on `unresolved`. One test exercises all three origins through the actual CLI, with a +synthetic unresolved fixture that exits 1. `deno task arch:check` now exits 0 across all 36 roots. +#1380 remains open; its box 5 implementation is provided here for PR-C to cite and tick. + +## D-5 — orchestrator resolution: temporary #1549 allowances + +D-3's two comment false positives receive exactly two per-line `quality-allow:` comments. Each +reason says the scanner matched an English comment word rather than TypeScript `any` and routes the +durable comment-awareness fix to #1549. The PR-owned repo scan allowance count rises **8 → 10**; +both additions are designed to be deleted by #1549. + +## D-6 — orchestrator correction: wrapper scope is the owned tool trees + +The brief's `.llm/tools` wrapper root was too broad. Final wrapper evidence uses only +`.llm/tools/quality` and `.llm/tools/fitness`; the pre-existing unformatted +`.llm/tools/harness/extract-verdict.ts` remains untouched and outside PR-B. diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md index f035c07b21..ae23477c56 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/triage.md @@ -1,14 +1,12 @@ # Newly surfaced findings triage -The repaired scans surfaced **3 findings total**: 1 actionable doctrine warning in -`packages/plugin-streams-core` and 2 scanner false positives in the changed tool file. No finding is -fixed in PR-B. +The repaired scans leave **1 actionable finding** in `packages/plugin-streams-core`. Two additional +scanner reports in the changed tool file were classified as comment false positives and carry +temporary, per-line allowances linked to #1549. No package finding is fixed in PR-B. | File | Line | Rule | Assessment | | --- | ---: | --- | --- | | `packages/plugin-streams-core/src/application/durable-stream-producer-supervisor.ts` | 501 | `A8/AP-1/F-1` | The file is 515 lines, crossing the doctrine's 500-line warning threshold. This is pre-existing decomposition debt and should be handled in a package-owned follow-up, not in the gate-coverage PR. | -| `.llm/tools/fitness/check-doctrine.ts` | 169 | `explicit-any` | The scanner matches the English word “any” in an existing comment (`any export abstract class`); this is not an explicit TypeScript `any`. Scanner comment-awareness is outside #1403 and must be handled separately. | -| `.llm/tools/fitness/check-doctrine.ts` | 237 | `explicit-any` | The scanner matches the English word “any” in an existing heuristic comment (`any class chain`); this is the same pre-existing false-positive class and is not suppressed here. | Focused quality scan evidence: @@ -19,6 +17,15 @@ exit 0; findings=0; allowCount=0 Focused doctrine evidence reports the one warning above and one informational A9 reminder that the package has no `docs/architecture.md`. The A9 record is informational rather than a finding, so it -is not counted in the triage total. The actual PR changed-file scan exits 1 on the two explicitly -listed false positives, proving the `.llm/tools` workflow path executes rather than silently -succeeding. +is not counted in the triage total. + +## Temporary allowance register + +| File | Line | Rule | Assessment | +| --- | ---: | --- | --- | +| `.llm/tools/fitness/check-doctrine.ts` | 210 | `explicit-any` | The scanner matches the English word “any” in an existing comment (`any export abstract class`), not a TypeScript `any`. A per-line allowance names #1549; delete it when that issue adds comment-awareness. | +| `.llm/tools/fitness/check-doctrine.ts` | 278 | `explicit-any` | The scanner matches the English word “any” in an existing heuristic comment (`any class chain`), not a TypeScript `any`. The same reversible #1549 allowance applies. | + +The committed pre-allowance changed-file run at `b64550722` exited 1 on these two lines. That is the +red-first proof that a `.llm/tools`-only PR now executes and reports; before PR-B the workflow ran no +command and returned success. The final scan is green with two reported allowances. diff --git a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md index 1061d7d7ac..8d15b34991 100644 --- a/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md +++ b/.llm/runs/release-0.0.6-internals--orchestration/slices/pr-b-1403/worklog.md @@ -70,3 +70,39 @@ fixtures, not inferred from the final implementation. - `deno task quality:scan:repo` — exit **0**, 0 findings, 8 allowances. - `deno task gen:assets-barrel` second run — exit **0** and `git status --porcelain` empty; generated assets are fresh and idempotent. + +## Orchestrator rescope + +- R-5 moved from PR-C into PR-B because 36-root discovery and A14 origin-awareness cannot be split. +- Actual-cli three-origin fixture: imported exit 0, locally bound exit 0, unresolved exit 1 with + `FAIL A14`. +- `deno task arch:check` after R-5 — exit **0** over all 36 roots. +- Two authorized comment false positives carry temporary #1549 allowances; the PR-owned repo scan + allowance census is **8 → 10**. +- Wrapper scope corrected to `.llm/tools/quality` + `.llm/tools/fitness`; no change to the unrelated + formatter residue. + +## Final gate evidence after orchestrator decisions + +| Gate | Result | Evidence | +| --- | --- | --- | +| Fitness + quality tests | PASS, exit 0 | 15 passed, 0 failed; includes 36-root census and the imported/local/unresolved A14 CLI fixture | +| `deno task arch:check` | PASS, exit 0 | all 36 roots; CLI/database/MCP A14 false positives eliminated by origin resolution | +| `deno task quality:gate` | PASS, exit 0 | default quality scan followed by the green 36-root doctrine gate | +| `deno task quality:scan:repo` | PASS, exit 0 | 0 findings, allowCount **10** (base 8 + 2 reversible #1549 comment allowances) | +| Owned scoped check | PASS, exit 0 | roots `.llm/tools/quality` + `.llm/tools/fitness`, 10 files, 0 diagnostics | +| Owned scoped lint | PASS, exit 0 | same 10 files, 0 diagnostics | +| Owned scoped format | PASS, exit 0 | same 10 files, 0 findings | +| Workflow-equivalent PR scan | PASS, exit 0 | `.llm/tools` files scanned; 0 findings, 2 reported allowances | +| Workflow sanity | PASS, exit 0 | draft policy 3/3; `code-quality.yml` parses through `@std/yaml` | +| Asset barrel generation | PASS, exit 0 | generated tool embedding refreshed; final second-run cleanliness checked after commit | + +## Final reconcile + +- #1403 remains the sole closing issue and has eight index-based evidence entries on draft PR #1570. +- #1380 is referenced without a closing keyword: box 5's A14 implementation lands here for PR-C to + cite and tick; no #1380 checkbox was changed by this lane. +- #1549 remains open and owns deletion of the two temporary comment allowances when scanner + comment-awareness lands. +- The actionable `plugin-streams-core` 515-line A8 warning remains unchanged in `triage.md`. +- Draft PR #1570 stays `status:impl`; the orchestrator retains ready/merge authority. diff --git a/.llm/tools/fitness/check-doctrine.ts b/.llm/tools/fitness/check-doctrine.ts index 94b7b3f257..718cab47c0 100644 --- a/.llm/tools/fitness/check-doctrine.ts +++ b/.llm/tools/fitness/check-doctrine.ts @@ -42,6 +42,47 @@ export async function discoverDoctrineRoots(repoRoot: string = Deno.cwd()): Prom return roots.sort(); } +/** Lexical origin of a test-global-shaped identifier. */ +export type IdentifierOrigin = 'imported' | 'locally-bound' | 'unresolved'; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function importedBindings(source: string): Set { + const bindings = new Set(); + for (const match of source.matchAll(/^\s*import\s+([\s\S]*?)\s+from\s+['"][^'"]+['"]\s*;?/gm)) { + const clause = match[1].trim().replace(/^type\s+/, ''); + const named = clause.match(/\{([\s\S]*?)\}/)?.[1]; + if (named) { + for (const item of named.split(',')) { + const names = item.trim().replace(/^type\s+/, '').split(/\s+as\s+/); + const local = names.at(-1)?.trim(); + if (local) bindings.add(local); + } + } + const namespace = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/); + if (namespace) bindings.add(namespace[1]); + const defaultBinding = clause.match(/^([A-Za-z_$][\w$]*)\s*(?:,|$)/); + if (defaultBinding) bindings.add(defaultBinding[1]); + } + return bindings; +} + +/** Resolves an identifier using imports and file-local lexical declarations. */ +export function resolveIdentifierOrigin(source: string, identifier: string): IdentifierOrigin { + if (importedBindings(source).has(identifier)) return 'imported'; + + const escaped = escapeRegExp(identifier); + const declaration = new RegExp( + `\\b(?:const|let|var|function|class)\\s+${escaped}\\b`, + ); + const parameter = new RegExp( + `(?:function\\s*[A-Za-z_$]*[\\w$]*\\s*)?\\([^)]*\\b${escaped}\\b[^)]*\\)\\s*(?:=>|\\{)`, + ); + return declaration.test(source) || parameter.test(source) ? 'locally-bound' : 'unresolved'; +} + async function main(): Promise { const args = parseArgs(Deno.args, { string: ['root', 'out'], @@ -166,7 +207,7 @@ async function main(): Promise { // ───────────────────────────────────────────────────────────────────────── // A4 — Base classes are stub-only contracts - // Heuristic: any `export abstract class` MUST declare ≥ 1 abstract member, and + // Heuristic: any `export abstract class` MUST declare ≥ 1 abstract member, and // quality-allow: scanner matches the English word in a comment, not a TypeScript any; durable comment-awareness fix #1549 // concrete implementations MUST live in a sibling `*.default.ts` or `*.impl.ts` // rather than the base file. An abstract member is an abstract method, an // abstract accessor, OR an `abstract readonly` identity field — doctrine file @@ -234,7 +275,7 @@ async function main(): Promise { // ───────────────────────────────────────────────────────────────────────── // A5 — Composition over inheritance. // AP-5 / F-4: deep inheritance. - // Heuristic: any class chain ≥ 3 deep (extends Foo extends Bar) flagged. + // Heuristic: any class chain ≥ 3 deep (extends Foo extends Bar) flagged. // quality-allow: scanner matches the English word in a comment, not a TypeScript any; durable comment-awareness fix #1549 // ───────────────────────────────────────────────────────────────────────── // Approximated: count chains that say `extends X` where X also extends Y in same package. const extendsMap = new Map(); @@ -426,7 +467,7 @@ async function main(): Promise { } // ───────────────────────────────────────────────────────────────────────── - // A14 — Tests preserve doctrine. Detect Jest leftovers / forbidden patterns. + // A14 — Tests preserve doctrine. Detect unresolved Jest/Vitest globals. // ───────────────────────────────────────────────────────────────────────── const testFiles: string[] = []; for await ( @@ -440,12 +481,17 @@ async function main(): Promise { // Match only *bare* Jest/Vitest globals, never method invocations: a leading // `.` or word char means it is a method call (e.g. the `defineAiTool(...) // .describe(...)` fluent tool builder), not a forbidden test global. - if (/(? resolveIdentifierOrigin(text, match[1]) === 'unresolved'); + if (unresolved) { findings.push({ ref: 'A14', level: 'FAIL', - message: 'Jest/Vitest globals (describe/it/expect) — only Deno.test allowed', + message: `unresolved Jest/Vitest global '${ + unresolved[1] + }' — import a sanctioned binding or use Deno.test`, path: relative(ROOT, f), + line: text.slice(0, unresolved.index).split(/\r?\n/).length, }); } if ( diff --git a/.llm/tools/fitness/check-doctrine_test.ts b/.llm/tools/fitness/check-doctrine_test.ts index 0009d7a68d..b05d9b1df2 100644 --- a/.llm/tools/fitness/check-doctrine_test.ts +++ b/.llm/tools/fitness/check-doctrine_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from '@std/assert'; +import { assertEquals, assertStringIncludes } from '@std/assert'; import { join } from '@std/path'; import { discoverDoctrineRoots } from './check-doctrine.ts'; @@ -30,3 +30,43 @@ Deno.test('doctrine root discovery equals the independently enumerated publishab assertEquals(actual.includes('packages/plugin-streams-core'), true); assertEquals(actual.includes('packages/cli/e2e'), false); }); + +Deno.test('A14 distinguishes imported, locally-bound, and unresolved test identifiers', async () => { + const fixtureRoot = await Deno.makeTempDir(); + const tool = join(Deno.cwd(), '.llm/tools/fitness/check-doctrine.ts'); + const cases = [ + { + name: 'imported', + source: + "import { describe, it } from '@std/testing/bdd';\ndescribe('suite', () => it('works', () => {}));\n", + code: 0, + }, + { + name: 'locally-bound', + source: "const describe = (value: string) => value;\ndescribe('local helper');\n", + code: 0, + }, + { + name: 'unresolved', + source: "describe('bare global', () => {});\n", + code: 1, + }, + ] as const; + + for (const fixture of cases) { + const root = join(fixtureRoot, fixture.name); + await Deno.mkdir(join(root, 'tests'), { recursive: true }); + await Deno.writeTextFile(join(root, 'mod.ts'), '/** @module */\nexport const value = true;\n'); + await Deno.writeTextFile(join(root, 'tests', 'origin_test.ts'), fixture.source); + const output = await new Deno.Command(Deno.execPath(), { + args: ['run', '--allow-read', tool, '--root', root], + }).output(); + assertEquals(output.code, fixture.code, fixture.name); + const stdout = new TextDecoder().decode(output.stdout); + if (fixture.name === 'unresolved') { + assertStringIncludes(stdout, "FAIL A14: unresolved Jest/Vitest global 'describe'"); + } else { + assertEquals(stdout.includes('FAIL A14'), false, fixture.name); + } + } +}); diff --git a/.llm/tools/quality/scan-code-quality.ts b/.llm/tools/quality/scan-code-quality.ts index 27464de15a..87771a7bc7 100644 --- a/.llm/tools/quality/scan-code-quality.ts +++ b/.llm/tools/quality/scan-code-quality.ts @@ -17,7 +17,8 @@ export interface QualityFinding { const PLUGIN_NAMES = ['ai', 'auth', 'sagas', 'streams', 'triggers', 'workers']; // Root policy: the default `quality:scan` half of `quality:gate` covers CLI // host code plus first-party plugins. Package-wide auditing, including the -// publishable plugin-*-core packages, is the explicit `quality:scan:repo` task. +// publishable plugin-*-core packages and fitness/quality tool sources, is the +// explicit `quality:scan:repo` task. // The companion `arch:check` independently evaluates every doctrine root. const DEFAULT_ROOTS = ['packages/cli/src', 'plugins']; const EMPTY_TAINT: Set = new Set(); diff --git a/deno.json b/deno.json index 902b580fc2..3edf6ac3f8 100644 --- a/deno.json +++ b/deno.json @@ -48,7 +48,7 @@ "test:redis-regression": "deno run --allow-env=NETSCRIPT_TEST_REDIS_URL --allow-read --allow-run .llm/tools/validation/redis-regression-gate.ts", "test:redis-regression:negative-control": "deno run --allow-env=NETSCRIPT_TEST_REDIS_URL --allow-read --allow-write=packages/kv/adapters/redis.adapter.ts --allow-run .llm/tools/validation/redis-regression-gate.ts --negative-control", "quality:scan": "deno run --allow-read .llm/tools/quality/scan-code-quality.ts", - "quality:scan:repo": "deno run --allow-read .llm/tools/quality/scan-code-quality.ts --root packages --root plugins", + "quality:scan:repo": "deno run --allow-read .llm/tools/quality/scan-code-quality.ts --root packages --root plugins --root .llm/tools/fitness --root .llm/tools/quality", "quality:gate": "deno task quality:scan && deno task arch:check", "coverage:functions": "rm -rf .llm/tmp/coverage/functions && deno test --allow-all --coverage=.llm/tmp/coverage/functions --coverage-raw-data-only packages/contracts/tests/errors_test.ts packages/service/tests/_fixtures/readme-examples_test.ts && deno run --allow-read --allow-write .llm/tools/reporting/report-function-coverage.ts --coverage .llm/tmp/coverage/functions --out .llm/tmp/coverage/function-report.json --package packages/contracts --package packages/plugin-triggers-core --package packages/service --package packages/plugin", "audit:critical": "deno audit --level critical", diff --git a/packages/cli/src/kernel/assets/agent-tools.generated.ts b/packages/cli/src/kernel/assets/agent-tools.generated.ts index c8c727d393..0b54135596 100644 --- a/packages/cli/src/kernel/assets/agent-tools.generated.ts +++ b/packages/cli/src/kernel/assets/agent-tools.generated.ts @@ -17,7 +17,7 @@ const EMBEDDED_AGENT_TOOL_STATIC_FILES: Readonly> = { 'validation/check-aspire-host-ports.ts': "#!/usr/bin/env -S deno run --allow-read\n/**\n * Reject a scaffold default that pins an Aspire **host** port.\n *\n * Aspire's `port` option is the host (proxy) port, not the port the process\n * binds. A pinned host port is a machine-global reservation that\n * `aspire start --isolated` cannot randomise away, so two workspaces scaffolded\n * from the same template collide by construction and the dashboard can\n * advertise a URL owned by another instance (#952).\n *\n * The generators are allowed to *emit* a pin — a config entry that carries\n * `HostPort` opts into one deliberately. What is forbidden is a **scaffold\n * default** that pins one without the developer asking: a literal `Port:` or\n * `HostPort:` in the `appsettings.json` the scaffold writes, or a\n * `withHttpEndpoint({ port: ... })` emitted from a source position\n * that is not driven by config.\n *\n * A deliberate exception may carry an inline `aspire-host-port-ok: `\n * marker; an empty reason is itself a failure.\n */\nimport { walk } from 'jsr:@std/fs@^1/walk';\nimport { relative } from 'jsr:@std/path@^1';\n\nconst DEFAULT_ROOTS = ['packages/cli/src'] as const;\nconst SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.template', '.json']);\nconst ALLOW_MARKER = 'aspire-host-port-ok:';\nconst GENERATED_STATE_DIR = /[\\\\/](?:\\.data|\\.git|node_modules)(?:[\\\\/]|$)/;\n\n/**\n * `withHttpEndpoint({ port: 8010 ... })` — a numeric literal in the host-port\n * slot. An interpolation (`${...}`) is config-driven and therefore fine.\n */\nconst LITERAL_HOST_PORT = /withHttpEndpoint\\(\\s*\\{[^}]*\\bport:\\s*\\d/;\n\n/**\n * A scaffold writing a `Port:` / `HostPort:` key into a resource entry it\n * composes for `appsettings.json`.\n *\n * The pre-fix defect was `Port: appProxyPort` and `Port: options.servicePort` —\n * identifiers, not numeric literals — so matching only literals would look past\n * exactly the shape that shipped. Any *unconditional* write of the key is the\n * defect; a conditional opt-in\n * (`...(opts.hostPort ? { HostPort: opts.hostPort } : {})`) is the fix, and is\n * recognised by the ternary on the same line.\n */\nconst ENTRY_PORT_KEY = /\\b(?:Host)?Port:\\s*\\S/;\nconst JSON_PORT_KEY = /\"(?:Host)?Port\"\\s*:\\s*\\d/;\nconst CONDITIONAL_WRITE = /\\?/;\n\n/** Files that compose resource entries for the scaffolded `appsettings.json`. */\nconst SCAFFOLD_ENTRY_FILES = [\n 'packages/cli/src/kernel/application/scaffold/render-ts-apphost.ts',\n 'packages/cli/src/kernel/templates/aspire/generate-appsettings.ts',\n];\n\n/** One place a scaffold default pins a host port. */\nexport interface HostPortFinding {\n readonly path: string;\n readonly line: number;\n readonly text: string;\n readonly message: string;\n}\n\n/** A pin that carries an explicit `aspire-host-port-ok:` justification. */\nexport interface HostPortAllowance {\n readonly path: string;\n readonly line: number;\n readonly reason: string;\n}\n\n/** Result of one scan over the configured roots. */\nexport interface HostPortScanResult {\n readonly scannedFiles: number;\n readonly findings: readonly HostPortFinding[];\n readonly allowances: readonly HostPortAllowance[];\n}\n\nfunction normalized(path: string): string {\n return path.replaceAll('\\\\', '/');\n}\n\nfunction isTestPath(path: string): boolean {\n const value = `/${normalized(path)}`;\n return value.includes('/tests/') || value.includes('_test.') || value.includes('/fixtures/');\n}\n\nfunction allowanceReason(line: string): string | undefined {\n const index = line.indexOf(ALLOW_MARKER);\n if (index === -1) return undefined;\n return line.slice(index + ALLOW_MARKER.length).trim();\n}\n\n/**\n * Scans one file's content for scaffold defaults that pin an Aspire host port.\n *\n * @param path - Repo-relative path, used for reporting and rule selection\n * @param content - Full file text\n */\nexport function scanContent(\n path: string,\n content: string,\n): { findings: HostPortFinding[]; allowances: HostPortAllowance[] } {\n const findings: HostPortFinding[] = [];\n const allowances: HostPortAllowance[] = [];\n const normalizedPath = normalized(path);\n const checksEntryPorts = SCAFFOLD_ENTRY_FILES.includes(normalizedPath);\n const checksGeneratedJson = normalizedPath.endsWith('/aspire/appsettings.json');\n\n content.split('\\n').forEach((text, index) => {\n const hitsEndpoint = LITERAL_HOST_PORT.test(text);\n const hitsEntry = checksEntryPorts &&\n ENTRY_PORT_KEY.test(text) &&\n !CONDITIONAL_WRITE.test(text);\n const hitsGeneratedJson = checksGeneratedJson && JSON_PORT_KEY.test(text);\n if (!hitsEndpoint && !hitsEntry && !hitsGeneratedJson) return;\n\n const line = index + 1;\n const reason = allowanceReason(text);\n if (reason !== undefined) {\n if (reason.length > 0) {\n allowances.push({ path, line, reason });\n return;\n }\n findings.push({\n path,\n line,\n text: text.trim(),\n message: `\\`${ALLOW_MARKER}\\` marker has an empty reason.`,\n });\n return;\n }\n\n findings.push({\n path,\n line,\n text: text.trim(),\n message: hitsEndpoint\n ? 'Generated `withHttpEndpoint` pins a literal host port. Drive it from the ' +\n 'resource entry (`HostPort`) so `aspire start --isolated` can allocate.'\n : 'Scaffold writes a literal host port into appsettings.json. Leave it unset so ' +\n 'Aspire allocates the host and target ports.',\n });\n });\n\n return { findings, allowances };\n}\n\n/**\n * Walks the given roots and reports every scaffold default that pins a host port.\n *\n * @param roots - Repo-relative directories to scan\n */\nexport async function scanHostPorts(\n roots: readonly string[] = DEFAULT_ROOTS,\n): Promise {\n const findings: HostPortFinding[] = [];\n const allowances: HostPortAllowance[] = [];\n let scannedFiles = 0;\n\n for (const root of roots) {\n for await (\n const entry of walk(root, {\n includeDirs: false,\n skip: [GENERATED_STATE_DIR],\n })\n ) {\n const path = normalized(relative('.', entry.path));\n if (![...SOURCE_EXTENSIONS].some((suffix) => path.endsWith(suffix))) continue;\n if (path.includes('/node_modules/') || isTestPath(path)) continue;\n\n scannedFiles += 1;\n const result = scanContent(path, await Deno.readTextFile(entry.path));\n findings.push(...result.findings);\n allowances.push(...result.allowances);\n }\n }\n\n return { scannedFiles, findings, allowances };\n}\n\nif (import.meta.main) {\n if (Deno.args.includes('--help') || Deno.args.includes('-h')) {\n console.log([\n 'Usage:',\n ' deno run --allow-read check-aspire-host-ports.ts [root ...] [--pretty]',\n '',\n 'Roots default to packages/cli/src. Pass a generated project root to validate the scaffold',\n 'that consumers actually received.',\n ].join('\\n'));\n Deno.exit(0);\n }\n const pretty = Deno.args.includes('--pretty');\n const roots = Deno.args.filter((arg) => !arg.startsWith('-'));\n const result = await scanHostPorts(roots.length > 0 ? roots : DEFAULT_ROOTS);\n\n if (pretty) {\n console.log(`Scanned ${result.scannedFiles} files.`);\n for (const allowance of result.allowances) {\n console.log(` allowed ${allowance.path}:${allowance.line} — ${allowance.reason}`);\n }\n for (const finding of result.findings) {\n console.log(` FAIL ${finding.path}:${finding.line} — ${finding.message}`);\n console.log(` ${finding.text}`);\n }\n console.log(result.findings.length === 0 ? 'OK — no pinned host ports.' : 'FAILED');\n } else {\n console.log(JSON.stringify(result, null, 2));\n }\n\n if (result.findings.length > 0) Deno.exit(1);\n}\n", 'quality/scan-code-quality.ts': - "import { relative, resolve } from 'jsr:@std/path@^1';\n\nexport type QualityRule =\n | 'explicit-any-ignore'\n | 'unsafe-cast'\n | 'explicit-any'\n | 'plugin-name-check'\n | 'ts-error-suppression';\n\nexport interface QualityFinding {\n readonly rule: QualityRule;\n readonly file: string;\n readonly line: number;\n readonly text: string;\n}\n\nconst PLUGIN_NAMES = ['ai', 'auth', 'sagas', 'streams', 'triggers', 'workers'];\n// Root policy: the default `quality:scan` half of `quality:gate` covers CLI\n// host code plus first-party plugins. Package-wide auditing, including the\n// publishable plugin-*-core packages, is the explicit `quality:scan:repo` task.\n// The companion `arch:check` independently evaluates every doctrine root.\nconst DEFAULT_ROOTS = ['packages/cli/src', 'plugins'];\nconst EMPTY_TAINT: Set = new Set();\n\n/**\n * Same-file identifiers bound to a plugin name — `const target = 'auth'` or an\n * array literal containing one. Host code that compares `plugin.name` against\n * such an identifier is the plugin-identity anti-pattern hidden behind an\n * innocent-looking extraction, so these idents are treated as plugin names.\n */\nfunction collectPluginNameIdents(lines: readonly string[]): Set {\n const names = PLUGIN_NAMES.join('|');\n const stringBind = new RegExp(\n `\\\\b(?:const|let|var)\\\\s+([A-Za-z_$][\\\\w$]*)\\\\s*(?::[^=]+)?=\\\\s*[\\\"'](?:${names})[\\\"']`,\n );\n const arrayBind = new RegExp(\n `\\\\b(?:const|let|var)\\\\s+([A-Za-z_$][\\\\w$]*)\\\\s*(?::[^=]+)?=\\\\s*\\\\[[^\\\\]]*[\\\"'](?:${names})[\\\"']`,\n );\n const tainted = new Set();\n for (const line of lines) {\n const s = stringBind.exec(line);\n if (s) tainted.add(s[1]);\n const a = arrayBind.exec(line);\n if (a) tainted.add(a[1]);\n }\n return tainted;\n}\n\nfunction ruleFor(line: string, file: string, tainted: Set): QualityRule | undefined {\n // Template/fixture source strings are data, not syntax in the scanned module.\n if (/^\\s*[`'\\\"]/.test(line)) return undefined;\n if (/deno-lint-ignore(?:-file)?\\s+no-explicit-any/.test(line)) return 'explicit-any-ignore';\n if (/@ts-(?:ignore|expect-error|nocheck)\\b/.test(line)) return 'ts-error-suppression';\n if (/\\bas\\s+unknown\\s+as\\b|\\bas\\s+any\\b|\\bas\\s+never\\b/.test(line)) return 'unsafe-cast';\n if (/(?:<|:\\s*)any(?:\\s*[,>;)\\]}]|\\b)/.test(line)) return 'explicit-any';\n // Host-side plugin identity: equality/predicate against a plugin name whether\n // written as a quoted literal OR a same-file identifier bound to one (const\n // indirection). Requiring the closing quote on literals keeps `'auth-backend'`\n // (a capability id) from matching the `auth` plugin name.\n if (file.includes('/features/plugins/')) {\n const names = PLUGIN_NAMES.join('|');\n const literalEquality = new RegExp(\n `(?:===|!==)\\\\s*[\\\"'](?:${names})[\\\"']|[\\\"'](?:${names})[\\\"']\\\\s*(?:===|!==)`,\n );\n const literalPredicate = new RegExp(\n `\\\\.(?:startsWith|endsWith|includes)\\\\(\\\\s*[\\\"'](?:${names})[\\\"']`,\n );\n if (literalEquality.test(line) || literalPredicate.test(line)) return 'plugin-name-check';\n if (tainted.size > 0) {\n const idents = [...tainted].map((id) => id.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|');\n // `.name`/`kind` compared to a tainted ident, or a predicate on it, or a\n // tainted array `.includes(plugin.name)`.\n const identEquality = new RegExp(\n `(?:\\\\.name|\\\\bkind)\\\\s*(?:===|!==)\\\\s*(?:${idents})\\\\b|\\\\b(?:${idents})\\\\s*(?:===|!==)\\\\s*(?:[\\\\w.]*\\\\.name|kind)\\\\b`,\n );\n const identPredicate = new RegExp(\n `\\\\.(?:startsWith|endsWith|includes)\\\\(\\\\s*(?:${idents})\\\\b`,\n );\n const arrayIncludes = new RegExp(\n `\\\\b(?:${idents})\\\\.includes\\\\(\\\\s*[\\\\w.]*(?:\\\\.name|kind)\\\\b`,\n );\n if (identEquality.test(line) || identPredicate.test(line) || arrayIncludes.test(line)) {\n return 'plugin-name-check';\n }\n }\n }\n return undefined;\n}\n\nfunction isTypeFixture(file: string): boolean {\n const normalized = file.replaceAll('\\\\', '/');\n return normalized.includes('/tests/type-fixtures/') && normalized.endsWith('_type.ts');\n}\n\nfunction isScannable(file: string): boolean {\n return /\\.[cm]?[jt]sx?$/.test(file) && !/(?:_test|\\.test|\\.spec)\\.[cm]?[jt]sx?$/.test(file) &&\n !file.endsWith('.generated.ts') && !isTypeFixture(file);\n}\n\nasync function collect(path: string): Promise {\n try {\n const stat = await Deno.stat(path);\n if (stat.isFile) return isScannable(path) ? [path] : [];\n } catch {\n return [];\n }\n const files: string[] = [];\n for await (const entry of Deno.readDir(path)) {\n const child = resolve(path, entry.name);\n if (entry.isDirectory) files.push(...await collect(child));\n else if (entry.isFile && isScannable(child)) files.push(child);\n }\n return files;\n}\n\n/** A reasoned `// quality-allow:` suppression the scanner honored. */\nexport interface QualityAllowance {\n readonly file: string;\n readonly line: number;\n readonly reason: string;\n}\n\n/** Full scan result: real findings plus every honored allowance (for audit). */\nexport interface QualityScan {\n readonly findings: QualityFinding[];\n readonly allowances: QualityAllowance[];\n}\n\n/** Scan selected source paths, returning findings and honored allowances. */\nexport async function scanCodeQualityDetailed(\n paths: readonly string[],\n cwd: string = Deno.cwd(),\n): Promise {\n const files = (await Promise.all(paths.map((path) => collect(resolve(cwd, path))))).flat();\n const findings: QualityFinding[] = [];\n const allowances: QualityAllowance[] = [];\n for (const file of files) {\n const lines = (await Deno.readTextFile(file)).split(/\\r?\\n/);\n const normalized = file.replaceAll('\\\\', '/');\n const tainted = normalized.includes('/features/plugins/')\n ? collectPluginNameIdents(lines)\n : EMPTY_TAINT;\n for (let index = 0; index < lines.length; index++) {\n const line = lines[index];\n const allowance = line.match(/\\/\\/\\s*quality-allow:\\s*(.+)$/);\n if (allowance?.[1].trim()) {\n // A quality-allow only suppresses a line that would otherwise fire a\n // rule — an allowance on a clean line is dead weight, not counted.\n if (ruleFor(line.replace(/\\/\\/\\s*quality-allow:.*$/, ''), normalized, tainted)) {\n allowances.push({\n file: relative(cwd, file),\n line: index + 1,\n reason: allowance[1].trim(),\n });\n }\n continue;\n }\n const rule = ruleFor(line, normalized, tainted);\n if (rule) {\n findings.push({ rule, file: relative(cwd, file), line: index + 1, text: line.trim() });\n }\n }\n }\n findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);\n return { findings, allowances };\n}\n\n/** Scan selected source paths for code-quality violations. */\nexport async function scanCodeQuality(\n paths: readonly string[],\n cwd: string = Deno.cwd(),\n): Promise {\n return (await scanCodeQualityDetailed(paths, cwd)).findings;\n}\n\nif (import.meta.main) {\n const pretty = Deno.args.includes('--pretty');\n const changed = Deno.args.flatMap((arg, index, args) =>\n arg === '--changed-file' ? [args[index + 1]] : []\n );\n const roots = Deno.args.flatMap((arg, index, args) => arg === '--root' ? [args[index + 1]] : []);\n const maxAllowArg = Deno.args.flatMap((arg, index, args) =>\n arg === '--max-allow' ? [args[index + 1]] : []\n )[0];\n const maxAllow = maxAllowArg === undefined ? undefined : Number(maxAllowArg);\n const mode = changed.length > 0 ? 'changed-files' : 'repository';\n const scanned = changed.length > 0 ? changed : roots.length > 0 ? roots : DEFAULT_ROOTS;\n const { findings, allowances } = await scanCodeQualityDetailed(scanned);\n const allowExceeded = maxAllow !== undefined && Number.isFinite(maxAllow) &&\n allowances.length > maxAllow;\n const result = {\n ok: findings.length === 0 && !allowExceeded,\n mode,\n scanned,\n findings,\n allowCount: allowances.length,\n allowances,\n ...(allowExceeded ? { allowLimitExceeded: { limit: maxAllow, count: allowances.length } } : {}),\n };\n console.log(JSON.stringify(result, null, pretty ? 2 : undefined));\n if (!result.ok) Deno.exit(1);\n}\n", + "import { relative, resolve } from 'jsr:@std/path@^1';\n\nexport type QualityRule =\n | 'explicit-any-ignore'\n | 'unsafe-cast'\n | 'explicit-any'\n | 'plugin-name-check'\n | 'ts-error-suppression';\n\nexport interface QualityFinding {\n readonly rule: QualityRule;\n readonly file: string;\n readonly line: number;\n readonly text: string;\n}\n\nconst PLUGIN_NAMES = ['ai', 'auth', 'sagas', 'streams', 'triggers', 'workers'];\n// Root policy: the default `quality:scan` half of `quality:gate` covers CLI\n// host code plus first-party plugins. Package-wide auditing, including the\n// publishable plugin-*-core packages and fitness/quality tool sources, is the\n// explicit `quality:scan:repo` task.\n// The companion `arch:check` independently evaluates every doctrine root.\nconst DEFAULT_ROOTS = ['packages/cli/src', 'plugins'];\nconst EMPTY_TAINT: Set = new Set();\n\n/**\n * Same-file identifiers bound to a plugin name — `const target = 'auth'` or an\n * array literal containing one. Host code that compares `plugin.name` against\n * such an identifier is the plugin-identity anti-pattern hidden behind an\n * innocent-looking extraction, so these idents are treated as plugin names.\n */\nfunction collectPluginNameIdents(lines: readonly string[]): Set {\n const names = PLUGIN_NAMES.join('|');\n const stringBind = new RegExp(\n `\\\\b(?:const|let|var)\\\\s+([A-Za-z_$][\\\\w$]*)\\\\s*(?::[^=]+)?=\\\\s*[\\\"'](?:${names})[\\\"']`,\n );\n const arrayBind = new RegExp(\n `\\\\b(?:const|let|var)\\\\s+([A-Za-z_$][\\\\w$]*)\\\\s*(?::[^=]+)?=\\\\s*\\\\[[^\\\\]]*[\\\"'](?:${names})[\\\"']`,\n );\n const tainted = new Set();\n for (const line of lines) {\n const s = stringBind.exec(line);\n if (s) tainted.add(s[1]);\n const a = arrayBind.exec(line);\n if (a) tainted.add(a[1]);\n }\n return tainted;\n}\n\nfunction ruleFor(line: string, file: string, tainted: Set): QualityRule | undefined {\n // Template/fixture source strings are data, not syntax in the scanned module.\n if (/^\\s*[`'\\\"]/.test(line)) return undefined;\n if (/deno-lint-ignore(?:-file)?\\s+no-explicit-any/.test(line)) return 'explicit-any-ignore';\n if (/@ts-(?:ignore|expect-error|nocheck)\\b/.test(line)) return 'ts-error-suppression';\n if (/\\bas\\s+unknown\\s+as\\b|\\bas\\s+any\\b|\\bas\\s+never\\b/.test(line)) return 'unsafe-cast';\n if (/(?:<|:\\s*)any(?:\\s*[,>;)\\]}]|\\b)/.test(line)) return 'explicit-any';\n // Host-side plugin identity: equality/predicate against a plugin name whether\n // written as a quoted literal OR a same-file identifier bound to one (const\n // indirection). Requiring the closing quote on literals keeps `'auth-backend'`\n // (a capability id) from matching the `auth` plugin name.\n if (file.includes('/features/plugins/')) {\n const names = PLUGIN_NAMES.join('|');\n const literalEquality = new RegExp(\n `(?:===|!==)\\\\s*[\\\"'](?:${names})[\\\"']|[\\\"'](?:${names})[\\\"']\\\\s*(?:===|!==)`,\n );\n const literalPredicate = new RegExp(\n `\\\\.(?:startsWith|endsWith|includes)\\\\(\\\\s*[\\\"'](?:${names})[\\\"']`,\n );\n if (literalEquality.test(line) || literalPredicate.test(line)) return 'plugin-name-check';\n if (tainted.size > 0) {\n const idents = [...tainted].map((id) => id.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')).join('|');\n // `.name`/`kind` compared to a tainted ident, or a predicate on it, or a\n // tainted array `.includes(plugin.name)`.\n const identEquality = new RegExp(\n `(?:\\\\.name|\\\\bkind)\\\\s*(?:===|!==)\\\\s*(?:${idents})\\\\b|\\\\b(?:${idents})\\\\s*(?:===|!==)\\\\s*(?:[\\\\w.]*\\\\.name|kind)\\\\b`,\n );\n const identPredicate = new RegExp(\n `\\\\.(?:startsWith|endsWith|includes)\\\\(\\\\s*(?:${idents})\\\\b`,\n );\n const arrayIncludes = new RegExp(\n `\\\\b(?:${idents})\\\\.includes\\\\(\\\\s*[\\\\w.]*(?:\\\\.name|kind)\\\\b`,\n );\n if (identEquality.test(line) || identPredicate.test(line) || arrayIncludes.test(line)) {\n return 'plugin-name-check';\n }\n }\n }\n return undefined;\n}\n\nfunction isTypeFixture(file: string): boolean {\n const normalized = file.replaceAll('\\\\', '/');\n return normalized.includes('/tests/type-fixtures/') && normalized.endsWith('_type.ts');\n}\n\nfunction isScannable(file: string): boolean {\n return /\\.[cm]?[jt]sx?$/.test(file) && !/(?:_test|\\.test|\\.spec)\\.[cm]?[jt]sx?$/.test(file) &&\n !file.endsWith('.generated.ts') && !isTypeFixture(file);\n}\n\nasync function collect(path: string): Promise {\n try {\n const stat = await Deno.stat(path);\n if (stat.isFile) return isScannable(path) ? [path] : [];\n } catch {\n return [];\n }\n const files: string[] = [];\n for await (const entry of Deno.readDir(path)) {\n const child = resolve(path, entry.name);\n if (entry.isDirectory) files.push(...await collect(child));\n else if (entry.isFile && isScannable(child)) files.push(child);\n }\n return files;\n}\n\n/** A reasoned `// quality-allow:` suppression the scanner honored. */\nexport interface QualityAllowance {\n readonly file: string;\n readonly line: number;\n readonly reason: string;\n}\n\n/** Full scan result: real findings plus every honored allowance (for audit). */\nexport interface QualityScan {\n readonly findings: QualityFinding[];\n readonly allowances: QualityAllowance[];\n}\n\n/** Scan selected source paths, returning findings and honored allowances. */\nexport async function scanCodeQualityDetailed(\n paths: readonly string[],\n cwd: string = Deno.cwd(),\n): Promise {\n const files = (await Promise.all(paths.map((path) => collect(resolve(cwd, path))))).flat();\n const findings: QualityFinding[] = [];\n const allowances: QualityAllowance[] = [];\n for (const file of files) {\n const lines = (await Deno.readTextFile(file)).split(/\\r?\\n/);\n const normalized = file.replaceAll('\\\\', '/');\n const tainted = normalized.includes('/features/plugins/')\n ? collectPluginNameIdents(lines)\n : EMPTY_TAINT;\n for (let index = 0; index < lines.length; index++) {\n const line = lines[index];\n const allowance = line.match(/\\/\\/\\s*quality-allow:\\s*(.+)$/);\n if (allowance?.[1].trim()) {\n // A quality-allow only suppresses a line that would otherwise fire a\n // rule — an allowance on a clean line is dead weight, not counted.\n if (ruleFor(line.replace(/\\/\\/\\s*quality-allow:.*$/, ''), normalized, tainted)) {\n allowances.push({\n file: relative(cwd, file),\n line: index + 1,\n reason: allowance[1].trim(),\n });\n }\n continue;\n }\n const rule = ruleFor(line, normalized, tainted);\n if (rule) {\n findings.push({ rule, file: relative(cwd, file), line: index + 1, text: line.trim() });\n }\n }\n }\n findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);\n return { findings, allowances };\n}\n\n/** Scan selected source paths for code-quality violations. */\nexport async function scanCodeQuality(\n paths: readonly string[],\n cwd: string = Deno.cwd(),\n): Promise {\n return (await scanCodeQualityDetailed(paths, cwd)).findings;\n}\n\nif (import.meta.main) {\n const pretty = Deno.args.includes('--pretty');\n const changed = Deno.args.flatMap((arg, index, args) =>\n arg === '--changed-file' ? [args[index + 1]] : []\n );\n const roots = Deno.args.flatMap((arg, index, args) => arg === '--root' ? [args[index + 1]] : []);\n const maxAllowArg = Deno.args.flatMap((arg, index, args) =>\n arg === '--max-allow' ? [args[index + 1]] : []\n )[0];\n const maxAllow = maxAllowArg === undefined ? undefined : Number(maxAllowArg);\n const mode = changed.length > 0 ? 'changed-files' : 'repository';\n const scanned = changed.length > 0 ? changed : roots.length > 0 ? roots : DEFAULT_ROOTS;\n const { findings, allowances } = await scanCodeQualityDetailed(scanned);\n const allowExceeded = maxAllow !== undefined && Number.isFinite(maxAllow) &&\n allowances.length > maxAllow;\n const result = {\n ok: findings.length === 0 && !allowExceeded,\n mode,\n scanned,\n findings,\n allowCount: allowances.length,\n allowances,\n ...(allowExceeded ? { allowLimitExceeded: { limit: maxAllow, count: allowances.length } } : {}),\n };\n console.log(JSON.stringify(result, null, pretty ? 2 : undefined));\n if (!result.ok) Deno.exit(1);\n}\n", 'deps/outdated.ts': "/**\n * deps/outdated.ts — structured wrapper over `deno outdated`.\n *\n * `deno outdated` has no `--json` flag, and its `--latest` view includes\n * pre-release tags (see deps/latest.ts for why that misleads). This wrapper runs\n * `deno outdated --recursive [--latest]`, parses the box-drawing table into JSON,\n * and (for the --latest view) flags rows whose \"Latest\" is a pre-release so a\n * reader does not mistake `2.3.0-dev.*` for a real release.\n *\n * Use deps/latest.ts for the authoritative \"latest stable\" decision; use this for\n * the lock-aware inventory (it surfaces transitive/locked entries latest.ts does\n * not, because it reads the resolved graph rather than declared imports).\n *\n * Usage:\n * deno run --allow-read --allow-run .llm/tools/deps/outdated.ts [--latest] [--pretty]\n */\n\ninterface Row {\n package: string;\n current: string;\n update: string;\n latest: string;\n latestIsPrerelease: boolean;\n}\n\nfunction parseArgs(argv: string[]): { latest: boolean; pretty: boolean } {\n return { latest: argv.includes('--latest'), pretty: argv.includes('--pretty') };\n}\n\nfunction parseTable(stdout: string): Row[] {\n const rows: Row[] = [];\n for (const line of stdout.split('\\n')) {\n if (!line.includes('│')) continue;\n const cells = line.split('│').map((cell) => cell.trim()).filter((cell) => cell.length > 0);\n if (cells.length < 4) continue;\n if (cells[0].toLowerCase() === 'package') continue; // header\n const [pkg, current, update, latest] = cells;\n rows.push({\n package: pkg,\n current,\n update,\n latest,\n latestIsPrerelease: latest.includes('-'),\n });\n }\n return rows;\n}\n\nasync function main() {\n const args = parseArgs(Deno.args);\n const cmdArgs = ['outdated', '--recursive'];\n if (args.latest) cmdArgs.push('--latest');\n const command = new Deno.Command('deno', { args: cmdArgs, stdout: 'piped', stderr: 'piped' });\n const output = await command.output();\n const stdout = new TextDecoder().decode(output.stdout);\n const rows = parseTable(stdout);\n const result = {\n generatedAt: new Date().toISOString(),\n mode: args.latest ? 'latest' : 'compatible',\n count: rows.length,\n prereleaseLatest: rows.filter((row) => row.latestIsPrerelease).map((row) => row.package),\n rows,\n };\n\n if (args.pretty) {\n console.log(`deps:outdated (${result.mode}) — ${rows.length} rows`);\n for (const row of rows) {\n const warn = row.latestIsPrerelease ? ' [prerelease — use deps:latest]' : '';\n console.log(` ${row.package} ${row.current} → ${row.latest}${warn}`);\n }\n } else {\n console.log(JSON.stringify(result, null, 2));\n }\n Deno.exit(output.code);\n}\n\nfunction printHelp(): void {\n console.log(\n [\n 'deps/outdated.ts — structured JSON wrapper over `deno outdated --recursive`',\n '',\n 'Usage:',\n ' deno run --allow-read --allow-run .llm/tools/deps/outdated.ts [flags]',\n '',\n 'Flags:',\n ' --latest include the --latest view (flags prerelease \"Latest\" rows)',\n ' --pretty human-readable list instead of JSON',\n ' --help, -h show this help',\n '',\n 'Output (default): JSON { generatedAt, mode, count, prereleaseLatest, rows }.',\n ].join('\\n'),\n );\n}\n\nif (import.meta.main) {\n if (Deno.args.includes('--help') || Deno.args.includes('-h')) {\n printHelp();\n Deno.exit(0);\n }\n await main();\n}\n", 'deps/why.ts': @@ -49,4 +49,4 @@ export const EMBEDDED_AGENT_TOOL_PATHS: readonly string[] = [ /** SHA-256 of canonical manifest-ordered agent-tool paths and content. */ export const EMBEDDED_AGENT_TOOL_BUNDLE_HASH: string = - '9c453efff624aec3107bd7f128023a116099bea748e25267ed855f647fbf5c0b'; + '45fc5b94049bc70e1bd3241f025fa27a9b4d48a02d77225686b36b4d4fea6bba';