mandrel 2.24.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.
- package/.agents/agents/acceptance-critic.md +19 -21
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -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 +8 -0
- package/.agents/schemas/baselines/crap.schema.json +13 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +124 -15
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- 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-context-budget.js +95 -14
- 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 +121 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -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-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -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 +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- 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 -7
- 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/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +124 -27
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +86 -13
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/env-loader.js +46 -16
- 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/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/label-constants.js +17 -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/acceptance-eval-decision.js +110 -17
- 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/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- 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/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- 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 +216 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
- 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/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
- 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/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
- 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/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- 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/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
- package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
- package/.agents/scripts/lib/workers/crap-worker.js +8 -13
- 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/pr-watch-with-update.js +568 -213
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +137 -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 +36 -36
- package/.agents/scripts/single-story-close.js +34 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +308 -47
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- 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/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +92 -97
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- 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 +95 -4
- package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +49 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +62 -75
- package/README.md +13 -6
- package/docs/CHANGELOG.md +93 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- 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,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
|
+
});
|
|
@@ -78,6 +78,11 @@ import os from 'node:os';
|
|
|
78
78
|
import path from 'node:path';
|
|
79
79
|
import { fileURLToPath } from 'node:url';
|
|
80
80
|
import { runAsCli } from './lib/cli-utils.js';
|
|
81
|
+
import { mainCheckoutRoot } from './lib/config/temp-paths.js';
|
|
82
|
+
import {
|
|
83
|
+
isReservedTestId,
|
|
84
|
+
RESERVED_TEST_ID_BAND,
|
|
85
|
+
} from './lib/reserved-test-ids.js';
|
|
81
86
|
import {
|
|
82
87
|
findRawTmpdirMkdtemp,
|
|
83
88
|
listSuiteTempRoots,
|
|
@@ -369,6 +374,84 @@ export function cleanFixtureDirs({
|
|
|
369
374
|
return { candidates, removed };
|
|
370
375
|
}
|
|
371
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Every Epic / Story id a stream file's own path attributes it to.
|
|
379
|
+
*
|
|
380
|
+
* Both canonical layouts are read, and a nested Epic-attached stream yields
|
|
381
|
+
* both ids (`run-<eid>/stories/story-<sid>/…`): either half being a fixture id
|
|
382
|
+
* makes the stream fixture-owned, and taking only the outer one is how a
|
|
383
|
+
* fixture Story under a real run would slip past.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} rel POSIX-normalised path relative to `temp/`.
|
|
386
|
+
* @returns {number[]}
|
|
387
|
+
*/
|
|
388
|
+
function streamOwnerIds(rel) {
|
|
389
|
+
const ids = [];
|
|
390
|
+
const run = /^run-(\d+)$/.exec(rel.split('/')[0]);
|
|
391
|
+
if (run) ids.push(Number(run[1]));
|
|
392
|
+
const story = /(?:^|\/)story-(\d+)\//.exec(rel);
|
|
393
|
+
if (story) ids.push(Number(story[1]));
|
|
394
|
+
return ids;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Stream files under `tempDir` owned by a **reserved test-fixture id**
|
|
399
|
+
* (Story #4892).
|
|
400
|
+
*
|
|
401
|
+
* This is the residual-pollution dimension the snapshot/assert bracket cannot
|
|
402
|
+
* cover: the bracket only runs in CI, where `temp/` starts empty, so a local
|
|
403
|
+
* run that appends fixture telemetry to the operator's live ledger was only
|
|
404
|
+
* ever discovered from the ticket the retro graduator filed off it (issue
|
|
405
|
+
* #4870 cited `#999999`, a `--story 999999` CLI spawn from the suite).
|
|
406
|
+
*
|
|
407
|
+
* Unlike the snapshot diff this needs no baseline and is immune to a
|
|
408
|
+
* concurrent delivery in another checkout: a reserved id is reserved *from*
|
|
409
|
+
* real work, so nothing but a test can own one of these files.
|
|
410
|
+
*
|
|
411
|
+
* @param {string} tempDir
|
|
412
|
+
* @returns {string[]} POSIX-normalised paths relative to `tempDir`, sorted.
|
|
413
|
+
*/
|
|
414
|
+
export function findReservedIdStreamFiles(tempDir) {
|
|
415
|
+
return listStreamFiles(tempDir).filter((rel) =>
|
|
416
|
+
streamOwnerIds(rel).some(isReservedTestId),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Post-run guard: fail when a test run left a fixture-id telemetry stream in
|
|
422
|
+
* the **repository-root** temp tree (Story #4892).
|
|
423
|
+
*
|
|
424
|
+
* Resolution matters more than it looks: every writer anchors a relative
|
|
425
|
+
* `tempRoot` to the *main checkout* (so a Story worktree and its `/deliver`
|
|
426
|
+
* host converge on one ledger), so a guard that scanned `cwd` would scan an
|
|
427
|
+
* empty worktree tree and pass vacuously on the very tree it is meant to
|
|
428
|
+
* protect. `resolveRoot` is the injection seam for tests.
|
|
429
|
+
*
|
|
430
|
+
* @param {object} [opts]
|
|
431
|
+
* @param {string} [opts.cwd=process.cwd()]
|
|
432
|
+
* @param {(line: string) => void} [opts.log]
|
|
433
|
+
* @param {(cwd: string) => string|null} [opts.resolveRoot]
|
|
434
|
+
* @returns {number} exit code (0 clean, 1 polluted).
|
|
435
|
+
*/
|
|
436
|
+
export function assertNoReservedIdStreams({
|
|
437
|
+
cwd = process.cwd(),
|
|
438
|
+
log = (l) => process.stderr.write(`${l}\n`),
|
|
439
|
+
resolveRoot = mainCheckoutRoot,
|
|
440
|
+
} = {}) {
|
|
441
|
+
const root = resolveRoot(cwd) ?? cwd;
|
|
442
|
+
const tempDir = tempDirFor(root);
|
|
443
|
+
const found = findReservedIdStreamFiles(tempDir);
|
|
444
|
+
if (found.length === 0) return 0;
|
|
445
|
+
log(
|
|
446
|
+
`[test-temp-hygiene] FAIL — ${found.length} fixture-id telemetry stream(s) in the real temp tree (${tempDir}):`,
|
|
447
|
+
);
|
|
448
|
+
for (const rel of found) log(` + fixture ${rel}`);
|
|
449
|
+
log(
|
|
450
|
+
`[test-temp-hygiene] ids ${RESERVED_TEST_ID_BAND} are reserved for fixtures, so a test wrote to the live ledger — the retro graduator reads these streams and files tickets off them. Inject an absolute per-test tempRoot on the offending spawn, then remove the stream(s) with --clean --ids <id> --yes.`,
|
|
451
|
+
);
|
|
452
|
+
return 1;
|
|
453
|
+
}
|
|
454
|
+
|
|
372
455
|
/**
|
|
373
456
|
* Parse the CLI argv into a normalised options object.
|
|
374
457
|
* @param {string[]} argv
|
|
@@ -573,5 +656,42 @@ runAsCli(
|
|
|
573
656
|
const code = runHygiene(parseArgv(process.argv.slice(2)));
|
|
574
657
|
return code;
|
|
575
658
|
},
|
|
576
|
-
{
|
|
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
|
+
},
|
|
577
697
|
);
|