diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14c89b4..6d4694e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,24 +16,12 @@ jobs: - name: Install dependencies run: bun install - - name: Lint - run: bun run lint - - - name: Typecheck - run: bun run typecheck - - - name: Aggregate quality checks + - name: Quality gates (check:all) run: bun run check:all - - name: Generated CLI docs are up to date - run: bun run gen:docs:check - - name: Run tests (CI reporter) run: bun run test:ci - - name: Coverage ratchet - run: bun run check:coverage - - name: Test timing summary if: always() run: bun run report:test-timing diff --git a/.mulch/expertise/quality.jsonl b/.mulch/expertise/quality.jsonl new file mode 100644 index 0000000..2ec7af9 --- /dev/null +++ b/.mulch/expertise/quality.jsonl @@ -0,0 +1,2 @@ +{"type":"decision","classification":"tactical","recorded_at":"2026-06-12T05:48:44.323Z","evidence":{"seeds":"pl-a59c","commit":"a2443728bb772cf13238f6666e1321995de8999b"},"title":"Byte-identical check:all runner with biome formatter override","rationale":"The fleet standard freezes scripts/check-all.ts and scripts/check-ci-parity.ts byte-identical; canopy's biome formatter would reformat them, so the sanctioned fix (matching plot/seeds) is a biome.json override disabling the formatter for exactly those two files rather than editing the scripts.","id":"mx-2e4545"} +{"type":"failure","classification":"tactical","recorded_at":"2026-06-12T05:48:44.523Z","evidence":{"commit":"a2443728bb772cf13238f6666e1321995de8999b"},"description":"Template l5-toolkit scripts fail canopy lint as copied: check-all.ts/check-ci-parity.ts violate the biome formatter at lineWidth 100, and the ratchet test files have unsorted imports under organizeImports.","resolution":"Added a biome.json override disabling the formatter for the two byte-identical scripts (fleet pattern from plot/seeds), ran biome check --write on the non-frozen ratchet test files, and upgraded @biomejs/biome 2.4.6 -> ^2.4.14 (resolved 2.4.16) with a matching $schema bump.","id":"mx-42bf1f"} diff --git a/.mulch/mulch.config.yaml b/.mulch/mulch.config.yaml index d80495c..66ec683 100644 --- a/.mulch/mulch.config.yaml +++ b/.mulch/mulch.config.yaml @@ -1,8 +1,9 @@ version: '1' domains: - - cicd - - canopy - - architecture + cicd: {} + canopy: {} + architecture: {} + quality: {} governance: max_entries: 100 warn_entries: 150 diff --git a/.seeds/issues.jsonl b/.seeds/issues.jsonl index 5589be6..6f90eac 100644 --- a/.seeds/issues.jsonl +++ b/.seeds/issues.jsonl @@ -95,3 +95,7 @@ {"id":"canopy-912f","title":"cn setup pi recipe + pi-aware onboarding marker variant (ONBOARD_VERSION 2→3, :pi suffix)","status":"open","type":"task","priority":2,"plan_step_index":4,"description":"\nStep 5 of plan pl-c2a8.\n\nParent seed: canopy-a29b — Build pi-canopy extension — prompts as pi slash commands\nPlan template: feature\nPlan approach: Ship inside `@os-eco/canopy-cli` (Option A from the investigation doc) — adds `extensions/pi/{index.ts, lib/*.ts, README.md}`, plus a `pi` manifest, the `pi-package` keyword, and `peerDependencies` + `peerDependenciesMeta.optional: true`…\n\nRun `sd plan show pl-c2a8` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-05-15T22:04:29.570Z","updatedAt":"2026-05-15T22:04:29.570Z","plan_id":"pl-c2a8","blockedBy":["canopy-94fc"],"blocks":["canopy-4e3d","canopy-a29b"]} {"id":"canopy-4e3d","title":"Tests + README","status":"open","type":"task","priority":2,"plan_step_index":5,"description":"\nStep 6 of plan pl-c2a8.\n\nParent seed: canopy-a29b — Build pi-canopy extension — prompts as pi slash commands\nPlan template: feature\nPlan approach: Ship inside `@os-eco/canopy-cli` (Option A from the investigation doc) — adds `extensions/pi/{index.ts, lib/*.ts, README.md}`, plus a `pi` manifest, the `pi-package` keyword, and `peerDependencies` + `peerDependenciesMeta.optional: true`…\n\nRun `sd plan show pl-c2a8` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-05-15T22:04:29.570Z","updatedAt":"2026-05-15T22:04:29.570Z","plan_id":"pl-c2a8","blockedBy":["canopy-714e","canopy-f6b9","canopy-bb56","canopy-912f"],"blocks":["canopy-a29b"]} {"id":"canopy-da2c","title":"Cancel pi-canopy extension build before any code lands","status":"open","type":"task","priority":2,"createdAt":"2026-05-17T18:51:12.189Z","updatedAt":"2026-05-17T18:51:12.189Z","description":"Sibling decision to mulch-18e4 (mulch repo, revert) and seeds-2f21 (seeds repo, revert). Canopy is the easy case: no extension code has been written yet — the plan pl-c2a8 is approved with 6 open child seeds (canopy-94fc, canopy-714e, canopy-f6b9, canopy-bb56, canopy-912f, canopy-4e3d) but step 1 (foundation) has not started. Closing them out before any code lands is a clean prevention rather than a rollback.\n\n## Why (decision rationale)\n\nAudit of the mulch pi extension (this session, 2026-05-15) plus a philosophy discussion with the owner concluded that pi extensions are not the right integration surface for any of the three os-eco primitives (mulch / seeds / canopy):\n\n1. **The mulch audit found the install recipe was silently broken** — `ml setup pi` wrote bare `@os-eco/mulch-cli` to .pi/settings.json, but pi's resolver only auto-installs entries with the `npm:` prefix. Verified empirically. The seeds recipe uses the same pattern. The canopy recipe (planned as step 5 of pl-c2a8) would have inherited the same bug. Distribution complexity for pi packages is a real cost we keep paying.\n\n2. **Extensions aren't needed.** CLAUDE.md is the universal substrate — auto-discovered by pi, Claude Code, Cursor, Codex, every coding agent. The canopy CLI is clean (`cn list`, `cn render --json`, `cn emit`); agents are very good at calling CLIs via bash. The qualitative differentiator pi-canopy would have added — `resources_discover` rendering every prompt as a native slash command — is cosmetic; the agent can already run `cn render ` itself.\n\n3. **For canopy specifically, the marginal value is lowest of the three.** Mulch had a (weak) case for systemPrompt-cached prime via `before_agent_start`. Seeds had a case for the status widget showing ready/in-progress/blocked counts (human UX). Canopy is a passive prompt library — there is no comparable surface. The plan even acknowledges this: `canopy-a29b` explicitly excludes auto-prime (Appendix A of the investigation doc rejected `cn prime --json`) and explicitly excludes a status widget (alternative #9 in pl-c2a8: 'canopy is a passive library — no work-queue state worth surfacing'). What remains is `resources_discover` + 14 cn_* tool wrappers + 9 slash commands — all of which are reachable via bash + the existing CLI.\n\nThe plan's own framing ('the LLM rarely emits, rarely updates') is a symptom of agents needing better CLAUDE.md prose, not a pi-specific code path. Fix the prose, ship the value uniformly across all runtimes.\n\n## What to do\n\nNo code rollback (no code has landed). Close out the plan and its children with cross-references.\n\n```bash\n# 1. close the 6 child seeds\nsd close canopy-94fc canopy-714e canopy-f6b9 canopy-bb56 canopy-912f canopy-4e3d \\\n --reason 'pi-canopy extension build cancelled; see . CLI + CLAUDE.md remain the integration surface for all agent runtimes.'\n\n# 2. close the parent feature seed\nsd close canopy-a29b --reason 'pi-canopy extension build cancelled; see '\n\n# 3. handle the plan record\n# sd plan does not currently support a 'cancelled' status — options:\n# (a) sd plan outcome pl-c2a8 --outcome cancelled (if supported)\n# (b) leave pl-c2a8 with status 'approved' and a comment, since all children are closed\n# (c) hand-edit .seeds/plans.jsonl to add an outcome row\n# Verify what's available with: sd plan --help\n```\n\n## What stays in canopy (no work needed)\n\n- `cn prime` continues to work as a tool call agents can invoke from bash.\n- `cn list` / `cn render` / `cn emit` / `cn update` continue to work via bash.\n- The `` onboarding snippet in CLAUDE.md remains as-is (non-pi variant).\n- The `cn config` work that landed in pl-f13b (canopy-9e5b) stays — that was useful CLI infrastructure regardless of pi.\n\n## Out of scope (file separately if any of this is wanted)\n\n- **Document the CLAUDE.md-only integration pattern** — same docs work as the sibling rollbacks. Worth doing once across all three primitives in os-eco/docs/ rather than three times.\n- **Improve `cn prime` output** so it carries enough context to nudge agents toward `cn list` / `cn render` workflows. The current prime is terse; making it richer is a higher-leverage move than building an extension.\n- **Drop the `pi-extensions-investigation.md` doc** (in os-eco root) or annotate it with a 'superseded — see + mulch-18e4 + seeds-2f21' header.\n\n## Acceptance\n\n- canopy-a29b closed with cross-reference to this seed.\n- All 6 children of pl-c2a8 closed with cross-reference.\n- pl-c2a8 marked cancelled / not in progress (mechanism TBD per `sd plan --help`).\n- No `extensions/` directory exists in canopy/.\n- No `pi` manifest field or peerDependencies in canopy/package.json.\n- No `cn setup` command added (it was step 5 of the plan — leave as future-if-needed).\n- Existing canopy CLI (`cn prime`, `cn list`, `cn render`, etc.) unchanged.\n\n## Sibling references\n\n- mulch-18e4 — Roll back pi extension (mulch repo, revert)\n- seeds-2f21 — Roll back pi-seeds extension (seeds repo, revert)\n- pl-c2a8, canopy-a29b — superseded by this seed","labels":["pi-extension","rollback","audit-followup"]} +{"id":"canopy-9e11","title":"Adopt canonical check:all standard","status":"open","type":"feature","priority":2,"createdAt":"2026-06-12T04:51:00.628Z","updatedAt":"2026-06-12T05:48:19.548Z","plan_id":"pl-a59c"} +{"id":"canopy-3b09","title":"Add the canonical scripts/check-all.ts quiet runner copied byte-identical from templates/l5-toolkit/scripts/check-all.ts, with canopy's exported GATES manifest in the standard's order: lint, typecheck, check:agents, check:dups, check:deps, check:size, check:debt, gen:docs:check, check:coverage, check:ci-parity (last) -- note check:coverage and the conditional gen:docs:check gate must be ADDED to the manifest (they exist as scripts but are absent from the current 5-gate && chain). Replace package.json check:all with `bun scripts/check-all.ts`, add `verify`: `bun run check:all`, and add scripts/check-all.test.ts. If check-debt-markers.ts / check-file-sizes.ts lack co-located tests, add them for parity with the other repos. Confirm the quiet-output contract.","status":"closed","type":"task","priority":2,"plan_step_index":0,"description":"\nStep 1 of plan pl-a59c.\n\nParent seed: canopy-9e11 — Adopt canonical check:all standard\nPlan template: feature\nPlan approach: Swap the && chain for the canonical quiet runner with a GATES manifest in the standard's order, adding check:coverage (currently absent from the chain) and the conditional gen:docs:check gate (canopy is a doc generator) slotted before…\n\nRun `sd plan show pl-a59c` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-12T04:53:29.073Z","updatedAt":"2026-06-12T05:48:19.434Z","plan_id":"pl-a59c","blocks":["canopy-b9ee","canopy-9e11"],"closedAt":"2026-06-12T05:48:19.434Z"} +{"id":"canopy-b9ee","title":"Add scripts/check-ci-parity.ts (with test) copied from templates/l5-toolkit/scripts, importing the GATES array from check-all.ts; add the check:ci-parity script as the final gate in the manifest. Reconcile any residual non-canonical gate name. Verify check:ci-parity passes against .github/workflows/ci.yml.","status":"closed","type":"task","priority":2,"plan_step_index":1,"description":"\nStep 2 of plan pl-a59c.\n\nParent seed: canopy-9e11 — Adopt canonical check:all standard\nPlan template: feature\nPlan approach: Swap the && chain for the canonical quiet runner with a GATES manifest in the standard's order, adding check:coverage (currently absent from the chain) and the conditional gen:docs:check gate (canopy is a doc generator) slotted before…\n\nRun `sd plan show pl-a59c` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-12T04:53:29.073Z","updatedAt":"2026-06-12T05:48:19.491Z","plan_id":"pl-a59c","blocks":["canopy-e548","canopy-9e11"],"closedAt":"2026-06-12T05:48:19.491Z"} +{"id":"canopy-e548","title":"Wire .github/workflows/ci.yml to invoke the canonical gates (or `bun run check:all`) so the local manifest matches CI. Run `bun run check:all` and `bun run verify` green end-to-end with the quiet-output contract and a passing check:ci-parity.","status":"closed","type":"task","priority":2,"plan_step_index":2,"description":"\nStep 3 of plan pl-a59c.\n\nParent seed: canopy-9e11 — Adopt canonical check:all standard\nPlan template: feature\nPlan approach: Swap the && chain for the canonical quiet runner with a GATES manifest in the standard's order, adding check:coverage (currently absent from the chain) and the conditional gen:docs:check gate (canopy is a doc generator) slotted before…\n\nRun `sd plan show pl-a59c` for the full plan (context, alternatives, sibling steps, acceptance criteria).\n","createdAt":"2026-06-12T04:53:29.073Z","updatedAt":"2026-06-12T05:48:19.548Z","plan_id":"pl-a59c","blocks":["canopy-9e11"],"closedAt":"2026-06-12T05:48:19.548Z"} diff --git a/.seeds/plans.jsonl b/.seeds/plans.jsonl index d259728..b9654a2 100644 --- a/.seeds/plans.jsonl +++ b/.seeds/plans.jsonl @@ -1,3 +1,4 @@ {"id":"pl-003f","seed":"canopy-f58b","template":"feature","status":"done","revision":1,"sections":{"context":"Warren V2 (warren ROADMAP item R-11) needs canopy roles to declare which mulch domains and file globs matter for a role's work. Today canopy roles and mulch records are disconnected — each session a human or agent runs `ml prime` separately and hopes the right expertise loads. Roles defined for a given codebase's expertise should travel with their dependencies as part of the role definition. Canopy is the right home because (a) the role's mulch dependencies are part of the role's identity and should be inheritable, and (b) canopy's existing inheritance machinery is the natural place to resolve opt-in `extends_mulch` merging across the role tree. Critical purity constraint: canopy must NOT shell out to mulch at render time; it only emits the resolved declaration as metadata in the rendered envelope. Warren consumes the resolved declaration at run-spawn time and runs `ml prime` itself. Keeping canopy mulch-agnostic preserves canopy as a pure prompt template engine.","approach":"Add a top-level `extends_mulch: boolean` flag and a structured `mulch:` block (with `prime.domains[]`, `prime.files[]`, `budget`, `on_empty`) to the prompt frontmatter schema. Treat the `mulch:` block as a first-class field on `Prompt` (alongside `extends`, `mixins`, `frontmatter`) so the inheritance resolver can apply special merge semantics: by default the child's `mulch:` wholesale overrides the parent's (predictable, no surprise inherited deps); with `extends_mulch: true` the resolver unions `domains`/`files` and applies last-wins for `budget`/`on_empty`. Multi-level merging applies pairwise up the chain, each level's flag controlling its own merge with its parent. `cn render --json` surfaces the resolved declaration as a top-level `mulch` field (absent — not null/empty — when no role in the chain declares one). No shell-out to `ml`. Mixins follow the same merge semantics as `extends_mulch` for consistency, but only when the focal prompt opts in via `extends_mulch: true` on the mixin contributions (open question — see alternatives). Validation lives in the existing schema layer: malformed declarations (unknown `on_empty` value, non-array `domains`, non-number `budget`) yield clear errors. An optional `doctor` check validates `domains` against `.mulch/mulch.config.yaml` when mulch is present, and degrades to a no-op when absent.","alternatives":[{"name":"Store `mulch:` inside generic `frontmatter` Record","rejected_because":"Inheritance resolver would need to special-case keys inside an opaque map. Promoting `mulch` to a typed top-level field on `Prompt` keeps the merge logic localized and statically typed, and keeps `frontmatter` as a true escape hatch for arbitrary metadata."},{"name":"Have canopy run `ml prime` itself at render time and embed the output","rejected_because":"Violates canopy's purity constraint (it would become an expertise consumer, not just a prompt template engine). Also forces an `ml` runtime dependency on every render, breaks `--json` determinism, and couples release cadence to mulch."},{"name":"Default to merge semantics (omit flag means union)","rejected_because":"Surprises users with inherited expertise dependencies they didn't ask for. Override-by-default mirrors how most languages treat inheritance of declarative metadata and matches canopy's existing `frontmatter` shallow-merge intuition."},{"name":"Use `mulch.merge: true` instead of top-level `extends_mulch:`","rejected_because":"Less symmetric with the existing top-level `extends:` keyword. `extends_mulch:` reads as 'this prompt extends its parent's mulch declaration,' which mirrors the existing inheritance vocabulary."},{"name":"Apply `extends_mulch:` to mixins as well","rejected_because":"Open question — current plan defers mixin merge semantics to a follow-up. Mixin `mulch:` contributions are unioned-then-overridden by focal's wholesale-or-merged result, matching how mixin sections work today (mixin → focal). Re-evaluate after first integration with warren."}],"steps":[{"title":"Extend Prompt type and JSONL store schema with `mulch` and `extends_mulch` fields","description":"Add `MulchDeclaration` interface in src/types.ts (with `prime: { domains?: string[]; files?: string[] }; budget?: number; on_empty?: \"skip\"|\"warn\"|\"error\"`). Add `mulch?: MulchDeclaration` and `extends_mulch?: boolean` as optional fields on `Prompt`. Update store read/write paths to round-trip these fields through JSONL. No behavior changes yet — wiring only. Verify `cn show ` and `cn render ` still work for prompts that don't declare these fields.","blocks":[1,2,3]},{"title":"Implement `extends_mulch` merge semantics in resolveInner (render.ts)","description":"Add `mulch?: MulchDeclaration` to `RenderResult`. In resolveInner: when the focal prompt has no `extends_mulch:` (or it's false), child's `mulch` wholesale replaces parent's resolved mulch. When `extends_mulch: true`, deep-merge: `prime.domains` and `prime.files` arrays union (preserve order, dedupe); `budget` last-wins (child overrides parent if defined); `on_empty` last-wins. Apply pairwise up the inheritance chain — each level's flag controls its own merge. Mixins: contribute their resolved `mulch` left-to-right under whatever rule the focal prompt chose for its parent (document this; tests cover diamond cases). Resolved mulch is `undefined` (not `{}`) when no role in the chain declared it. Add unit tests in render.test.ts: override default, merge with flag, multi-level chain, mixin contributions, absent-when-undeclared.","blocks":[2,3,4,5]},{"title":"Surface resolved `mulch` in `cn render --json` envelope (top-level field)","description":"Update src/commands/render.ts to include `mulch: result.mulch` in both the `--json` and `--format json` outputs. Omit the field entirely (not `null`, not `{}`) when the resolver returns `undefined` — warren needs to distinguish 'no mulch dependencies' from 'empty list'. Update `cn show` to display the resolved declaration when present (read-only summary, mirrors how frontmatter is shown). Add integration tests in render command tests covering: present + override, present + merged, absent. Confirm canopy never invokes ml/exec at render time.","blocks":[4,5]},{"title":"Add schema validation for `mulch` block and `extends_mulch` flag","description":"In src/validate.ts (or a new helper called from create/update/doctor): validate `mulch.prime.domains` is `string[]`, `mulch.prime.files` is `string[]`, `mulch.budget` is a positive number, `mulch.on_empty` ∈ {skip, warn, error}, and `extends_mulch` is boolean. Reject unknown keys under `mulch.*` with a clear error pointing at the offending key. Wire into `cn create` / `cn update` rejection paths and add a doctor check `mulch-declaration` that scans all prompts. Add tests covering each malformed shape.","blocks":[5]},{"title":"Optional doctor check: validate domain names against `.mulch/mulch.config.yaml`","description":"Add a doctor sub-check that, when `.mulch/mulch.config.yaml` exists in the cwd, parses it (using the existing yaml.ts), extracts known domain names, and warns (not fails) for any prompt declaring a domain not in that list. Degrade to a no-op (with a 'mulch not present, skipping domain validation' info line in verbose mode) when the file is absent or unparseable. Glob patterns are NOT validated here — leave glob expansion for warren spawn time. Test with: mulch present + valid, mulch present + typo'd domain, mulch absent.","blocks":[6]},{"title":"Update SPEC.md and prime/onboard docs to document the new schema","description":"Add a 'Mulch declarations' section to SPEC.md explaining the schema, override-vs-merge semantics, the no-shell-out invariant, and the rendered envelope shape. Update the prompt example JSONL in SPEC.md to include a sample `mulch:` block. Update `cn prime` output (commands/prime.ts) to mention the new fields in the quick-reference. No CLAUDE.md change needed unless a session-level convention emerges.","blocks":[6]},{"title":"End-to-end smoke test: warren-style consumption of rendered envelope","description":"Add a single integration test that creates a small prompt tree (grandparent → parent → child with `extends_mulch: true`, plus a mixin that contributes a domain), calls `cn render --json`, parses the JSON, and asserts the resolved `mulch` field matches expectations exactly (deep-equal). This is the contract test warren consumes — keep it explicit and well-commented. Bonus: assert that running render does NOT spawn any child processes (trap via mocking `Bun.spawn` / `child_process.spawn` if practical, otherwise document the invariant).","blocks":[]}],"risks":["Cycle detection (mx-dce42e): tree.ts buildTree/renderChildren already require visited Sets; the mulch resolver follows the same recursion path so any new traversal must thread the existing `visited` array — don't introduce a parallel traversal that bypasses cycle guards.","Frontmatter shallow merge precedent (mx-2166d1, mx-ac7ad4): existing `frontmatter` does shallow-merge by default; the new `mulch:` block intentionally diverges (override-by-default). Document clearly so future contributors don't 'fix' the inconsistency.","JSONL round-trip risk: adding fields to Prompt requires care — older records on disk lack the new fields, so reads must default to undefined and writes must omit unset keys (don't serialize `mulch: undefined` or `extends_mulch: false` for unset cases) to keep diffs clean.","Schema-forward compat: warren's consumer code will key off the presence of `mulch` in the render envelope. Make sure absent-vs-empty distinction is preserved through every code path (resolver, render command JSON output, --format json output).","Doctor check false positives: validating against `.mulch/mulch.config.yaml` only works when canopy is invoked from the same repo as mulch. Multi-repo setups (root-level `.mulch/` in os-eco vs sub-repo `.mulch/`) may surface ambiguous results — make the check warn-not-fail and clearly label which mulch config it consulted.","Mixin merge semantics are under-specified in the seed. Picking a default now may force a breaking change later. Mitigation: pick the most conservative semantics (mixin contributions follow the focal prompt's `extends_mulch` flag) and call it out as 'subject to change' in SPEC.md until warren validates the contract."],"acceptance":["Role frontmatter accepts `mulch.prime.domains[]`, `mulch.prime.files[]`, `mulch.budget`, `mulch.on_empty`, and top-level `extends_mulch: true`; round-trips through `cn create` / `cn update` / `cn show` cleanly.","Schema validation rejects malformed declarations (bad `on_empty`, non-array domains, unknown keys under `mulch.*`) with clear, actionable error messages.","Without `extends_mulch:` (or with `extends_mulch: false`), child's `mulch:` block wholesale overrides parent's resolved mulch — no surprise inherited dependencies.","With `extends_mulch: true`, child merges additively with parent: `prime.domains` and `prime.files` union (deduped, order preserved); `budget` and `on_empty` are last-wins (child overrides if defined).","Multi-level inheritance (grandparent → parent → child) applies merge semantics pairwise, each level's flag controlling its own merge with its parent.","`cn render --json` includes the resolved `mulch:` block as a top-level field when any role in the chain declared one; the field is omitted entirely (not null, not {}) when no role declared mulch.","`cn render` does NOT shell out to `ml prime` or any external process — verified by an integration test that runs a render in a sandbox and asserts no subprocess was spawned.","Built-in templates and existing library prompts that don't declare `mulch:` continue to render unchanged — no regressions in render.test.ts or any command test.","An optional doctor check validates `domains` against `.mulch/mulch.config.yaml` when present (warn on typos), and degrades to a no-op when mulch is absent.","SPEC.md documents the new schema, override-vs-merge semantics, the no-shell-out invariant, and the rendered envelope shape, with a worked JSONL example.","All existing tests pass; new tests cover override default, merge with flag, multi-level chain, mixin contributions, absent-when-undeclared, malformed validation, and end-to-end render envelope contract."]},"children":["canopy-3ba6","canopy-400c","canopy-f027","canopy-e74d","canopy-552d","canopy-7f6d","canopy-594a"],"createdAt":"2026-05-10T17:38:39.950Z","updatedAt":"2026-05-10T18:10:00.692Z"} {"id":"pl-f13b","seed":"canopy-9e5b","template":"feature","status":"done","revision":1,"sections":{"context":"Canopy is the only one of the three primitive CLIs (mulch, seeds, canopy) without a `config` subcommand. `ml config` and `sd config` both expose a uniform 4-verb surface (schema/show/set/unset) that doubles as warren V2's wire contract (warren ROADMAP R-10): warren reads ` config schema --json`, renders a form, and writes back via ` config set `. Adding `cn config` closes the parity gap, gives warren a schema-driven editor for `.canopy/config.yaml`, and removes the last spot where users have to hand-edit a YAML file an agent could safely mutate. Canopy already has all the primitives — file-locking (acquireLock in src/store.ts), atomic writes, a YAML parser (src/yaml.ts), and a Config type (src/types.ts:63) — so this is wiring, schema, and one new command file, not new infrastructure.","approach":"Mirror `sd config` as the closer reference (smaller, uses canopy-style file lock + atomic rename rather than ml's direct ajv calls). Add a JSON Schema describing only the **canonical** targets shape (`project`, `version`, `targets: Record`). The existing `loadConfig` legacy converter (src/config.ts:36-76) keeps reading `emitDir`/`emitDirByTag` files; the first `cn config set` on a legacy project normalizes the file to the canonical shape (documented as expected behavior). Accept `ajv` as a runtime dependency — it joins `chalk` and `commander` as the third runtime dep, justified by ecosystem consistency (mulch already ships it) and being required for the warren wire contract. Implementation order: (1) ship the schema first so warren can preview it, (2) add the deps + small YAML scalar helper needed by `set`, (3) build the command on top of those, (4) wire it into index.ts, (5) add integration tests modeled on `seeds/src/commands/config.test.ts`, (6) document in SPEC.md and the README quick reference, plus record mulch patterns. Steps 1 and 2 are independent and parallelizable; step 3 fans into wiring (4), tests (5), and docs (6).","alternatives":[{"name":"Schema includes legacy emitDir/emitDirByTag fields","rejected_because":"Pollutes warren's auto-rendered form with deprecated knobs and forces every consumer to understand two shapes. The loader already migrates legacy configs transparently on read; documenting normalize-on-first-write is simpler than maintaining a dual-shape schema."},{"name":"Skip ajv, ship schema + show only","rejected_because":"Breaks parity — warren expects `set`/`unset` to round-trip validated writes. A half-implementation forces a follow-up and would leave canopy as the only primitive without write-side schema enforcement."},{"name":"Hand-roll JSON Schema validation to avoid the ajv dep","rejected_because":"Reimplementing AJV's error formatting and schema-path traversal is significant code for no benefit; mulch already pulls ajv into the ecosystem, so the marginal install cost is zero for users who have any other CLI installed."},{"name":"Port ml config (with schema-path validation + required-key pruning) instead of sd config","rejected_because":"ml's extra path-validation and required-key-aware unset are useful but add ~150 lines of code we'd need to test independently. sd config's surface is sufficient for the warren contract; we can always add the stricter guards in a follow-up if real misuse surfaces."}],"steps":[{"title":"Add src/config-schema.ts with canonical-targets-only JSON Schema + golden snapshot test","description":"Create `src/config-schema.ts` exporting `configSchema(): JSONSchema` (function form, matching seeds). Schema describes only the canonical shape: required `project` (string, minLength 1) and `version` (string, default '1'); optional `targets` as `Record` where `EmitTarget = { dir: string (required), default?: boolean, tags?: string[] }`. `additionalProperties: false` at every closed boundary. Include `$schema: \"https://json-schema.org/draft/2020-12/schema\"`, `$id`, `title`, `description`, and per-field `title`/`description` so warren's UI gets human-readable labels. Provide an `examples` block with a two-target config (a `default: true` agents target and a tag-routed `.claude/commands` target) so the warren form has a copy-to-start affordance. Add `src/config-schema.test.ts` with a golden-snapshot test that locks the emitted JSON (snapshot file checked into the repo, regenerable via `bun test --update-snapshots`). Do NOT touch `loadConfig` or the legacy converter in this step.","blocks":[3,5]},{"title":"Add ajv runtime dependency and extend src/yaml.ts with parseScalarOrFlow","description":"Run `bun add ajv` (latest stable, currently 8.x) — this is the third runtime dep alongside chalk and commander; update the CLAUDE.md \"Minimal runtime dependencies\" line to reflect the new count. Extend `src/yaml.ts` with a `parseScalarOrFlow(s: string): YamlValue` helper modeled on `seeds/src/yaml.ts:230` so users can type `cn config set targets.x.default true` and have it parse as a boolean, or `cn config set targets.x.tags '[a, b]'` and get an array. Add a `YamlValue` type union (string | number | boolean | null | YamlValue[] | { [k: string]: YamlValue }) — promote it alongside the existing `YamlMap` rather than replacing it, to avoid touching unrelated callers. Add unit tests in `src/yaml.test.ts` (or a new file) covering scalars (string, int, float, bool, null), flow arrays `[a, b]`, and flow maps `{k: v}`. Do NOT modify `parseYaml`/`serializeYaml`.","blocks":[3]},{"title":"Implement src/commands/config.ts with schema/show/set/unset subcommands","description":"Port `seeds/src/commands/config.ts` adapted to canopy's primitives. Export `registerConfigCommand(program: Command): void` following the `index-wiring-pattern` convention (mx-f949d3). Subcommands: (a) `schema` — emit `JSON.stringify(configSchema(), null, 2)` by default, single-line via `--json`; (b) `show [--path ] [--json]` — read `.canopy/config.yaml`, walk `--path` if provided, print full YAML or path scalar via `humanOut`, JSON envelope via `jsonOut` when `--json`; (c) `set [--json]` — under `acquireLock(configPath)`, read config, `setAtPath` parsed via `parseScalarOrFlow`, validate the full post-write doc against `configSchema()` with AJV (`strict: false, allErrors: true`), atomic write via temp+rename; reject the write on validation failure with a formatted error listing instancePath + message; (d) `unset [--json]` — same lock+validate+write flow, idempotent no-op when path is absent. Use `findCanopyDir()` (match existing pattern in `loadConfig`) to locate `.canopy/`. Throw `ExitError(1)` on failure inside locked blocks (failure mx-4a480a: never `process.exit` inside try/finally with a lock). Honor `--quiet` via `humanOut` (pattern mx-85550c). Use `acquireLock`/`releaseLock` from `src/store.ts` not a new lock implementation.","blocks":[4,5,6]},{"title":"Wire registerConfigCommand into src/index.ts","description":"Add the dynamic import line (`const { registerConfigCommand } = await import(\"./commands/config.ts\");`) and call `registerConfigCommand(program)` alongside the existing register block in `src/index.ts:77-120`. Place it alphabetically between `completions` and `create`. Verify `cn config --help`, `cn config schema`, `cn config show`, `cn config set foo bar`, `cn config unset foo` all parse without errors against the current `.canopy/config.yaml`. No tests in this step — wiring is exercised by step 5's integration tests.","blocks":[]},{"title":"Add src/commands/config.test.ts integration tests","description":"Follow the integration-test conventions from canopy (mx-23198f, mx-35af39): use `tmpDir = join(import.meta.dir, '../../.test-tmp-config')`, real `.canopy/` directories created via `cn init` (or direct `saveConfig`), captureOutput helper. Cover: (1) `config schema` emits valid JSON matching the golden snapshot and parses with AJV; (2) `config show` prints full YAML; with `--path project` prints scalar; with `--json --path targets.default.dir` returns JSON envelope; non-existent path returns exit 1 with clear error; (3) `config set` writes a scalar, validates against schema, atomic rename leaves no `.tmp.*` files; setting an invalid type (e.g. `set version 5` when string required) rejects with AJV error and leaves the file untouched; setting an unknown top-level key (e.g. `set bogus foo`) rejects due to `additionalProperties: false`; (4) `config unset` removes a leaf, is idempotent on already-absent paths, and rejects when the unset would remove a required field; (5) concurrency: two parallel `config set` calls on different paths both succeed (lock pattern mx-65533b — use Promise.all with 5 concurrent writers, assert final file is valid and contains all writes). Use `ExitError` assertions via captureOutput-throw pattern (mx-e33a54).","blocks":[]},{"title":"Document cn config in SPEC.md, README, and record mulch patterns","description":"SPEC.md: add a `## Config Management` section after the `### config.yaml` block (around line 65) describing the four subcommands, the dot-path syntax, the YAML-scalar parsing rule, the warren wire contract (point to mulch/seeds for analogous prior art), and the legacy-config normalize-on-first-write behavior. Update the runtime-dependency line (`chalk + commander only`) in canopy/CLAUDE.md to `chalk + commander + ajv`. README.md: add `cn config schema|show|set|unset` to the Quick Reference / commands table. After implementation lands, run `ml record canopy --type pattern --description 'cn config schema/show/set/unset mirrors sd config; canonical-targets schema only; ajv on AdditionalProperties:false; warren wire contract.' --evidence-seeds canopy-9e5b` and `ml record canopy --type decision --title 'Accept ajv as third runtime dep' --rationale 'Ecosystem parity (ml/sd ship ajv); required for warren V2 schema-driven config UI; marginal install cost zero.'`. Do NOT manually edit `.canopy/` emitted files; if any prompts need updating use `cn update`.","blocks":[]}],"risks":["Adding ajv breaks the long-standing 'chalk + commander only' invariant documented in canopy/CLAUDE.md; downstream consumers may pin canopy by total install size. Mitigation: call it out in CHANGELOG and the docs step.","Schema covers only canonical shape; users running `cn config show` on a legacy `emitDir`/`emitDirByTag` config see a normalized view (correct) but the first `cn config set` rewrites the file shape (surprising). Mitigation: documented behavior + a one-liner warning on legacy-format detection during `set`.","`additionalProperties: false` in the schema rejects forward-compat fields users might add for experimental purposes. Mitigation: this matches sd/ml; reserve `x-*` extensions in a follow-up if real need surfaces.","AJV draft 2020-12 meta-schema URI may need stripping before compile (see seeds/src/commands/config.ts:90 — `configSchema()` advertises 2020-12 but the AJV instance runs default draft-07). Mitigation: copy the same `$schema` strip on compile in canopy.","process.exit inside acquireLock blocks leaves stale .lock files (failure mx-4a480a). Mitigation: enforced ExitError pattern (mx-301cd1) — covered by the implementation step's description and asserted by the concurrency test.","yaml.ts in canopy uses a different shape (YamlMap, no YamlValue union) than seeds; adding parseScalarOrFlow without breaking existing callers requires careful type-additive changes rather than substitutions. Mitigation: introduce YamlValue alongside YamlMap, not as a replacement."],"acceptance":["`cn config --help` lists schema, show, set, unset as subcommands.","`cn config schema --json` emits valid JSON matching a checked-in golden snapshot; the schema validates against AJV's draft-07 strict:false compile.","`cn config show` round-trips a canonical config.yaml; `--path project` prints the project name; `--path targets.default.dir` prints the directory; `--json` envelopes match the sd/ml convention.","`cn config set project newname` writes the new value atomically, validates the full post-write doc, and leaves no `.tmp.*` files on success or failure.","`cn config set` rejects (a) wrong types, (b) unknown top-level keys (additionalProperties: false), (c) malformed YAML scalar values, each with a non-zero exit code and a clear schema-referenced error.","`cn config unset` is idempotent on absent paths, removes leaf values from present paths, and rejects unsets that would violate the schema's required fields.","Concurrent `cn config set` calls (Promise.all x5 on disjoint paths) all succeed and the final file is schema-valid with all writes present.","ajv is declared in package.json runtime dependencies (not devDependencies); `bun install && bun run typecheck && bun test && bun run lint` all pass cleanly.","SPEC.md has a Config Management section; README quick reference lists `cn config`; canopy/CLAUDE.md runtime-deps line is updated to include ajv.","Two mulch records filed: the schema/command pattern and the ajv-dependency decision, both linked via --evidence-seeds canopy-9e5b."]},"children":["canopy-9449","canopy-0ac0","canopy-4457","canopy-dbc7","canopy-4ae2","canopy-6c1e"],"createdAt":"2026-05-15T20:12:12.327Z","updatedAt":"2026-05-15T20:37:58.619Z","name":"cn config CLI for warren wire contract"} {"id":"pl-c2a8","seed":"canopy-a29b","template":"feature","status":"approved","revision":1,"sections":{"context":"Canopy integration with pi (the coding-agent harness becoming the daily-driver runtime) is conventional today: CLAUDE.md tells the LLM to run `cn prime`, `cn list`, `cn render`, and we hope it remembers. It rarely emits, rarely updates, and parses human output when it does shell out instead of using `--json`. A pi extension hard-wires the prompt library into pi lifecycle events — `resources_discover` registers every active prompt as a native pi slash command (no `cn emit` dance), `cn_*` structured tools so the LLM stops re-parsing human output, and a tmpdir cache invalidated on `.canopy/prompts.jsonl` mtime change. Investigation in `os-eco/docs/pi-extensions-investigation.md` §3; pi extension API surface validated against `/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent@0.74.0`. The recently-landed `cn config` (pl-f13b, commit canopy-6c1e) unblocks the `pi.*` namespace inside `.canopy/config.yaml` for one-source-of-truth knobs. Auto-prime is intentionally skipped for V1 — investigation Appendix A flagged `cn prime --json` as a single-blob (no typed sections), and §3 explicitly recommends `skip `cn prime` entirely and rely on `resources_discover` + `cn list` + `cn render`` as the better design. Sibling plans: mulch/pl-5563 (Build pi-mulch extension), seeds/pl-a1d4 (Build pi-seeds extension).","approach":"Ship inside `@os-eco/canopy-cli` (Option A from the investigation doc) — adds `extensions/pi/{index.ts, lib/*.ts, README.md}`, plus a `pi` manifest, the `pi-package` keyword, and `peerDependencies` + `peerDependenciesMeta.optional: true` in `package.json`. Mirror the seeds/mulch distribution shape exactly so the three extensions feel like siblings. Add a new `cn setup` command for parity with `ml setup` / `sd setup` — `cn setup pi` is the first recipe, writes `.pi/settings.json` idempotently, validates `pi` is on PATH, and flips the onboarding marker variant. Config knobs live under a `pi.*` namespace inside `.canopy/config.yaml` via a new `pi` block in `src/config-schema.ts` (one source of truth, file-locked atomic writes, AJV-validated; warren V2's schema-driven config UI inherits them for free). Add `findCanopyDir` (analog of `findSeedsDir` / `findMulchDir`) to `src/config.ts` so the extension resolves `.canopy/` to the main repo when invoked from a git worktree — this also closes a latent gap for the CLI itself. The unique-to-canopy hook is `resources_discover`: walk `.canopy/prompts.jsonl` (active prompts), render each via the in-process render module (not a `cn` subprocess — extension lives in the same tarball), write to `~/.pi/cache/canopy//.md`, return that directory. Refresh on `agent_end` when `prompts.jsonl` mtime changed; cleanup on `session_shutdown`. Tool surface is broad — 14 `cn_*` tools covering the read + write surface (list/show/render/tree/history/diff + create/update/emit/archive/pin/unpin/validate/import) so the LLM has full prompt-library access without escaping to bash; each is a thin in-process call. Slash commands are subprocess-shelled TUI wrappers: `/cn`, `/cn:list`, `/cn:render `, `/cn:update `, `/cn:show `, `/cn:tree `, `/cn:emit `, `/cn:history `, `/cn:diff `. Onboarding marker stays on the existing scheme (``, ``) — bump `ONBOARD_VERSION` 2 → 3 and emit a `:pi` suffix on the version marker when the recipe is installed, so existing `detectStatus` logic doubles as install-state detection. Status widget, reference expansion, autocomplete, auto-prime, and `vars?` substitution for `render_prompt` are all explicitly out of V1.","alternatives":[{"name":"Inject `cn prime --json` content blob into systemPrompt for auto-prime parity with seeds","rejected_because":"Investigation §3 + Appendix A explicitly recommend skipping `cn prime` for canopy — the slash-commands-via-`resources_discover` design carries the same expertise (the prompts themselves) at higher fidelity. Adding auto-prime would duplicate context and burn budget for no win. Defer to v0.2 if `cn prime` ever grows typed sections."},{"name":"Shell out to `cn` subprocess for every tool call (matches seeds' pattern)","rejected_because":"Seeds wraps `sd` because the extension is conceptually a separate consumer; canopy's extension lives in the same tarball, so in-process calls to renderPrompt/listPrompts/etc. are strictly faster, type-safe, and avoid the JSON re-parse roundtrip. Subprocess pattern is reserved for the slash commands' user-visible `cn ...` lines."},{"name":"Ship only `render_prompt` + `list_prompts` (investigation §3 minimum)","rejected_because":"Decision is to expose the broadest viable tool surface. The 14-tool surface covers read + write so the LLM can author and curate the prompt library end-to-end without bash. Each tool is a 5–20 line shim; the marginal cost per tool is tiny relative to the LLM ergonomics win."},{"name":"Sibling workspace package via bun workspaces (Option B from investigation doc)","rejected_because":"Version-locking with the CLI is the whole point. The extension calls in-process render functions; if the render contract changes, the extension must change in the same PR. One tarball, one version, cannot drift. Option B is reserved for warren in the investigation doc."},{"name":"New `` marker parallel to the existing version marker","rejected_because":"Doubles detection logic and forces a migration. A `:pi` suffix on the existing `` marker is a one-line change to markers.ts, reuses detectStatus, and means a single source of truth for install state. Same idiom mulch (pl-5563) and seeds (pl-a1d4) chose."},{"name":"Add `vars?` substitution to `render_prompt`","rejected_because":"Canopy core doesn't have variable substitution. Adding it at the extension layer creates two divergent semantics (extension vs. CLI). Drop `vars?` for V1; if variable support lands in canopy proper later, the tool wraps it transparently."},{"name":"Shadow config in `.pi/canopy.json`","rejected_because":"Would lose file-locking, schema validation, and multi-agent concurrency safety that `.canopy/config.yaml` already provides via the just-landed `cn config`. `pi.*` namespace in the existing config inherits all of it, and warren's schema-driven UI (R-10) gets pi knobs for free."},{"name":"Leave `findCanopyDir` worktree resolution for a separate seed","rejected_because":"The extension reads `.canopy/` on every session_start / agent_end; running inside a worktree would silently miss the main repo's prompts. Fixing it in this plan is a 30-line helper that also closes a latent CLI bug."},{"name":"Status widget showing prompt count (`cn: 12 prompts`)","rejected_because":"Canopy is a passive library — there's no work-queue state worth surfacing in the status line the way seeds surfaces `ready / in-progress / blocked`. Cost (widget render + refresh logic) outweighs the signal. Explicitly out of V1."},{"name":"Reference expansion (`#cn:` autocomplete + inline expansion)","rejected_because":"Without an issue-queue analog, the surface is unclear (which prompts? when?). Defer until usage proves the need; the slash-command surface from `resources_discover` covers the same workflow at lower complexity for V1."}],"steps":[{"title":"Foundation: extension skeleton, peer deps, pi.* config schema, findCanopyDir worktree helper, cn setup scaffolding","blocks":[2,3,4,5]},{"title":"resources_discover + tmpdir render lifecycle + agent_end cache invalidation","blocks":[6]},{"title":"Custom tools: 14 cn_* tools (list, show, render, tree, history, diff, create, update, emit, archive, pin, unpin, validate, import) wrapping in-process functions","blocks":[6]},{"title":"Slash commands: /cn, /cn:list, /cn:render, /cn:update, /cn:show, /cn:tree, /cn:emit, /cn:history, /cn:diff","blocks":[6]},{"title":"cn setup pi recipe + pi-aware onboarding marker variant (ONBOARD_VERSION 2→3, :pi suffix)","blocks":[6]},{"title":"Tests + README","blocks":[]}],"risks":["Pi extension API drift — pinned to ^0.74.0; minor bumps may add or remove hook fields. Watch for breaking changes in session_start event shape, registerTool definition, sendMessage delivery modes, resources_discover return shape, and promptPaths semantics. Same risk mulch's pl-5563 and seeds' pl-a1d4 call out.","resources_discover refresh semantics — pi's promptPaths contract for *dynamic* refresh mid-session is not crisply documented. Step 2 assumes either (a) resources_discover is re-invoked on demand, or (b) only the next session_start picks up changes. If (b), the cache-invalidation logic on agent_end re-renders the tmpdir but new prompts won't surface as slash commands until reload. Acceptable for V1 — document the /reload workaround in the README. Stronger fix is a filesystem watcher; deferred.","In-process render coupling — extensions/pi/lib/discover.ts and tools.ts import src/render.ts, src/store.ts, src/commands/render.ts. Fine while in-tree, but future extraction of an @os-eco/canopy-core package would force coordinated changes. Accepted tradeoff; flag if extraction is planned (same risk mulch pl-5563 calls out).","Tmpdir collision across sessions — ~/.pi/cache/canopy// assumes pi exposes a unique sessionId per session. If sessions reuse IDs (or the ID is opaque/missing), two concurrent sessions could clobber each other. Verify the pi context surface in step 2; fall back to crypto.randomUUID() if needed.","peerDependenciesMeta.optional behavior — users who `npm install -g @os-eco/canopy-cli` for the CLI alone must not see peer-dep warnings about @earendil-works/pi-coding-agent or typebox. Verify with a clean install before merging step 1; same check seeds and mulch ran.","Onboarding marker migration breakage — bumping ONBOARD_VERSION 2 → 3 marks every existing canopy-onboarded project as outdated until they re-run `cn onboard`. Intended churn pulse matching seeds + mulch. Document in CHANGELOG.","Large tool surface validation overhead — 14 tools means 14 TypeBox schemas + 14 in-process call sites + 14 test cases. Easy to ship a half-wired tool that's registered but has a broken parameter shape. Step 3 must enumerate the full set in one PR and the tests must cover the matrix; partial-completion is the most likely V1 regression.","cn setup as new command surface — adding `cn setup` introduces a new top-level command needing entries in: command router (src/index.ts), shell completions (src/commands/completions.ts), --help listing, the `cn prime` quick-reference (so the LLM knows about it). Easy to miss one. Step 1 must touch all four; verify via subprocess test of `cn --help` output and shell completions golden file.","findCanopyDir regression surface — refactoring loadConfig callers to route through findCanopyDir touches multiple commands (every command that reads config). One missed caller and that command silently breaks under worktrees. Step 1 must grep-audit all `.canopy/config.yaml` joins and replace them with the helper; existing tests should catch the rest."],"acceptance":["`pi -e ./extensions/pi/index.ts` loads cleanly in this repo with no errors and no peer-dep warnings on `npm install -g @os-eco/canopy-cli`","After session_start, every active prompt in .canopy/prompts.jsonl is rendered to ~/.pi/cache/canopy//.md and surfaces in pi's slash command list (verifiable via pi.listResources() or equivalent)","`pi.auto_discover = false` set via `cn config set pi.auto_discover false` causes the next session_start to skip the tmpdir render and return zero promptPaths","Editing .canopy/prompts.jsonl (or running `cn update`) during an active session triggers a re-render within 1s of the next agent_end; the new prompt appears on /reload","All 14 custom tools (cn_list, cn_show, cn_render, cn_tree, cn_history, cn_diff, cn_create, cn_update, cn_emit, cn_archive, cn_pin, cn_unpin, cn_validate, cn_import) are registered, visible in pi's tool list, and a happy-path call to each returns structured JSON the LLM can read without re-parsing","cn_show with a non-existent name returns a structured {success:false, error, command} envelope, not a thrown exception","cn_render resolves inheritance chains identically to `cn render --json` (golden test compares both outputs)","All 9 slash commands (/cn, /cn:list, /cn:render, /cn:update, /cn:show, /cn:tree, /cn:emit, /cn:history, /cn:diff) are registered and inject their results as display:false steer messages","`cn setup pi` writes .pi/settings.json idempotently (second run is a no-op), updates the CLAUDE.md version marker to ``, and `cn setup pi --check` returns up_to_date","`cn setup pi --remove` reverses both changes; `cn setup pi --check` returns not_installed","Snippet body in CLAUDE.md is the short pi-aware variant when the :pi suffix is present; bare snippet otherwise","Inside a `git worktree`, all extension hooks and cn_* tools resolve .canopy/ via findCanopyDir(ctx.cwd) and operate on the main repo's prompts; CLI commands (cn list, etc.) get the same fix transparently","`cn config schema` includes the new `pi` block with auto_discover, custom_tools, commands, cache.invalidate_on_write, and cache.dir properties, each with title + default; config-schema.test.ts golden snapshot updated","session_shutdown removes ~/.pi/cache/canopy// (verified by file existence check post-shutdown in tests)","`bun test && bun run lint && bun run typecheck` all green","extensions/pi/README.md exists with install, config, tools (14-row table), commands (9-row table), troubleshooting, and worktree-behavior sections"]},"children":["canopy-94fc","canopy-714e","canopy-f6b9","canopy-bb56","canopy-912f","canopy-4e3d"],"createdAt":"2026-05-15T22:04:29.570Z","updatedAt":"2026-05-15T22:04:29.570Z","name":"Build pi-canopy extension"} +{"id":"pl-a59c","seed":"canopy-9e11","template":"feature","status":"done","revision":1,"sections":{"context":"canopy has the canonical terse check scripts plus lint, typecheck, check:coverage, and a gen:docs:check doc-generator gate, but its check:all is a 5-gate && chain (check:size/debt/dups/deps/agents) that OMITS check:coverage entirely and does not run gen:docs:check, with no quiet runner, no `verify` alias, and no check:ci-parity. Conforming to docs/check-all-standard.md (os-eco-9048) means adopting the byte-identical runner (os-eco-5db7), folding in the missing coverage and doc-check gates, and adding ci-parity. Depends on root tracker pl-760e steps 1-2.","approach":"Swap the && chain for the canonical quiet runner with a GATES manifest in the standard's order, adding check:coverage (currently absent from the chain) and the conditional gen:docs:check gate (canopy is a doc generator) slotted before check:coverage, then add the generalized ci-parity gate and the verify alias.","steps":[{"title":"Add the canonical scripts/check-all.ts quiet runner copied byte-identical from templates/l5-toolkit/scripts/check-all.ts, with canopy's exported GATES manifest in the standard's order: lint, typecheck, check:agents, check:dups, check:deps, check:size, check:debt, gen:docs:check, check:coverage, check:ci-parity (last) -- note check:coverage and the conditional gen:docs:check gate must be ADDED to the manifest (they exist as scripts but are absent from the current 5-gate && chain). Replace package.json check:all with `bun scripts/check-all.ts`, add `verify`: `bun run check:all`, and add scripts/check-all.test.ts. If check-debt-markers.ts / check-file-sizes.ts lack co-located tests, add them for parity with the other repos. Confirm the quiet-output contract.","type":"task","priority":2,"blocks":[2]},{"title":"Add scripts/check-ci-parity.ts (with test) copied from templates/l5-toolkit/scripts, importing the GATES array from check-all.ts; add the check:ci-parity script as the final gate in the manifest. Reconcile any residual non-canonical gate name. Verify check:ci-parity passes against .github/workflows/ci.yml.","type":"task","priority":2,"blocks":[3]},{"title":"Wire .github/workflows/ci.yml to invoke the canonical gates (or `bun run check:all`) so the local manifest matches CI. Run `bun run check:all` and `bun run verify` green end-to-end with the quiet-output contract and a passing check:ci-parity.","type":"task","priority":2,"blocks":[]}],"acceptance":["check:all is `bun scripts/check-all.ts` with the byte-identical runner and a GATES manifest in the standard's order including check:coverage and gen:docs:check; verify aliases check:all.","scripts/check-ci-parity.ts imports GATES and passes against .github/workflows/ci.yml.","`bun run check:all` runs green with the quiet-output contract."]},"children":["canopy-3b09","canopy-b9ee","canopy-e548"],"createdAt":"2026-06-12T04:53:29.073Z","updatedAt":"2026-06-12T05:48:19.605Z","name":"Adopt canonical check:all standard (canopy)","outcome":"success"} diff --git a/AGENTS.md b/AGENTS.md index 330e2b8..c377fc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,9 @@ canopy/ │ ├── yaml.ts # minimal built-in YAML parser │ └── ... ├── scripts/ # quality-gate scripts (size, debt, coverage, …) +│ ├── check-all.ts # canonical quiet runner (byte-identical fleet-wide) +│ ├── check-ci-parity.ts # CI ⇄ check:all parity gate (byte-identical fleet-wide) +│ ├── ci-parity-config.json # per-repo parity escape hatches (aliases / ciOnly) │ ├── validate-agents-md.ts # validates this file's references │ ├── generate-cli-docs.ts # emits docs/cli-reference.md │ ├── check-file-sizes.ts @@ -90,25 +93,44 @@ bun run test:ci # bun test with coverage + junit reporters Quality gates (each lives in `scripts/`): ```bash +bun run check:all # scripts/check-all.ts — canonical quiet runner (10 gates) +bun run verify # alias for check:all (agent-facing entry point) bun run check:size # scripts/check-file-sizes.ts bun run check:debt # scripts/check-debt-markers.ts +bun run check:dups # jscpd duplication budget +bun run check:deps # knip unused/undeclared dependencies bun run check:coverage # scripts/check-coverage.ts bun run check:agents # scripts/validate-agents-md.ts (this file) +bun run check:ci-parity # scripts/check-ci-parity.ts — CI ⇄ check:all parity bun run gen:docs # emit docs/cli-reference.md from src/index.ts bun run gen:docs:check # fail CI when docs/cli-reference.md is stale bun run report:test-timing # slowest suites/tests from junit.xml bun run report:quality-metrics # consolidated quality summary ``` +`check:all` follows the os-eco check:all standard (see +check-all-standard.md under docs/ at the os-eco meta-repo root, not in +this repo): the ordered manifest +is `lint → typecheck → check:agents → check:dups → check:deps → +check:size → check:debt → gen:docs:check → check:coverage → +check:ci-parity`, with quiet one-line-per-gate output and a final +tally. `scripts/check-all.ts` and `scripts/check-ci-parity.ts` are +byte-identical fleet-wide — never edit them in place; per-repo +variation lives in `package.json` scripts and +`scripts/ci-parity-config.json`. `CHECK_ALL_VERBOSE=1` streams full +gate output; `--bail` stops at the first failure. + Each gate either passes silently or prints a remediation pointer. The ratchet scripts (`check:size`, `check:debt`, `check:coverage`) read JSON budgets from `budgets/`; the budgets are baselined from the repo's current state and only tighten over time (size + debt move down, coverage moves up). -CI invokes `bun run lint`, `bun run typecheck`, `bun test`, -`bun run check:agents`, and `bun run gen:docs:check` on every push to -`main` and every pull request (see `.github/workflows/ci.yml`). +CI invokes `bun run check:all` plus `bun run test:ci` (the same +tests+coverage gate with junit reporters) on every push to `main` and +every pull request (see `.github/workflows/ci.yml`); the +`check:ci-parity` gate proves CI and the local gate surface stay +equivalent. User-facing `cn` reference: @@ -210,19 +232,17 @@ parallel agents in different worktrees never corrupt the JSONL. ### Per-change verification -Before committing any code change run all of the following from the +Before committing any code change run the canonical aggregate from the repo root: ```bash -bun run lint -bun run typecheck -bun test -bun run check:agents -bun run gen:docs:check +bun run verify # = bun run check:all (10 gates, quiet output) ``` -All five must exit 0. CI runs the same five — local greens are the -contract. If you added a new `cn` subcommand or changed an existing +All 10 gates must exit 0. CI runs the same manifest (enforced by the +`check:ci-parity` gate) — local greens are the contract. To iterate on +a single gate, re-run it directly (e.g. `bun run typecheck` or +`bun test src/render.test.ts`). If you added a new `cn` subcommand or changed an existing flag, `gen:docs:check` will fail until you run `bun run gen:docs` and commit the updated `docs/cli-reference.md`. diff --git a/biome.json b/biome.json index bad76e0..7db1922 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", "files": { "includes": ["src/**", "scripts/**", "!**/node_modules"] }, @@ -46,6 +46,12 @@ "lineWidth": 100 }, "overrides": [ + { + "includes": ["scripts/check-all.ts", "scripts/check-ci-parity.ts"], + "formatter": { + "enabled": false + } + }, { "includes": [ "src/commands/archive.ts", diff --git a/bun.lock b/bun.lock index 5379ad1..4668a0b 100644 --- a/bun.lock +++ b/bun.lock @@ -10,31 +10,32 @@ "commander": "^14.0.3", }, "devDependencies": { - "@biomejs/biome": "^2.4.6", + "@biomejs/biome": "^2.4.14", "@types/bun": "latest", "knip": "^6.14.2", "typescript": "^5.9.0", + "yaml": "^2.9.0", }, }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.4.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.6", "@biomejs/cli-darwin-x64": "2.4.6", "@biomejs/cli-linux-arm64": "2.4.6", "@biomejs/cli-linux-arm64-musl": "2.4.6", "@biomejs/cli-linux-x64": "2.4.6", "@biomejs/cli-linux-x64-musl": "2.4.6", "@biomejs/cli-win32-arm64": "2.4.6", "@biomejs/cli-win32-x64": "2.4.6" }, "bin": { "biome": "bin/biome" } }, "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ=="], + "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], diff --git a/package.json b/package.json index b7926d0..eed0d9a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,9 @@ "check:deps": "knip --dependencies", "check:coverage": "bun run scripts/check-coverage.ts", "check:agents": "bun run scripts/validate-agents-md.ts", - "check:all": "bun run check:size && bun run check:debt && bun run check:dups && bun run check:deps && bun run check:agents", + "check:ci-parity": "bun scripts/check-ci-parity.ts", + "check:all": "bun scripts/check-all.ts", + "verify": "bun run check:all", "gen:docs": "bun run scripts/generate-cli-docs.ts", "gen:docs:check": "bun run scripts/generate-cli-docs.ts --check", "report:test-timing": "bun run scripts/report-test-timing.ts", @@ -51,10 +53,11 @@ "version:bump": "bun run scripts/version-bump.ts" }, "devDependencies": { - "@biomejs/biome": "^2.4.6", + "@biomejs/biome": "^2.4.14", "@types/bun": "latest", "knip": "^6.14.2", - "typescript": "^5.9.0" + "typescript": "^5.9.0", + "yaml": "^2.9.0" }, "dependencies": { "ajv": "^8.17.1", diff --git a/scripts/check-all.test.ts b/scripts/check-all.test.ts new file mode 100644 index 0000000..e041a6b --- /dev/null +++ b/scripts/check-all.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { + CANONICAL_GATES, + extractFailureSignatures, + formatGateLine, + GATES, + loadScripts, + resolveGates, +} from "./check-all.ts"; + +const CANONICAL_ORDER = CANONICAL_GATES.map((g) => g.name); + +describe("check-all", () => { + test("canonical order: lint first, coverage second-to-last, ci-parity last", () => { + expect(CANONICAL_ORDER[0]).toBe("lint"); + expect(CANONICAL_ORDER[CANONICAL_ORDER.length - 2]).toBe("check:coverage"); + expect(CANONICAL_ORDER[CANONICAL_ORDER.length - 1]).toBe("check:ci-parity"); + }); + + test("resolveGates includes every core gate even when scripts are missing", () => { + const gates = resolveGates({}); + expect(gates).toEqual(CANONICAL_GATES.filter((g) => !g.conditional).map((g) => g.name)); + }); + + test("resolveGates includes conditional gates only when defined, preserving order", () => { + const gates = resolveGates({ + "gen:docs:check": "bun run scripts/generate-docs.ts --check", + }); + expect(gates).toContain("gen:docs:check"); + expect(gates).not.toContain("check:bundle-size"); + expect(gates).not.toContain("gen:openapi:check"); + expect(gates.indexOf("gen:docs:check")).toBeGreaterThan(gates.indexOf("check:debt")); + expect(gates.indexOf("gen:docs:check")).toBeLessThan(gates.indexOf("check:coverage")); + }); + + test("resolveGates with all conditionals defined yields the full canonical list", () => { + const gates = resolveGates({ + "check:bundle-size": "x", + "gen:docs:check": "x", + "gen:openapi:check": "x", + }); + expect(gates).toEqual(CANONICAL_ORDER); + }); + + test("GATES is a canonical-order subsequence ending in check:ci-parity", () => { + const indices = GATES.map((g) => CANONICAL_ORDER.indexOf(g)); + expect(indices).not.toContain(-1); + expect([...indices].sort((a, b) => a - b)).toEqual(indices); + expect(GATES[GATES.length - 1]).toBe("check:ci-parity"); + }); + + test("loadScripts tolerates a missing package.json", () => { + expect(loadScripts("/nonexistent/package.json")).toEqual({}); + }); + + test("formatGateLine aligns names and renders status marks", () => { + expect(formatGateLine("ok", "lint", 1.23, 10)).toBe("✓ lint (1.2s)"); + expect(formatGateLine("fail", "check:dups", 0.05, 10)).toBe("✗ check:dups (0.1s)"); + }); + + test("extractFailureSignatures picks bun-test fail lines over noise", () => { + const output = [ + "bun test v1.2.0", + "(pass) suite > passing test [0.10ms]", + "(fail) suite > broken test [0.42ms]", + " expected 1, got 2", + "(fail) suite > other broken test [0.11ms]", + " 12 pass", + " 2 fail", + ].join("\n"); + const sig = extractFailureSignatures(output); + expect(sig).toContain("(fail) suite > broken test [0.42ms]"); + expect(sig).toContain("(fail) suite > other broken test [0.11ms]"); + expect(sig).not.toContain("(pass) suite > passing test [0.10ms]"); + }); + + test("extractFailureSignatures picks tsc error lines", () => { + const output = [ + "src/foo.ts(12,5): error TS2322: Type 'string' is not assignable to type 'number'.", + "Found 1 error.", + ].join("\n"); + expect(extractFailureSignatures(output)[0]).toContain("error TS2322"); + }); + + test("extractFailureSignatures falls back to the output tail", () => { + const output = ["line one", "", "line two", "budget exceeded somehow"].join("\n"); + const sig = extractFailureSignatures(output); + expect(sig.length).toBeGreaterThan(0); + expect(sig).not.toContain(""); + }); +}); diff --git a/scripts/check-all.ts b/scripts/check-all.ts new file mode 100644 index 0000000..d6f9daf --- /dev/null +++ b/scripts/check-all.ts @@ -0,0 +1,157 @@ +#!/usr/bin/env bun +/** + * Canonical quiet runner for the os-eco fleet `check:all` standard + * (docs/check-all-standard.md at the os-eco root, os-eco-5db7). + * + * This file is BYTE-IDENTICAL across every conforming repo — do not + * edit it in place. Per-repo variation comes exclusively from + * package.json: the runner resolves its gate manifest by filtering the + * canonical ordered gate list against the scripts the host repo + * actually defines. Core gates are mandatory (a repo missing one fails + * the run); conditional gates (check:bundle-size, gen:docs:check, + * gen:openapi:check) run only where package.json defines them. + * + * Output contract ("quiet"): + * - one aligned ` (N.Ns)` line per gate + * - a one-line tally on success + * - on failure: the failing gate names plus parsed failure + * signatures from the captured output — never the full log + * - CHECK_ALL_VERBOSE=1 streams every gate's full output instead + * - --bail stops at the first failing gate + */ + +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const PACKAGE_JSON = resolve(REPO_ROOT, "package.json"); + +export type CanonicalGate = { name: string; conditional: boolean }; + +/** + * The frozen, ordered gate vocabulary. Cheap static gates first, + * conditional gates next, the expensive test+coverage gate + * second-to-last, and the CI-parity meta-gate always LAST so it sees + * the final manifest. + */ +export const CANONICAL_GATES: readonly CanonicalGate[] = [ + { name: "lint", conditional: false }, + { name: "typecheck", conditional: false }, + { name: "check:agents", conditional: false }, + { name: "check:dups", conditional: false }, + { name: "check:deps", conditional: false }, + { name: "check:size", conditional: false }, + { name: "check:debt", conditional: false }, + { name: "check:bundle-size", conditional: true }, + { name: "gen:docs:check", conditional: true }, + { name: "gen:openapi:check", conditional: true }, + { name: "check:coverage", conditional: false }, + { name: "check:ci-parity", conditional: false }, +]; + +type PackageJson = { scripts?: Record }; + +export function loadScripts(packageJsonPath: string = PACKAGE_JSON): Record { + if (!existsSync(packageJsonPath)) return {}; + const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackageJson; + return pkg.scripts ?? {}; +} + +/** + * Resolve the host repo's gate manifest: every core gate (whether or + * not the repo defines it — a missing core gate must fail loudly, not + * silently narrow the manifest) plus each conditional gate the repo's + * package.json defines. + */ +export function resolveGates(scripts: Record): string[] { + return CANONICAL_GATES.filter((g) => !g.conditional || scripts[g.name] !== undefined).map( + (g) => g.name, + ); +} + +/** The host repo's resolved manifest — the single source of truth that + * check-ci-parity.ts imports. */ +export const GATES: readonly string[] = resolveGates(loadScripts()); + +export function formatGateLine( + status: "ok" | "fail", + gate: string, + seconds: number, + width: number, +): string { + const mark = status === "ok" ? "✓" : "✗"; + return `${mark} ${gate.padEnd(width)} (${seconds.toFixed(1)}s)`; +} + +const SIGNATURE_RE = + /^\(fail\) |^✗ |error TS\d+|: error |^✖|^Error: |\berror\b.*\bbudget\b|exceeds? .*budget/i; +const MAX_SIGNATURE_LINES = 50; +const TAIL_FALLBACK_LINES = 25; + +/** + * Pull the failure-relevant lines out of a gate's captured output: + * known failure signatures (bun test `(fail)` lines, tsc/biome error + * lines, budget-ratchet violations) when present, otherwise the tail + * of the output. + */ +export function extractFailureSignatures(output: string): string[] { + const lines = output.split("\n"); + const matched = lines.filter((l) => SIGNATURE_RE.test(l.trim())); + if (matched.length > 0) return matched.slice(0, MAX_SIGNATURE_LINES); + return lines.filter((l) => l.trim() !== "").slice(-TAIL_FALLBACK_LINES); +} + +type GateResult = { gate: string; ok: boolean; seconds: number; output: string }; + +function runGate(gate: string, verbose: boolean): GateResult { + const start = performance.now(); + const proc = Bun.spawnSync(["bun", "run", gate], { + cwd: REPO_ROOT, + stdout: verbose ? "inherit" : "pipe", + stderr: verbose ? "inherit" : "pipe", + env: process.env, + }); + const seconds = (performance.now() - start) / 1000; + const output = verbose + ? "" + : `${proc.stdout?.toString() ?? ""}\n${proc.stderr?.toString() ?? ""}`; + return { gate, ok: proc.exitCode === 0, seconds, output }; +} + +function main(): void { + const verbose = process.env.CHECK_ALL_VERBOSE === "1"; + const bail = process.argv.includes("--bail"); + const width = Math.max(...GATES.map((g) => g.length)); + const results: GateResult[] = []; + const overallStart = performance.now(); + + for (const gate of GATES) { + if (verbose) console.log(`\n── ${gate} ──`); + const result = runGate(gate, verbose); + results.push(result); + console.log(formatGateLine(result.ok ? "ok" : "fail", gate, result.seconds, width)); + if (!result.ok && bail) break; + } + + const failures = results.filter((r) => !r.ok); + const totalSeconds = (performance.now() - overallStart) / 1000; + + if (failures.length === 0) { + console.log(`\n${results.length}/${GATES.length} gates passed (${totalSeconds.toFixed(1)}s)`); + return; + } + + console.error( + `\n${failures.length} gate(s) failed: ${failures.map((f) => f.gate).join(", ")}\n`, + ); + for (const f of failures) { + console.error(`── ${f.gate} ──`); + for (const line of extractFailureSignatures(f.output)) console.error(` ${line}`); + console.error(` ↳ re-run: bun run ${f.gate} (or CHECK_ALL_VERBOSE=1 bun run check:all)`); + } + process.exit(1); +} + +if (import.meta.main) { + main(); +} diff --git a/scripts/check-ci-parity.test.ts b/scripts/check-ci-parity.test.ts new file mode 100644 index 0000000..5ad0e9b --- /dev/null +++ b/scripts/check-ci-parity.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type CiInvocation, + computeReachable, + evaluateParity, + extractBunRunTargets, + extractCiInvocations, + listCiWorkflows, + loadParityConfig, +} from "./check-ci-parity.ts"; + +describe("check-ci-parity", () => { + test("extractBunRunTargets picks up package-script invocations only", () => { + expect(extractBunRunTargets("bun run lint")).toEqual(["lint"]); + expect(extractBunRunTargets("bun run lint && bun run typecheck")).toEqual([ + "lint", + "typecheck", + ]); + expect(extractBunRunTargets("bun run check:coverage")).toEqual(["check:coverage"]); + // Multi-line shell still works. + expect(extractBunRunTargets("set -euo pipefail\nbun run lint\nbun run typecheck\n")).toEqual([ + "lint", + "typecheck", + ]); + // File invocations should NOT be treated as script names. + expect(extractBunRunTargets("bun run scripts/foo.ts")).toEqual([]); + }); + + test("computeReachable seeds from the gate manifest and walks transitively", () => { + const scripts = { + "check:all": "bun scripts/check-all.ts", + verify: "bun run check:all", + lint: "biome check .", + "check:coverage": "bun run scripts/check-coverage.ts", + "check:size": "bun run check:size:inner", + "check:size:inner": "echo leaf", + unrelated: "bun run lint", + }; + const reachable = computeReachable(scripts, ["lint", "check:coverage", "check:size"]); + expect(reachable.has("lint")).toBe(true); + expect(reachable.has("check:size:inner")).toBe(true); + expect(reachable.has("check:all")).toBe(true); + expect(reachable.has("verify")).toBe(true); + expect(reachable.has("unrelated")).toBe(false); + }); + + test("extractCiInvocations parses a synthetic workflow", () => { + const dir = mkdtempSync(join(tmpdir(), "ci-parity-")); + const file = join(dir, "ci.yml"); + writeFileSync( + file, + [ + "name: Synthetic", + "on: [push]", + "jobs:", + " ci:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v6", + " - run: bun install", + " - run: bun run lint && bun run typecheck", + " - name: tests", + " run: |", + " bun run test:ci", + "", + ].join("\n"), + ); + try { + const invocations = extractCiInvocations(file, dir); + const scripts = invocations.map((i) => i.script).sort(); + expect(scripts).toEqual(["lint", "test:ci", "typecheck"]); + expect(invocations[0]?.workflow).toBe("ci.yml"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("listCiWorkflows returns only ci*.yml, tolerating a missing dir", () => { + expect(listCiWorkflows("/nonexistent/workflows")).toEqual([]); + const dir = mkdtempSync(join(tmpdir(), "ci-parity-wf-")); + try { + writeFileSync(join(dir, "ci.yml"), "jobs: {}"); + writeFileSync(join(dir, "ci-postgres.yml"), "jobs: {}"); + writeFileSync(join(dir, "release.yml"), "jobs: {}"); + const found = listCiWorkflows(dir).map((p) => p.split("/").pop()); + expect(found).toEqual(["ci-postgres.yml", "ci.yml"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("loadParityConfig tolerates a missing config file", () => { + const config = loadParityConfig("/nonexistent/ci-parity-config.json"); + expect(config.aliases).toEqual({}); + expect(config.ciOnly.size).toBe(0); + }); + + test("evaluateParity flags drift and honors aliases + ciOnly", () => { + const inv = (script: string): CiInvocation => ({ + workflow: "ci.yml", + job: "ci", + step: 0, + script, + }); + const reachable = new Set(["check:all", "lint", "check:coverage"]); + const config = { + aliases: { "check:coverage:ci": "check:coverage", "lint:special": "lint:missing" }, + ciOnly: new Set(["report:test-timing"]), + }; + // Reachable directly — passes. + expect(evaluateParity([inv("lint")], reachable, config)).toEqual([]); + // Reachable via alias — passes. + expect(evaluateParity([inv("check:coverage:ci")], reachable, config)).toEqual([]); + // Allowlisted CI-only — passes. + expect(evaluateParity([inv("report:test-timing")], reachable, config)).toEqual([]); + // Unreachable — drift. + const drift = evaluateParity([inv("test:ci")], reachable, config); + expect(drift).toHaveLength(1); + expect(drift[0]?.reason).toContain("not reachable"); + // Aliased to something itself unreachable — drift with the aliased reason. + const aliasDrift = evaluateParity([inv("lint:special")], reachable, config); + expect(aliasDrift).toHaveLength(1); + expect(aliasDrift[0]?.canonical).toBe("lint:missing"); + expect(aliasDrift[0]?.reason).toContain("aliased to"); + }); +}); diff --git a/scripts/check-ci-parity.ts b/scripts/check-ci-parity.ts new file mode 100644 index 0000000..af557ab --- /dev/null +++ b/scripts/check-ci-parity.ts @@ -0,0 +1,206 @@ +#!/usr/bin/env bun +/** + * CI <-> `check:all` parity drift detector — fleet-canonical port of + * warren's original (warren-6296), generalized for the os-eco + * check:all standard (docs/check-all-standard.md, os-eco-5db7). + * + * This file is BYTE-IDENTICAL across every conforming repo — do not + * edit it in place. It imports the resolved GATES manifest from + * ./check-all.ts as the single source of truth, parses every + * `.github/workflows/ci*.yml`, and fails when any `bun run ` + * invoked by a CI `run:` step is not transitively reachable from the + * gate manifest — i.e. when CI enforces something `bun run check:all` + * does not exercise locally. + * + * Per-repo escape hatches live OUTSIDE this file, in an optional + * `scripts/ci-parity-config.json`: + * + * { + * "aliases": { "check:coverage:ci": "check:coverage" }, + * "ciOnly": ["report:test-timing", "report:quality-metrics"] + * } + * + * - `aliases` maps a CI-side script name onto a canonical + * gate-reachable equivalent. Use for variants that run the same + * gate with a different reporter / preamble (e.g. a junit + * emitter). + * - `ciOnly` is the explicit allowlist of scripts that are + * intentionally CI-only (summaries / setup with no local + * equivalent). Adding here is the only sanctioned way to diverge; + * justify each entry in the config's "$comment". + * + * Anything outside those two sinks is drift: grow the manifest, change + * the workflow, or add a justified escape-hatch entry. + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { parse } from "yaml"; +import { GATES } from "./check-all.ts"; + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const WORKFLOWS_DIR = resolve(REPO_ROOT, ".github/workflows"); +const PACKAGE_JSON = resolve(REPO_ROOT, "package.json"); +const PARITY_CONFIG = resolve(import.meta.dir, "ci-parity-config.json"); +const ROOT_GATE = "check:all"; + +export type ParityConfig = { aliases: Record; ciOnly: ReadonlySet }; + +type RawParityConfig = { aliases?: Record; ciOnly?: string[] }; + +export function loadParityConfig(configPath: string = PARITY_CONFIG): ParityConfig { + if (!existsSync(configPath)) return { aliases: {}, ciOnly: new Set() }; + const raw = JSON.parse(readFileSync(configPath, "utf8")) as RawParityConfig; + return { aliases: raw.aliases ?? {}, ciOnly: new Set(raw.ciOnly ?? []) }; +} + +type PackageJson = { scripts?: Record }; + +/** Tokens of the form `bun run ` that target a package script. + * `bun run scripts/foo.ts` (file invocation) is deliberately excluded. */ +const BUN_RUN_RE = /\bbun\s+run\s+([A-Za-z][\w:-]*)(?![\w./:-])/g; + +export function extractBunRunTargets(command: string): string[] { + const out: string[] = []; + for (const match of command.matchAll(BUN_RUN_RE)) { + const name = match[1]; + if (name) out.push(name); + } + return out; +} + +export function loadScripts(packageJsonPath: string = PACKAGE_JSON): Record { + if (!existsSync(packageJsonPath)) return {}; + const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackageJson; + return pkg.scripts ?? {}; +} + +/** + * Everything reachable from the gate manifest: the manifest itself, + * the check:all / verify entry points, and the transitive closure of + * `bun run ` references in script bodies. + */ +export function computeReachable( + scripts: Record, + gates: readonly string[], +): Set { + const reachable = new Set(); + const stack: string[] = [ROOT_GATE, "verify", ...gates]; + while (stack.length > 0) { + const name = stack.pop(); + if (!name || reachable.has(name)) continue; + reachable.add(name); + const body = scripts[name]; + if (body === undefined) continue; + for (const dep of extractBunRunTargets(body)) { + if (!reachable.has(dep)) stack.push(dep); + } + } + return reachable; +} + +type WorkflowStep = { run?: unknown }; +type WorkflowJob = { steps?: WorkflowStep[] }; +type WorkflowFile = { jobs?: Record }; + +export type CiInvocation = { workflow: string; job: string; step: number; script: string }; + +export function extractCiInvocations(filePath: string, repoRoot: string = REPO_ROOT): CiInvocation[] { + const text = readFileSync(filePath, "utf8"); + const doc = parse(text) as WorkflowFile | null; + const workflow = relative(repoRoot, filePath); + const out: CiInvocation[] = []; + if (!doc || typeof doc !== "object" || !doc.jobs) return out; + for (const [jobName, job] of Object.entries(doc.jobs)) { + const steps = job?.steps; + if (!Array.isArray(steps)) continue; + steps.forEach((step, idx) => { + const run = step?.run; + if (typeof run !== "string") return; + for (const script of extractBunRunTargets(run)) { + out.push({ workflow, job: jobName, step: idx, script }); + } + }); + } + return out; +} + +/** Gate workflows only (ci*.yml / ci*.yaml) — release/publish + * orchestration is intentionally out-of-band from the per-PR gate. */ +export function listCiWorkflows(dir: string = WORKFLOWS_DIR): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((f) => (f.endsWith(".yml") || f.endsWith(".yaml")) && f.startsWith("ci")) + .map((f) => join(dir, f)) + .sort(); +} + +export type ParityFailure = CiInvocation & { canonical: string; reason: string }; + +export function evaluateParity( + invocations: CiInvocation[], + reachable: ReadonlySet, + config: ParityConfig, +): ParityFailure[] { + const failures: ParityFailure[] = []; + for (const inv of invocations) { + const canonical = config.aliases[inv.script] ?? inv.script; + if (config.ciOnly.has(canonical)) continue; + if (reachable.has(canonical)) continue; + const reason = + canonical === inv.script + ? `not reachable from ${ROOT_GATE}` + : `aliased to "${canonical}", which is not reachable from ${ROOT_GATE}`; + failures.push({ ...inv, canonical, reason }); + } + return failures; +} + +export function checkParity(): { + invocations: CiInvocation[]; + reachable: Set; + failures: ParityFailure[]; +} { + const reachable = computeReachable(loadScripts(), GATES); + const invocations: CiInvocation[] = []; + for (const wf of listCiWorkflows()) { + invocations.push(...extractCiInvocations(wf)); + } + const failures = evaluateParity(invocations, reachable, loadParityConfig()); + return { invocations, reachable, failures }; +} + +function formatFailure(f: ParityFailure): string { + return ` ${f.workflow} (job=${f.job}, step=${f.step}): bun run ${f.script} — ${f.reason}`; +} + +function main(): void { + const { invocations, reachable, failures } = checkParity(); + if (failures.length === 0) { + console.log( + `✓ CI parity: ${invocations.length} bun-run invocation(s) across CI workflows, ` + + `all reachable from "${ROOT_GATE}" (${reachable.size} scripts in graph).`, + ); + return; + } + console.error( + `✗ CI parity drift: ${failures.length} CI step(s) invoke a script that is not ` + + `reachable from "${ROOT_GATE}":\n`, + ); + for (const f of failures) console.error(formatFailure(f)); + console.error( + `\nFix one of:\n` + + ` - Wire the script into the GATES manifest / a gate's script body.\n` + + ` - Change CI to invoke a script that is already reachable.\n` + + ` - If the step is intentionally CI-only (summary / setup with no local\n` + + ` equivalent), add it to "ciOnly" in scripts/ci-parity-config.json with a\n` + + ` justification in the config's "$comment".\n` + + ` - If two scripts run the same gate under different names, map the CI name\n` + + ` to its canonical equivalent in "aliases" in scripts/ci-parity-config.json.`, + ); + process.exit(1); +} + +if (import.meta.main) { + main(); +} diff --git a/scripts/check-debt-markers.test.ts b/scripts/check-debt-markers.test.ts new file mode 100644 index 0000000..7a06799 --- /dev/null +++ b/scripts/check-debt-markers.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { loadBudget, scan } from "./check-debt-markers.ts"; + +const TOOLKIT_ROOT = resolve(import.meta.dir, ".."); + +function makeFixture(): { root: string; cleanup: () => void } { + const root = mkdtempSync(join(tmpdir(), "check-debt-markers-")); + const cleanup = () => rmSync(root, { recursive: true, force: true }); + return { root, cleanup }; +} + +function writeFileTree(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const full = join(root, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); + } +} + +function writeBudget(path: string, trackerPatterns: string[], allowlist: string[] = []): void { + writeFileSync(path, JSON.stringify({ trackerPatterns, allowlist })); +} + +const DEFAULT_PATTERNS = [ + "\\b(?:warren|pl|mx|burrow|canopy|seeds|mulch)-[0-9a-f]+\\b", + "#\\d+\\b", + "https?://\\S+", +]; + +describe("loadBudget", () => { + test("parses tracker patterns into RegExp instances", () => { + const { root, cleanup } = makeFixture(); + try { + const p = join(root, "b.json"); + writeBudget(p, DEFAULT_PATTERNS); + const { trackerRegexes } = loadBudget(p); + expect(trackerRegexes).toHaveLength(3); + expect(trackerRegexes[0]?.test("// TODO(warren-abc1): later")).toBe(true); + expect(trackerRegexes[1]?.test("// TODO #42")).toBe(true); + expect(trackerRegexes[2]?.test("// TODO https://x.example/")).toBe(true); + } finally { + cleanup(); + } + }); + + test("rejects non-array trackerPatterns", () => { + const { root, cleanup } = makeFixture(); + try { + const p = join(root, "b.json"); + writeFileSync(p, JSON.stringify({ trackerPatterns: "nope", allowlist: [] })); + expect(() => loadBudget(p)).toThrow(/trackerPatterns/); + } finally { + cleanup(); + } + }); + + test("rejects malformed allowlist entry", () => { + const { root, cleanup } = makeFixture(); + try { + const p = join(root, "b.json"); + writeFileSync( + p, + JSON.stringify({ trackerPatterns: DEFAULT_PATTERNS, allowlist: ["bad-no-colon"] }), + ); + expect(() => loadBudget(p)).toThrow(/path:line/); + } finally { + cleanup(); + } + }); +}); + +describe("scan — synthetic pass", () => { + test("returns no untracked markers when every marker has a tracker", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/a.ts": "// TODO(warren-7f2b): later\nexport const a = 1;\n", + "src/b.ts": "// FIXME #123 wire this up\nexport const b = 2;\n", + "src/c.ts": "// HACK https://example.com/x — temporary\nexport const c = 3;\n", + }); + const budgetPath = join(root, "budget.json"); + writeBudget(budgetPath, DEFAULT_PATTERNS); + const result = scan({ + repoRoot: root, + budgetPath, + scanRoots: ["src"], + excludePathPrefixes: [], + selfExclude: new Set(), + }); + expect(result.untracked).toEqual([]); + expect(result.staleAllowlistEntries).toEqual([]); + } finally { + cleanup(); + } + }); + + test("placeholder strings like warren-XXXX are not flagged as markers", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/a.ts": + "// example placeholder: warren-XXXX or pl-XXXX (no real marker)\nexport const a = 1;\n", + }); + const budgetPath = join(root, "budget.json"); + writeBudget(budgetPath, DEFAULT_PATTERNS); + const result = scan({ + repoRoot: root, + budgetPath, + scanRoots: ["src"], + excludePathPrefixes: [], + selfExclude: new Set(), + }); + expect(result.untracked).toEqual([]); + } finally { + cleanup(); + } + }); +}); + +describe("scan — synthetic violation", () => { + test("flags a bare TODO with no tracker reference", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/a.ts": "// TODO: revisit later\nexport const a = 1;\n", + }); + const budgetPath = join(root, "budget.json"); + writeBudget(budgetPath, DEFAULT_PATTERNS); + const result = scan({ + repoRoot: root, + budgetPath, + scanRoots: ["src"], + excludePathPrefixes: [], + selfExclude: new Set(), + }); + expect(result.untracked).toHaveLength(1); + expect(result.untracked[0]?.path).toBe("src/a.ts"); + expect(result.untracked[0]?.line).toBe(1); + expect(result.untracked[0]?.marker).toBe("TODO"); + } finally { + cleanup(); + } + }); + + test("flags FIXME and HACK alongside TODO", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/a.ts": "// FIXME later\n// HACK so it works\nexport const a = 1;\n", + }); + const budgetPath = join(root, "budget.json"); + writeBudget(budgetPath, DEFAULT_PATTERNS); + const result = scan({ + repoRoot: root, + budgetPath, + scanRoots: ["src"], + excludePathPrefixes: [], + selfExclude: new Set(), + }); + expect(result.untracked.map((m) => m.marker).sort()).toEqual(["FIXME", "HACK"]); + } finally { + cleanup(); + } + }); + + test("allowlist silences a known untracked marker", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/a.ts": "// TODO: revisit later\nexport const a = 1;\n", + }); + const budgetPath = join(root, "budget.json"); + writeBudget(budgetPath, DEFAULT_PATTERNS, ["src/a.ts:1"]); + const result = scan({ + repoRoot: root, + budgetPath, + scanRoots: ["src"], + excludePathPrefixes: [], + selfExclude: new Set(), + }); + expect(result.untracked).toEqual([]); + expect(result.allowedSilenced).toHaveLength(1); + expect(result.staleAllowlistEntries).toEqual([]); + } finally { + cleanup(); + } + }); + + test("stale allowlist entries are reported", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/a.ts": "export const a = 1;\n", + }); + const budgetPath = join(root, "budget.json"); + writeBudget(budgetPath, DEFAULT_PATTERNS, ["src/a.ts:1"]); + const result = scan({ + repoRoot: root, + budgetPath, + scanRoots: ["src"], + excludePathPrefixes: [], + selfExclude: new Set(), + }); + expect(result.staleAllowlistEntries).toEqual(["src/a.ts:1"]); + } finally { + cleanup(); + } + }); +}); + +describe("CLI integration", () => { + test("CLI exits 0 when every marker carries a tracker", async () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/a.ts": "// TODO(warren-7f2b): later\nexport const a = 1;\n", + }); + const budgetPath = join(root, "budget.json"); + writeBudget(budgetPath, DEFAULT_PATTERNS); + const proc = Bun.spawn( + [ + "bun", + "run", + resolve(TOOLKIT_ROOT, "scripts/check-debt-markers.ts"), + "--repo-root", + root, + "--budget", + budgetPath, + "--root", + "src", + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + } finally { + cleanup(); + } + }); + + test("CLI exits 1 on a bare-TODO tree", async () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/a.ts": "// TODO: revisit later\nexport const a = 1;\n", + }); + const budgetPath = join(root, "budget.json"); + writeBudget(budgetPath, DEFAULT_PATTERNS); + const proc = Bun.spawn( + [ + "bun", + "run", + resolve(TOOLKIT_ROOT, "scripts/check-debt-markers.ts"), + "--repo-root", + root, + "--budget", + budgetPath, + "--root", + "src", + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + expect(exitCode).toBe(1); + } finally { + cleanup(); + } + }); +}); + +describe("template budget JSON", () => { + test("budgets/debt-markers-budget.json is well-formed", () => { + const raw = JSON.parse( + readFileSync(resolve(TOOLKIT_ROOT, "budgets/debt-markers-budget.json"), "utf8"), + ) as { trackerPatterns: unknown; allowlist: unknown }; + expect(Array.isArray(raw.trackerPatterns)).toBe(true); + expect(Array.isArray(raw.allowlist)).toBe(true); + for (const p of raw.trackerPatterns as unknown[]) { + expect(typeof p).toBe("string"); + expect(() => new RegExp(p as string, "i")).not.toThrow(); + } + }); +}); diff --git a/scripts/check-file-sizes.test.ts b/scripts/check-file-sizes.test.ts new file mode 100644 index 0000000..fb54d64 --- /dev/null +++ b/scripts/check-file-sizes.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { countLines, loadBudgets, scan } from "./check-file-sizes.ts"; + +const TOOLKIT_ROOT = resolve(import.meta.dir, ".."); + +function makeFixture(): { root: string; cleanup: () => void } { + const root = mkdtempSync(join(tmpdir(), "check-file-sizes-")); + const cleanup = () => rmSync(root, { recursive: true, force: true }); + return { root, cleanup }; +} + +function writeFileTree(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const full = join(root, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); + } +} + +function writeBudget(path: string, threshold: number, budgets: Record = {}): void { + writeFileSync(path, JSON.stringify({ threshold, budgets })); +} + +describe("loadBudgets", () => { + test("parses a valid budget JSON", () => { + const { root, cleanup } = makeFixture(); + try { + const p = join(root, "b.json"); + writeBudget(p, 500, { "src/foo.ts": 700 }); + const parsed = loadBudgets(p); + expect(parsed.threshold).toBe(500); + expect(parsed.budgets["src/foo.ts"]).toBe(700); + } finally { + cleanup(); + } + }); + + test("rejects non-positive threshold", () => { + const { root, cleanup } = makeFixture(); + try { + const p = join(root, "b.json"); + writeFileSync(p, JSON.stringify({ threshold: 0, budgets: {} })); + expect(() => loadBudgets(p)).toThrow(/threshold/); + } finally { + cleanup(); + } + }); + + test("rejects non-numeric entry", () => { + const { root, cleanup } = makeFixture(); + try { + const p = join(root, "b.json"); + writeFileSync(p, JSON.stringify({ threshold: 500, budgets: { "src/a.ts": "200" } })); + expect(() => loadBudgets(p)).toThrow(/positive number/); + } finally { + cleanup(); + } + }); +}); + +describe("scan — synthetic pass", () => { + test("returns no failures when every file is under threshold", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/a.ts": `${"line\n".repeat(50)}`, + "src/sub/b.ts": `${"line\n".repeat(120)}`, + "scripts/x.ts": `${"line\n".repeat(80)}`, + }); + const budgetsPath = join(root, "budget.json"); + writeBudget(budgetsPath, 500); + const result = scan({ + repoRoot: root, + budgetsPath, + scanRoots: ["src", "scripts"], + excludePathPrefixes: [], + }); + expect(result.failures).toEqual([]); + expect(result.staleBudgetEntries).toEqual([]); + } finally { + cleanup(); + } + }); + + test("file with explicit budget passes when within budget", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/big.ts": `${"line\n".repeat(700)}`, + }); + const budgetsPath = join(root, "budget.json"); + writeBudget(budgetsPath, 500, { "src/big.ts": 750 }); + const result = scan({ + repoRoot: root, + budgetsPath, + scanRoots: ["src"], + excludePathPrefixes: [], + }); + expect(result.failures).toEqual([]); + } finally { + cleanup(); + } + }); +}); + +describe("scan — synthetic violation", () => { + test("flags a file that exceeds the threshold", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/huge.ts": `${"line\n".repeat(800)}`, + }); + const budgetsPath = join(root, "budget.json"); + writeBudget(budgetsPath, 500); + const result = scan({ + repoRoot: root, + budgetsPath, + scanRoots: ["src"], + excludePathPrefixes: [], + }); + expect(result.failures.length).toBe(1); + expect(result.failures[0]?.path).toBe("src/huge.ts"); + expect(result.failures[0]?.lines).toBe(800); + expect(result.failures[0]?.budget).toBe(500); + } finally { + cleanup(); + } + }); + + test("flags a file that exceeds its frozen budget", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/grandfathered.ts": `${"line\n".repeat(900)}`, + }); + const budgetsPath = join(root, "budget.json"); + writeBudget(budgetsPath, 500, { "src/grandfathered.ts": 800 }); + const result = scan({ + repoRoot: root, + budgetsPath, + scanRoots: ["src"], + excludePathPrefixes: [], + }); + expect(result.failures.length).toBe(1); + expect(result.failures[0]?.budget).toBe(800); + expect(result.failures[0]?.lines).toBe(900); + } finally { + cleanup(); + } + }); + + test("reports stale budget entries that no longer match a file", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/keep.ts": "x\n", + }); + const budgetsPath = join(root, "budget.json"); + writeBudget(budgetsPath, 500, { "src/gone.ts": 700 }); + const result = scan({ + repoRoot: root, + budgetsPath, + scanRoots: ["src"], + excludePathPrefixes: [], + }); + expect(result.failures).toEqual([]); + expect(result.staleBudgetEntries).toEqual(["src/gone.ts"]); + } finally { + cleanup(); + } + }); +}); + +describe("CLI integration", () => { + test("CLI exits 0 on a synthetic-pass tree", async () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { "src/a.ts": `${"line\n".repeat(100)}` }); + const budgetsPath = join(root, "budget.json"); + writeBudget(budgetsPath, 500); + const proc = Bun.spawn( + [ + "bun", + "run", + resolve(TOOLKIT_ROOT, "scripts/check-file-sizes.ts"), + "--repo-root", + root, + "--budget", + budgetsPath, + "--root", + "src", + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + } finally { + cleanup(); + } + }); + + test("CLI exits 1 on a synthetic-violation tree", async () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { "src/huge.ts": `${"line\n".repeat(800)}` }); + const budgetsPath = join(root, "budget.json"); + writeBudget(budgetsPath, 500); + const proc = Bun.spawn( + [ + "bun", + "run", + resolve(TOOLKIT_ROOT, "scripts/check-file-sizes.ts"), + "--repo-root", + root, + "--budget", + budgetsPath, + "--root", + "src", + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + expect(exitCode).toBe(1); + } finally { + cleanup(); + } + }); +}); + +describe("countLines", () => { + test("returns 0 for an empty file", () => { + const { root, cleanup } = makeFixture(); + try { + const p = join(root, "empty.ts"); + writeFileSync(p, ""); + expect(countLines(p)).toBe(0); + } finally { + cleanup(); + } + }); + + test("counts logical lines for a file ending with a trailing newline", () => { + const { root, cleanup } = makeFixture(); + try { + const p = join(root, "trailing.ts"); + writeFileSync(p, "line1\nline2\n"); + expect(countLines(p)).toBe(2); + } finally { + cleanup(); + } + }); + + test("counts logical lines for a file lacking a trailing newline (newlineCount + 1)", () => { + const { root, cleanup } = makeFixture(); + try { + const p = join(root, "no-trailing.ts"); + writeFileSync(p, "line1\nline2"); + expect(countLines(p)).toBe(2); + } finally { + cleanup(); + } + }); + + test("counts a single-line file with no newline as 1", () => { + const { root, cleanup } = makeFixture(); + try { + const p = join(root, "single.ts"); + writeFileSync(p, "just one line"); + expect(countLines(p)).toBe(1); + } finally { + cleanup(); + } + }); +}); + +describe("scan — trailing newline correctness", () => { + test("flags a 2-line file without trailing newline against threshold=1", () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { + "src/two-lines-no-trailing.ts": "line1\nline2", + }); + const budgetsPath = join(root, "budget.json"); + writeBudget(budgetsPath, 1); + const result = scan({ + repoRoot: root, + budgetsPath, + scanRoots: ["src"], + excludePathPrefixes: [], + }); + expect(result.failures.length).toBe(1); + expect(result.failures[0]?.path).toBe("src/two-lines-no-trailing.ts"); + expect(result.failures[0]?.lines).toBe(2); + expect(result.failures[0]?.budget).toBe(1); + } finally { + cleanup(); + } + }); +}); + +describe("CLI integration — trailing newline bypass", () => { + test("CLI exits 1 on a 2-line no-trailing-newline file against threshold=1", async () => { + const { root, cleanup } = makeFixture(); + try { + writeFileTree(root, { "src/bypass.ts": "line1\nline2" }); + const budgetsPath = join(root, "budget.json"); + writeBudget(budgetsPath, 1); + const proc = Bun.spawn( + [ + "bun", + "run", + resolve(TOOLKIT_ROOT, "scripts/check-file-sizes.ts"), + "--repo-root", + root, + "--budget", + budgetsPath, + "--root", + "src", + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + expect(exitCode).toBe(1); + } finally { + cleanup(); + } + }); +}); + +describe("template budget JSON", () => { + test("budgets/file-size-budgets.json is well-formed", () => { + const raw = JSON.parse( + readFileSync(resolve(TOOLKIT_ROOT, "budgets/file-size-budgets.json"), "utf8"), + ) as { threshold: unknown; budgets: unknown }; + expect(typeof raw.threshold).toBe("number"); + expect(raw.threshold).toBeGreaterThan(0); + expect(typeof raw.budgets).toBe("object"); + expect(raw.budgets).not.toBeNull(); + }); +}); diff --git a/scripts/ci-parity-config.json b/scripts/ci-parity-config.json new file mode 100644 index 0000000..3533b86 --- /dev/null +++ b/scripts/ci-parity-config.json @@ -0,0 +1,7 @@ +{ + "$comment": "Per-repo escape hatches for check-ci-parity.ts (docs/check-all-standard.md §6). aliases: test:ci runs the same tests+coverage gate as check:coverage but with the junit reporter CI needs for the timing artifact. ciOnly: the report:* steps are always() log summaries with no local enforcement equivalent.", + "aliases": { + "test:ci": "check:coverage" + }, + "ciOnly": ["report:test-timing", "report:quality-metrics"] +}