mandrel 2.25.0 → 2.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -0,0 +1,214 @@
1
+ /**
2
+ * CLI: ratchet on cyclomatic complexity against
3
+ * `delivery.quality.codingGuardrails.cyclomaticMustFix` (Story #4923).
4
+ *
5
+ * The must-fix ceiling was documented as blocking (`code-quality-guardrails.md`
6
+ * promises "the close-validation chain refuses the merge") while being read by
7
+ * nothing. This is the consumer that makes the promise true. It joins the
8
+ * standalone-ratchet family — `check-arch-cycles.js`, `check-dead-exports.js`,
9
+ * `check-context-budget.js` — which the CI `baselines` job runs as a required
10
+ * check, and it follows their contract exactly:
11
+ *
12
+ * - Reads the committed breach baseline at `baselines/cyclomatic.json`
13
+ * (override with `--baseline <path>`).
14
+ * - Walks the maintainability gate's `targetDirs` / `ignoreGlobs` and scores
15
+ * every function with the in-repo escomplex kernel — no coverage artifact
16
+ * required, so the verdict is available on a cold checkout.
17
+ * - Exit 0 when clean, improved, or shrinking; exit 1 when a file gains an
18
+ * over-ceiling function or its worst function gets worse.
19
+ *
20
+ * `--update` rewrites the baseline from the current tree. That is the
21
+ * sanctioned motion after a deliberate refactor lands, and the only way the
22
+ * recorded breach count is allowed to rise.
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import process from 'node:process';
28
+ import { runAsCli } from './lib/cli-utils.js';
29
+ import { getQuality, resolveConfig } from './lib/config-resolver.js';
30
+ import {
31
+ buildCyclomaticEnvelope,
32
+ DEFAULT_CYCLOMATIC_BASELINE,
33
+ diffCyclomaticRows,
34
+ renderCyclomaticDiff,
35
+ resolveCyclomaticPolicy,
36
+ scanCyclomatic,
37
+ } from './lib/cyclomatic-ceiling.js';
38
+
39
+ /**
40
+ * Parse `--baseline <path>`, `--json`, and `--update`.
41
+ *
42
+ * @param {string[]} argv
43
+ * @returns {{ baselinePath: string | null, json: boolean, update: boolean }}
44
+ */
45
+ function parseArgv(argv = []) {
46
+ let baselinePath = null;
47
+ let json = false;
48
+ let update = false;
49
+ for (let i = 0; i < argv.length; i += 1) {
50
+ const a = argv[i];
51
+ if (a === '--baseline') {
52
+ const next = argv[i + 1];
53
+ if (next && !next.startsWith('--')) {
54
+ baselinePath = next;
55
+ i += 1;
56
+ }
57
+ } else if (a === '--json') {
58
+ json = true;
59
+ } else if (a === '--update') {
60
+ update = true;
61
+ }
62
+ }
63
+ return { baselinePath, json, update };
64
+ }
65
+
66
+ /**
67
+ * Read a breach baseline envelope from disk. Returns `null` when the file is
68
+ * missing or unparseable — the caller treats that as an empty baseline, which
69
+ * makes the very first run report every existing breach as `added` rather than
70
+ * silently passing.
71
+ *
72
+ * @param {string} baselinePath
73
+ * @returns {{ ceiling?: number, rows?: Array<object> } | null}
74
+ */
75
+ function loadCyclomaticBaseline(baselinePath) {
76
+ try {
77
+ if (!fs.existsSync(baselinePath)) return null;
78
+ const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
79
+ return parsed && typeof parsed === 'object' ? parsed : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Top-level CLI entry. Exported so tests can drive the whole pipeline through
87
+ * the injected seams below without spawning a process.
88
+ *
89
+ * @param {{
90
+ * argv?: string[],
91
+ * cwd?: string,
92
+ * stdout?: { write: (s: string) => void },
93
+ * stderr?: { write: (s: string) => void },
94
+ * resolveConfigImpl?: typeof resolveConfig,
95
+ * scanImpl?: typeof scanCyclomatic,
96
+ * loadBaselineImpl?: typeof loadCyclomaticBaseline,
97
+ * writeFileImpl?: (p: string, data: string) => void,
98
+ * }} [opts]
99
+ * @returns {Promise<number>} 0 = clean / improved; 1 = ratchet breached
100
+ */
101
+ export async function runCli({
102
+ argv = process.argv.slice(2),
103
+ cwd = process.cwd(),
104
+ stdout = process.stdout,
105
+ stderr = process.stderr,
106
+ resolveConfigImpl = resolveConfig,
107
+ scanImpl = scanCyclomatic,
108
+ loadBaselineImpl = loadCyclomaticBaseline,
109
+ writeFileImpl = (p, data) => fs.writeFileSync(p, data),
110
+ } = {}) {
111
+ const { baselinePath, json, update } = parseArgv(argv);
112
+ const quality = getQuality(resolveConfigImpl({ cwd }));
113
+ const policy = resolveCyclomaticPolicy(quality);
114
+ const resolvedBaselinePath = path.resolve(
115
+ cwd,
116
+ baselinePath ?? DEFAULT_CYCLOMATIC_BASELINE,
117
+ );
118
+
119
+ const scan = scanImpl({
120
+ targetDirs: policy.targetDirs,
121
+ ignoreGlobs: policy.ignoreGlobs,
122
+ ceiling: policy.mustFix,
123
+ cwd,
124
+ });
125
+
126
+ if (update) {
127
+ const envelope = buildCyclomaticEnvelope({
128
+ rows: scan.rows,
129
+ ceiling: policy.mustFix,
130
+ });
131
+ writeFileImpl(
132
+ resolvedBaselinePath,
133
+ `${JSON.stringify(envelope, null, 2)}\n`,
134
+ );
135
+ stdout.write(
136
+ `[cyclomatic] wrote ${scan.rows.length} breach row(s) at ceiling c=${policy.mustFix} to ${resolvedBaselinePath}\n`,
137
+ );
138
+ return 0;
139
+ }
140
+
141
+ const baseline = loadBaselineImpl(resolvedBaselinePath);
142
+ const baselineRows = Array.isArray(baseline?.rows) ? baseline.rows : [];
143
+ const diff = diffCyclomaticRows(baselineRows, scan.rows);
144
+ const exitCode = diff.added.length + diff.worsened.length > 0 ? 1 : 0;
145
+
146
+ if (json) {
147
+ stdout.write(
148
+ `${JSON.stringify(
149
+ {
150
+ kind: 'cyclomatic-report',
151
+ ceiling: policy.mustFix,
152
+ flag: policy.flag,
153
+ baselinePath: resolvedBaselinePath,
154
+ baselineCeiling: baseline?.ceiling ?? null,
155
+ scannedFiles: scan.scannedFiles,
156
+ parseErrors: scan.parseErrors,
157
+ baselineRows,
158
+ currentRows: scan.rows,
159
+ ...diff,
160
+ exitCode,
161
+ },
162
+ null,
163
+ 2,
164
+ )}\n`,
165
+ );
166
+ return exitCode;
167
+ }
168
+
169
+ if (!baseline) {
170
+ stderr.write(
171
+ `[cyclomatic] ⚠ baseline not found at ${resolvedBaselinePath} — treating as empty\n`,
172
+ );
173
+ } else if (
174
+ typeof baseline.ceiling === 'number' &&
175
+ baseline.ceiling !== policy.mustFix
176
+ ) {
177
+ stderr.write(
178
+ `[cyclomatic] ⚠ baseline was recorded at ceiling c=${baseline.ceiling} but the configured cyclomaticMustFix is c=${policy.mustFix} — re-run with --update\n`,
179
+ );
180
+ }
181
+ stdout.write('\n--- cyclomatic preview ---\n');
182
+ stdout.write(`${renderCyclomaticDiff(diff, policy.mustFix)}\n`);
183
+ return exitCode;
184
+ }
185
+
186
+ async function main() {
187
+ return runCli();
188
+ }
189
+
190
+ runAsCli(import.meta.url, main, {
191
+ source: 'cyclomatic',
192
+ propagateExitCode: true,
193
+ errorPrefix: '[cyclomatic] ❌ Fatal error',
194
+ usage: {
195
+ invocation:
196
+ 'node .agents/scripts/check-cyclomatic.js [--baseline <path>] [--update] [--json]',
197
+ summary:
198
+ 'Ratchet on cyclomatic complexity: fail when a file gains a function above `delivery.quality.codingGuardrails.cyclomaticMustFix`, or when its worst function gets worse than the recorded baseline.',
199
+ flags: [
200
+ [
201
+ '--baseline <path>',
202
+ 'Baseline file (default: baselines/cyclomatic.json).',
203
+ ],
204
+ [
205
+ '--update',
206
+ 'Rewrite the baseline from the current tree (the post-refactor motion).',
207
+ ],
208
+ ['--json', 'Emit the comparison envelope as JSON.'],
209
+ ],
210
+ notes: [
211
+ 'Exit codes:\n 0 clean, improved, or shrinking\n 1 a new or worsened over-ceiling function',
212
+ ],
213
+ },
214
+ });
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI: report every JSON Schema under `.agents/schemas/` that no code path
5
+ * compiles.
6
+ *
7
+ * A well-formed schema sitting in a schema directory reads as an enforced
8
+ * contract to every human reader and every audit lens — existence plus valid
9
+ * syntax is taken for authority. When nothing actually compiles it, that
10
+ * reading is wrong in the most expensive direction: the reader believes a
11
+ * shape the runtime rejects. `friction-event.schema.json` sat unreferenced
12
+ * from the Epic #4406 envelope cutover until Story #4938; in between, an
13
+ * `/audit-documentation` lens graded a High finding against it as the live
14
+ * contract, the finding became an acceptance criterion, and the delivering
15
+ * worker had to overrule that criterion to avoid writing a payload the
16
+ * enforced validator drops. Two verification layers accepted the file
17
+ * because it existed and parsed. Only reading the code caught it.
18
+ *
19
+ * This gate is that code read, run every time instead of once by accident.
20
+ *
21
+ * ## What counts as a compiling reference
22
+ *
23
+ * Resolution is evidence-ranked; the first kind that hits wins, and the
24
+ * report names both the kind and the file that supplied the evidence, so a
25
+ * pass is auditable rather than merely green.
26
+ *
27
+ * 1. `literal` — the schema's basename appears in a JS source. This is
28
+ * the ordinary idiom (`signal-validator.js` resolves
29
+ * `signal-event.schema.json` by literal path).
30
+ * 2. `schema-ref` — another schema `$ref`s this file by name, and that
31
+ * schema is itself referenced. A `$ref` is a compile
32
+ * edge: AJV pulls the target in when the parent
33
+ * compiles.
34
+ * 3. `dynamic` — the schema lives in a SUBDIRECTORY of the schema root
35
+ * that some JS source loads by computed path (a
36
+ * `` `${key}.schema.json` `` template), AND the
37
+ * schema's stem appears literally in a JS source as the
38
+ * key that would produce it. `lifecycle/` is loaded this
39
+ * way by `lib/orchestration/lifecycle/bus.js`, and
40
+ * `baselines/` by `lib/baselines/envelope.js`.
41
+ *
42
+ * BOTH halves are required. The directory evidence alone
43
+ * would bless every future file dropped into a
44
+ * dynamically-enumerated directory — the same blindness
45
+ * this gate exists to remove. Dynamic resolution is not
46
+ * offered for schemas sitting at the schema root: every
47
+ * root schema today is compiled by an explicit literal
48
+ * path, so a root-level stem match would be coincidence,
49
+ * not evidence.
50
+ *
51
+ * Anything left over is a finding — unless the schema says otherwise in its
52
+ * own body (see below). A finding is not an accusation of deadness; it is a
53
+ * demand that the file state which one it is.
54
+ *
55
+ * ## Recording a deliberate exemption
56
+ *
57
+ * A schema deliberately kept without a compiler declares it IN THE FILE, at
58
+ * the document root:
59
+ *
60
+ * ```json
61
+ * "x-mandrel-uncompiled": {
62
+ * "reason": "Documented SSOT; AJV is deliberately not in this path.",
63
+ * "runtimeGate": "lib/orchestration/foo.js#validateFooPayload"
64
+ * }
65
+ * ```
66
+ *
67
+ * In-file, never a side-car allowlist. The failure this gate answers was a
68
+ * reader trusting the schema document; the correction has to be legible to
69
+ * that same reader, in that same document. An allowlist elsewhere in the repo
70
+ * would leave the file itself still lying.
71
+ *
72
+ * Story #4938 left the tree with exactly one: `friction-event.schema.json` was
73
+ * deleted outright (its shape preserved field-for-field in
74
+ * `docs/archive/data-dictionary-2026-08.md`), and
75
+ * `model-attribution.schema.json` — a documented SSOT with a hand-rolled
76
+ * validator behind it — declared the marker.
77
+ *
78
+ * The gate answers "is anything compiling this?", not "is what compiles it
79
+ * faithful to it?". A schema whose hand-rolled mirror has silently drifted
80
+ * still passes here.
81
+ *
82
+ * Flags:
83
+ * --root <path> scan a different repository root (default: cwd)
84
+ * --json emit the structured envelope as single-line JSON
85
+ */
86
+
87
+ import fs from 'node:fs';
88
+ import path from 'node:path';
89
+ import process from 'node:process';
90
+ import { runAsCli } from './lib/cli-utils.js';
91
+
92
+ /** Schema root, relative to the repository root. */
93
+ const SCHEMA_ROOT = path.join('.agents', 'schemas');
94
+
95
+ /** Directories never worth walking for JS sources. */
96
+ const SKIP_DIRS = new Set([
97
+ '.git',
98
+ '.worktrees',
99
+ 'node_modules',
100
+ 'coverage',
101
+ 'temp',
102
+ ]);
103
+
104
+ /** Source extensions that can compile a schema. */
105
+ const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
106
+
107
+ /** Root keyword a schema uses to declare a deliberate exemption. */
108
+ const EXEMPTION_KEY = 'x-mandrel-uncompiled';
109
+
110
+ /**
111
+ * A computed schema path — `` `${something}.schema.json` `` — anywhere in a
112
+ * source file. Evidence that the file loads schemas by runtime key rather
113
+ * than by literal name.
114
+ */
115
+ const COMPUTED_PATH_RE = /\$\{[^}]*\}\.schema\.json/;
116
+
117
+ /** Block comments, including the JSDoc prose every module here opens with. */
118
+ const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
119
+
120
+ /**
121
+ * Line comments. Anchored to start-of-line or whitespace so a `//` inside a
122
+ * URL (`http://…`) is left alone — truncating that line could delete a real
123
+ * reference sitting after it.
124
+ */
125
+ const LINE_COMMENT_RE = /(^|\s)\/\/[^\n]*/g;
126
+
127
+ /**
128
+ * Strip comments from a source before matching.
129
+ *
130
+ * Prose is not a compile. This module's own header names
131
+ * `friction-event.schema.json` a dozen times while compiling nothing, and
132
+ * every schema-loading module documents the path it loads directly above the
133
+ * line that loads it — matching raw text would let a gate certify itself and
134
+ * would count a docblock as enforcement, which is precisely the confusion
135
+ * Story #4938 exists to end.
136
+ *
137
+ * @param {string} text raw source
138
+ * @returns {string} source with comment bodies removed
139
+ */
140
+ function stripComments(text) {
141
+ return text.replace(BLOCK_COMMENT_RE, ' ').replace(LINE_COMMENT_RE, '$1');
142
+ }
143
+
144
+ /**
145
+ * Parse argv. Exported so a unit test can pin the parser without spawning.
146
+ *
147
+ * @param {string[]} argv
148
+ * @returns {{ root: string, json: boolean }}
149
+ */
150
+ export function parseArgv(argv = []) {
151
+ const out = { root: process.cwd(), json: false };
152
+ for (let i = 0; i < argv.length; i += 1) {
153
+ if (argv[i] === '--json') out.json = true;
154
+ else if (argv[i] === '--root' && argv[i + 1]) {
155
+ out.root = path.resolve(argv[i + 1]);
156
+ i += 1;
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Recursively collect files under `dir` whose extension is in `extensions`.
164
+ *
165
+ * @param {string} dir absolute directory to walk
166
+ * @param {Set<string>} extensions lowercase extensions including the dot
167
+ * @param {string[]} [acc]
168
+ * @returns {string[]} absolute file paths
169
+ */
170
+ function walk(dir, extensions, acc = []) {
171
+ let entries;
172
+ try {
173
+ entries = fs.readdirSync(dir, { withFileTypes: true });
174
+ } catch {
175
+ return acc;
176
+ }
177
+ for (const entry of entries) {
178
+ const full = path.join(dir, entry.name);
179
+ if (entry.isDirectory()) {
180
+ if (!SKIP_DIRS.has(entry.name)) walk(full, extensions, acc);
181
+ } else if (extensions.has(path.extname(entry.name).toLowerCase())) {
182
+ acc.push(full);
183
+ }
184
+ }
185
+ return acc;
186
+ }
187
+
188
+ /**
189
+ * Read every JS source in the repository once.
190
+ *
191
+ * @param {string} root absolute repository root
192
+ * @returns {Array<{ rel: string, text: string }>}
193
+ */
194
+ function readSources(root) {
195
+ return walk(root, JS_EXTENSIONS).map((file) => ({
196
+ rel: path.relative(root, file),
197
+ text: stripComments(fs.readFileSync(file, 'utf8')),
198
+ }));
199
+ }
200
+
201
+ /**
202
+ * Describe every `*.schema.json` under the schema root.
203
+ *
204
+ * @param {string} root absolute repository root
205
+ * @returns {Array<{ rel: string, basename: string, stem: string, dir: string, doc: object|null }>}
206
+ */
207
+ function readSchemas(root) {
208
+ const schemaRoot = path.join(root, SCHEMA_ROOT);
209
+ return walk(schemaRoot, new Set(['.json']))
210
+ .filter((file) => file.endsWith('.schema.json'))
211
+ .map((file) => {
212
+ const basename = path.basename(file);
213
+ let doc = null;
214
+ try {
215
+ doc = JSON.parse(fs.readFileSync(file, 'utf8'));
216
+ } catch {
217
+ doc = null;
218
+ }
219
+ return {
220
+ rel: path.relative(root, file),
221
+ basename,
222
+ stem: basename.slice(0, -'.schema.json'.length),
223
+ dir: path.relative(schemaRoot, path.dirname(file)),
224
+ doc,
225
+ };
226
+ })
227
+ .sort((a, b) => a.rel.localeCompare(b.rel));
228
+ }
229
+
230
+ /**
231
+ * Find the first source whose text contains `needle`.
232
+ *
233
+ * @param {Array<{ rel: string, text: string }>} sources
234
+ * @param {string} needle
235
+ * @returns {string | null} the relative path of the witness, or null
236
+ */
237
+ function witness(sources, needle) {
238
+ const hit = sources.find((source) => source.text.includes(needle));
239
+ return hit ? hit.rel : null;
240
+ }
241
+
242
+ /**
243
+ * Find a source that loads schemas from `dir` by computed path.
244
+ *
245
+ * The file must both build a runtime-keyed schema path and name the
246
+ * directory — as a quoted `path.join` segment or as a `schemas/<dir>` path
247
+ * fragment.
248
+ *
249
+ * @param {Array<{ rel: string, text: string }>} sources
250
+ * @param {string} dir directory name relative to the schema root
251
+ * @returns {string | null}
252
+ */
253
+ function dynamicLoaderFor(sources, dir) {
254
+ if (!dir) return null;
255
+ const hit = sources.find(
256
+ (source) =>
257
+ COMPUTED_PATH_RE.test(source.text) &&
258
+ (source.text.includes(`'${dir}'`) ||
259
+ source.text.includes(`"${dir}"`) ||
260
+ source.text.includes(`schemas/${dir}`)),
261
+ );
262
+ return hit ? hit.rel : null;
263
+ }
264
+
265
+ /**
266
+ * Read a schema's in-file exemption declaration.
267
+ *
268
+ * A bare string is accepted as the reason; the object form additionally
269
+ * carries `runtimeGate`, the `file.js#functionName` actually enforcing the
270
+ * shape at runtime. That field is what makes an exemption reviewable: a
271
+ * schema kept as a documented SSOT with a hand-rolled validator behind it is
272
+ * a different animal from one kept purely for provenance, and the report
273
+ * should not flatten the two.
274
+ *
275
+ * @param {object|null} doc parsed schema document
276
+ * @returns {{ reason: string, runtimeGate: string | null } | null}
277
+ */
278
+ function exemption(doc) {
279
+ const block = doc?.[EXEMPTION_KEY];
280
+ const reason = typeof block === 'string' ? block : block?.reason;
281
+ if (typeof reason !== 'string' || !reason.trim()) return null;
282
+ const gate =
283
+ typeof block?.runtimeGate === 'string' ? block.runtimeGate : null;
284
+ return { reason: reason.trim(), runtimeGate: gate };
285
+ }
286
+
287
+ /**
288
+ * Resolve one schema to a reference kind plus the evidence for it.
289
+ *
290
+ * @param {object} schema a `readSchemas` record
291
+ * @param {Array<{ rel: string, text: string }>} sources
292
+ * @param {Array<{ rel: string, text: string }>} schemaTexts raw schema bodies
293
+ * @returns {{ kind: string, evidence: string | object | null }}
294
+ */
295
+ function resolveSchema(schema, sources, schemaTexts) {
296
+ const literal = witness(sources, schema.basename);
297
+ if (literal) return { kind: 'literal', evidence: literal };
298
+
299
+ const ref = schemaTexts.find(
300
+ (other) =>
301
+ other.rel !== schema.rel &&
302
+ other.text.includes(`"$ref"`) &&
303
+ other.text.includes(schema.basename),
304
+ );
305
+ if (ref) return { kind: 'schema-ref', evidence: ref.rel };
306
+
307
+ const loader = dynamicLoaderFor(sources, schema.dir);
308
+ if (loader) {
309
+ const key = witness(sources, schema.stem);
310
+ if (key) return { kind: 'dynamic', evidence: `${loader} (key: ${key})` };
311
+ }
312
+
313
+ const declared = exemption(schema.doc);
314
+ if (declared) return { kind: 'exempt', evidence: declared };
315
+
316
+ return { kind: 'unreferenced', evidence: null };
317
+ }
318
+
319
+ /**
320
+ * Run the audit and return a structured envelope.
321
+ *
322
+ * @param {{ root?: string }} [opts]
323
+ * @returns {{ schemaCount: number, referenced: number, exempt: Array<object>, findings: Array<object> }}
324
+ */
325
+ export function auditSchemaReferences(opts = {}) {
326
+ const root = opts.root ?? process.cwd();
327
+ const sources = readSources(root);
328
+ const schemas = readSchemas(root);
329
+ const schemaTexts = schemas.map((schema) => ({
330
+ rel: schema.rel,
331
+ text: fs.readFileSync(path.join(root, schema.rel), 'utf8'),
332
+ }));
333
+
334
+ const exempt = [];
335
+ const findings = [];
336
+ let referenced = 0;
337
+
338
+ for (const schema of schemas) {
339
+ const { kind, evidence } = resolveSchema(schema, sources, schemaTexts);
340
+ if (kind === 'unreferenced') findings.push({ schema: schema.rel });
341
+ else if (kind === 'exempt')
342
+ exempt.push({ schema: schema.rel, ...evidence });
343
+ else referenced += 1;
344
+ }
345
+
346
+ return { schemaCount: schemas.length, referenced, exempt, findings };
347
+ }
348
+
349
+ /**
350
+ * Render the human-readable report.
351
+ *
352
+ * @param {ReturnType<typeof auditSchemaReferences>} report
353
+ * @returns {string}
354
+ */
355
+ function formatReport(report) {
356
+ const digest =
357
+ `[check-schema-references] ${report.schemaCount} schema(s): ` +
358
+ `${report.referenced} compiled, ${report.exempt.length} exempt, ` +
359
+ `${report.findings.length} unreferenced.`;
360
+ if (report.findings.length === 0) return digest;
361
+ const lines = report.findings.map(
362
+ (finding) => ` ✗ ${finding.schema} — no code path compiles it.`,
363
+ );
364
+ return [
365
+ digest,
366
+ ...lines,
367
+ '',
368
+ 'Every schema under .agents/schemas/ must be compiled by code, or say in',
369
+ `its own body why not. Either delete it, wire it, or add a root-level`,
370
+ `"${EXEMPTION_KEY}": { "reason": "…" } declaring the retention.`,
371
+ ].join('\n');
372
+ }
373
+
374
+ /**
375
+ * CLI entry point.
376
+ *
377
+ * @param {string[]} [argv]
378
+ * @returns {Promise<number>} process exit code
379
+ */
380
+ export async function main(argv = process.argv.slice(2)) {
381
+ const { root, json } = parseArgv(argv);
382
+ const report = auditSchemaReferences({ root });
383
+ process.stdout.write(
384
+ json ? `${JSON.stringify(report)}\n` : `${formatReport(report)}\n`,
385
+ );
386
+ return report.findings.length > 0 ? 1 : 0;
387
+ }
388
+
389
+ runAsCli(import.meta.url, main, {
390
+ source: 'check-schema-references',
391
+ propagateExitCode: true,
392
+ });
@@ -656,5 +656,42 @@ runAsCli(
656
656
  const code = runHygiene(parseArgv(process.argv.slice(2)));
657
657
  return code;
658
658
  },
659
- { source: 'check-test-temp-hygiene', propagateExitCode: true },
659
+ {
660
+ source: 'check-test-temp-hygiene',
661
+ propagateExitCode: true,
662
+ usage: {
663
+ invocation:
664
+ 'node .agents/scripts/check-test-temp-hygiene.js [--snapshot | --assert | --clean] [--baseline <path>] [--lint-globs <globs>] [--ids <ids>] [--root <dir>]',
665
+ summary:
666
+ 'Regression guard for test-fixture pollution of the temp trees: the repo temp/ telemetry streams every retro reads, and the OS temp root the suite scratch dirs nest under.',
667
+ flags: [
668
+ [
669
+ '--snapshot',
670
+ 'Fingerprint every temp/ stream file and the pre-existing OS suite roots. Run BEFORE the suite.',
671
+ ],
672
+ [
673
+ '--assert',
674
+ 'Re-scan and fail on any stream added or grown, or a surviving suite root. Run AFTER the suite. This is the default when no mode flag is passed.',
675
+ ],
676
+ [
677
+ '--clean',
678
+ 'List stream directories whose Epic/Story id matches a known fixture id. Report-only — deletes nothing.',
679
+ ],
680
+ [
681
+ '--baseline <path>',
682
+ 'Explicit snapshot path (CI sets a runner-temp path). Defaults to an OS scratch location keyed by the repo root; refused inside the protected temp/ tree.',
683
+ ],
684
+ [
685
+ '--lint-globs <globs>',
686
+ 'Comma-separated repo-relative globs scanned for tests calling mkdtemp against os.tmpdir() instead of makeTempDir(). Off unless passed.',
687
+ ],
688
+ ['--ids <ids>', 'Comma-separated fixture ids for --clean.'],
689
+ ['--root <dir>', 'Repository root to scan (default: repo root).'],
690
+ ],
691
+ notes: [
692
+ '--assert requires a snapshot recorded by an earlier --snapshot run: a missing one is a hard failure, never a silent re-baseline.',
693
+ 'Exit codes:\n 0 clean\n 1 a breach, or --assert with no snapshot to attest against',
694
+ ],
695
+ },
696
+ },
660
697
  );