mandrel 2.25.0 → 2.27.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.
- package/.agents/agents/acceptance-critic.md +10 -6
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +3 -1
- package/.agents/instructions.md +1 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +8 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +89 -6
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +38 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +26 -4
- package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
- package/.agents/scripts/lib/baselines/reader.js +52 -38
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -11
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/crap-engine.js +32 -13
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +13 -0
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
- package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
- package/.agents/scripts/lib/workers/crap-worker.js +2 -10
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +112 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +26 -36
- package/.agents/scripts/single-story-close.js +28 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/stories-wave-tick.js +214 -38
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/deliver.md +13 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +77 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
- package/.agents/workflows/helpers/plan-reference.md +15 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +3 -0
- package/README.md +13 -6
- package/docs/CHANGELOG.md +71 -0
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- 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
|
-
{
|
|
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
|
);
|