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.
- 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/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 +64 -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
|
@@ -41,10 +41,20 @@
|
|
|
41
41
|
* tier along with the per-AC-cluster `--epic <id> --cluster <id>` mode that
|
|
42
42
|
* scored an Epic `## Acceptance Table` against a `main..epic/<id>` diff.)
|
|
43
43
|
*
|
|
44
|
+
* One gate call per round (Story #4951). A round may fan out into N parallel
|
|
45
|
+
* maker-blind cluster critics, but their per-cluster verdicts are merged by
|
|
46
|
+
* the caller into ONE verdict — `criteria[]` in acceptance-array order — and
|
|
47
|
+
* scored here exactly once. Invoking the gate per cluster instead would burn
|
|
48
|
+
* one Story-level round per cluster (distinct fingerprints defeat the replay
|
|
49
|
+
* guard) and race the `signals.ndjson` round ledger. `--expected-criteria`
|
|
50
|
+
* makes that contract enforceable: a partial (single-cluster) verdict is
|
|
51
|
+
* rejected before scoring, so the mistake costs no round.
|
|
52
|
+
*
|
|
44
53
|
* CLI:
|
|
45
|
-
* --story <id>
|
|
46
|
-
* --verdict <path>
|
|
47
|
-
* --
|
|
54
|
+
* --story <id> Story ID (required).
|
|
55
|
+
* --verdict <path> Path to the round's verdict JSON (required).
|
|
56
|
+
* --expected-criteria <n> Reject a verdict not covering exactly n criteria.
|
|
57
|
+
* --no-signal Suppress the signal emit (tests).
|
|
48
58
|
*
|
|
49
59
|
* Stdout: a single JSON envelope
|
|
50
60
|
* { storyId, epicId, decision, round, cap, capReached, totalCriteria,
|
|
@@ -143,6 +153,7 @@ function parseCliArgs(argv) {
|
|
|
143
153
|
options: {
|
|
144
154
|
story: { type: 'string' },
|
|
145
155
|
verdict: { type: 'string' },
|
|
156
|
+
'expected-criteria': { type: 'string' },
|
|
146
157
|
'no-signal': { type: 'boolean', default: false },
|
|
147
158
|
},
|
|
148
159
|
strict: false,
|
|
@@ -151,10 +162,69 @@ function parseCliArgs(argv) {
|
|
|
151
162
|
return {
|
|
152
163
|
storyId: Number.isInteger(storyId) && storyId > 0 ? storyId : null,
|
|
153
164
|
verdictPath: values.verdict ?? null,
|
|
165
|
+
expectedCriteria: values['expected-criteria'] ?? null,
|
|
154
166
|
emitSignal: values['no-signal'] !== true,
|
|
155
167
|
};
|
|
156
168
|
}
|
|
157
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The merge contract, stated once so both the flag error and the coverage
|
|
172
|
+
* error name the same shape the caller has to produce.
|
|
173
|
+
*/
|
|
174
|
+
const MERGE_CONTRACT =
|
|
175
|
+
'One round = N parallel cluster critics -> ONE merged verdict -> ONE gate call: ' +
|
|
176
|
+
"merge every cluster's records into a single criteria[] in acceptance[] order, " +
|
|
177
|
+
'one per acceptance item, before scoring.';
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve the optional `--expected-criteria` flag to a positive integer, or
|
|
181
|
+
* `null` when the flag is absent (which preserves the pre-#4951 behaviour
|
|
182
|
+
* exactly — no coverage assertion is made).
|
|
183
|
+
*
|
|
184
|
+
* Exported for tests.
|
|
185
|
+
*
|
|
186
|
+
* @param {string|null|undefined} raw
|
|
187
|
+
* @returns {number|null}
|
|
188
|
+
*/
|
|
189
|
+
export function resolveExpectedCriteria(raw) {
|
|
190
|
+
if (raw === null || raw === undefined) return null;
|
|
191
|
+
// Digits only. `Number.parseInt` stops at the first non-digit, so `4abc`
|
|
192
|
+
// resolved to 4 — a guard whose entire job is to reject a wrong-sized
|
|
193
|
+
// verdict was itself accepting a malformed count, and a typo'd `--expected-
|
|
194
|
+
// criteria` would then wave through a verdict of the wrong length.
|
|
195
|
+
const text = String(raw).trim();
|
|
196
|
+
const expected = /^\d+$/.test(text) ? Number(text) : Number.NaN;
|
|
197
|
+
if (!Number.isInteger(expected) || expected < 1) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`acceptance-eval: --expected-criteria must be a positive integer (the Story's acceptance[] count). ${MERGE_CONTRACT}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return expected;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reject a verdict that does not cover exactly `expectedCriteria` criteria.
|
|
207
|
+
*
|
|
208
|
+
* Called **before** `runAcceptanceEval`, which is where the round ledger is
|
|
209
|
+
* read and appended — so a partial cluster verdict handed to the gate by
|
|
210
|
+
* mistake costs no round and can never escalate a `redraft` into a `block`.
|
|
211
|
+
*
|
|
212
|
+
* Exported for tests.
|
|
213
|
+
*
|
|
214
|
+
* @param {object} verdict — schema-validated verdict.
|
|
215
|
+
* @param {number|null} expectedCriteria — `null` disables the assertion.
|
|
216
|
+
* @returns {void}
|
|
217
|
+
*/
|
|
218
|
+
export function assertCriteriaCoverage(verdict, expectedCriteria) {
|
|
219
|
+
if (expectedCriteria === null) return;
|
|
220
|
+
const actual = Array.isArray(verdict?.criteria) ? verdict.criteria.length : 0;
|
|
221
|
+
if (actual === expectedCriteria) return;
|
|
222
|
+
throw new Error(
|
|
223
|
+
`acceptance-eval: verdict covers ${actual} criteria but --expected-criteria is ${expectedCriteria}. ` +
|
|
224
|
+
`${MERGE_CONTRACT} No round was consumed.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
158
228
|
/**
|
|
159
229
|
* Compose the operator-facing envelope and emit the per-criterion signal.
|
|
160
230
|
*
|
|
@@ -294,11 +364,13 @@ export async function runAcceptanceEvalCli(
|
|
|
294
364
|
runAcceptanceEvalImpl = runAcceptanceEval,
|
|
295
365
|
logger = Logger,
|
|
296
366
|
} = deps;
|
|
297
|
-
const { storyId, verdictPath, emitSignal } =
|
|
367
|
+
const { storyId, verdictPath, expectedCriteria, emitSignal } =
|
|
368
|
+
parseCliArgs(argv);
|
|
369
|
+
const expected = resolveExpectedCriteria(expectedCriteria);
|
|
298
370
|
|
|
299
371
|
if (!storyId) {
|
|
300
372
|
throw new Error(
|
|
301
|
-
'Usage: node acceptance-eval.js --story <id> --verdict <path> [--no-signal]',
|
|
373
|
+
'Usage: node acceptance-eval.js --story <id> --verdict <path> [--expected-criteria <n>] [--no-signal]',
|
|
302
374
|
);
|
|
303
375
|
}
|
|
304
376
|
if (!verdictPath) {
|
|
@@ -329,6 +401,11 @@ export async function runAcceptanceEvalCli(
|
|
|
329
401
|
|
|
330
402
|
const verdict = validateVerdictImpl(parsed);
|
|
331
403
|
|
|
404
|
+
// Story #4951: a merged verdict must cover every acceptance[] item. This
|
|
405
|
+
// runs before the round ledger is touched, so a partial cluster verdict is
|
|
406
|
+
// a free mistake.
|
|
407
|
+
assertCriteriaCoverage(verdict, expected);
|
|
408
|
+
|
|
332
409
|
// A verdict whose embedded storyId disagrees with the CLI flag is a
|
|
333
410
|
// wiring error worth failing on, not a silent mismatch.
|
|
334
411
|
if (Number.isInteger(verdict.storyId) && verdict.storyId !== storyId) {
|
|
@@ -372,12 +449,18 @@ runAsCli(import.meta.url, main, {
|
|
|
372
449
|
source: 'acceptance-eval',
|
|
373
450
|
usage: {
|
|
374
451
|
invocation:
|
|
375
|
-
'node .agents/scripts/acceptance-eval.js --story <id> --verdict <path> [--no-signal]',
|
|
452
|
+
'node .agents/scripts/acceptance-eval.js --story <id> --verdict <path> [--expected-criteria <n>] [--no-signal]',
|
|
376
453
|
summary:
|
|
377
454
|
"Score an authored acceptance verdict against the Story's acceptance[] criteria and emit the bounded loop's proceed / redraft / block decision.",
|
|
378
455
|
flags: [
|
|
379
456
|
['--story <id>', 'GitHub issue number of the Story (required).'],
|
|
380
457
|
['--verdict <path>', 'Path to the authored verdict JSON (required).'],
|
|
458
|
+
[
|
|
459
|
+
'--expected-criteria <n>',
|
|
460
|
+
'Reject — before scoring, consuming no round — a verdict whose criteria[] ' +
|
|
461
|
+
"length is not n. Pass the Story's acceptance[] count so a partial " +
|
|
462
|
+
'cluster verdict cannot be scored as the round.',
|
|
463
|
+
],
|
|
381
464
|
[
|
|
382
465
|
'--no-signal',
|
|
383
466
|
"Skip appending the per-criterion signal to the Story's signals ledger.",
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI: baseline hotspot engine for the `/audit-baselines` lens (Story #4902).
|
|
5
|
+
*
|
|
6
|
+
* Turns the committed `baselines/` folder into ranked hotspot clusters,
|
|
7
|
+
* gate-surface health signals, trend deltas, and floor-tightening headroom —
|
|
8
|
+
* the deterministic evidence half of a baseline review, so the lens spends
|
|
9
|
+
* its judgment on findings instead of re-deriving the numbers by hand.
|
|
10
|
+
*
|
|
11
|
+
* Read-only by contract: nothing under `baselines/` is written, and no test,
|
|
12
|
+
* coverage, or mutation suite is run. The only file this process creates is
|
|
13
|
+
* the envelope at `--out`.
|
|
14
|
+
*
|
|
15
|
+
* Exit 0 whenever evidence was assembled — findings are evidence, not a gate.
|
|
16
|
+
* A missing git history, absent friction ledger, or unresolvable import graph
|
|
17
|
+
* are reported as degradations and still exit 0. Only an unwritable `--out`
|
|
18
|
+
* (or a missing one) is a failure.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* node .agents/scripts/audit-baselines.js --out temp/audit-baselines/envelope.json
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Fail-fast if the framework's runtime deps are not installed — must be the
|
|
25
|
+
// first import so the check runs before any third-party-importing sibling
|
|
26
|
+
// module is evaluated (Story #3432).
|
|
27
|
+
import './lib/runtime-deps/ensure-installed.js';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import process from 'node:process';
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_HOTSPOT_LIMIT,
|
|
33
|
+
runEngine,
|
|
34
|
+
summarize,
|
|
35
|
+
} from './lib/audit-baselines/engine.js';
|
|
36
|
+
import { DEFAULT_TOP_N } from './lib/audit-baselines/outliers.js';
|
|
37
|
+
import { buildBaselineSchemaAjv } from './lib/baseline-schema-registry.js';
|
|
38
|
+
import { defineFlags } from './lib/cli-args.js';
|
|
39
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
40
|
+
|
|
41
|
+
const ENVELOPE_SCHEMA_FILE = 'audit-baselines-envelope.schema.json';
|
|
42
|
+
|
|
43
|
+
const FLAG_SPEC = {
|
|
44
|
+
out: { type: 'string' },
|
|
45
|
+
cwd: { type: 'string' },
|
|
46
|
+
'top-n': { type: 'integer' },
|
|
47
|
+
'hotspot-limit': { type: 'integer' },
|
|
48
|
+
'trend-depth': { type: 'integer' },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate the envelope against its shipped schema through the shared
|
|
53
|
+
* baseline schema registry. Throws with the AJV error list on mismatch —
|
|
54
|
+
* a malformed envelope is an engine bug, not evidence.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} envelope
|
|
57
|
+
* @returns {void}
|
|
58
|
+
*/
|
|
59
|
+
export function assertEnvelope(envelope) {
|
|
60
|
+
const validate = buildBaselineSchemaAjv().getSchema(ENVELOPE_SCHEMA_FILE);
|
|
61
|
+
if (!validate) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[audit-baselines] ${ENVELOPE_SCHEMA_FILE} is not registered in the baseline schema registry`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (validate(envelope)) return;
|
|
67
|
+
const detail = (validate.errors ?? [])
|
|
68
|
+
.map((e) => `${e.instancePath || '/'} ${e.message}`)
|
|
69
|
+
.join('; ');
|
|
70
|
+
throw new Error(
|
|
71
|
+
`[audit-baselines] envelope failed schema validation: ${detail}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Assemble the envelope, validate it, write it, and return the stdout
|
|
77
|
+
* summary. Exported so tests drive the whole pipeline without spawning.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ argv?: string[], cwd?: string, stdout?: { write: (s: string) => void } }} [opts]
|
|
80
|
+
* @returns {Promise<number>} exit code
|
|
81
|
+
*/
|
|
82
|
+
export async function runCli({
|
|
83
|
+
argv = process.argv.slice(2),
|
|
84
|
+
cwd = process.cwd(),
|
|
85
|
+
stdout = process.stdout,
|
|
86
|
+
} = {}) {
|
|
87
|
+
const { values } = defineFlags(FLAG_SPEC, argv);
|
|
88
|
+
if (!values.out) {
|
|
89
|
+
throw new Error('[audit-baselines] --out <path> is required');
|
|
90
|
+
}
|
|
91
|
+
const repoRoot = path.resolve(values.cwd ?? cwd);
|
|
92
|
+
const outPath = path.resolve(repoRoot, values.out);
|
|
93
|
+
|
|
94
|
+
const envelope = runEngine({
|
|
95
|
+
cwd: repoRoot,
|
|
96
|
+
topN: values.topN ?? DEFAULT_TOP_N,
|
|
97
|
+
hotspotLimit: values.hotspotLimit ?? DEFAULT_HOTSPOT_LIMIT,
|
|
98
|
+
trendDepth: values.trendDepth ?? 5,
|
|
99
|
+
});
|
|
100
|
+
assertEnvelope(envelope);
|
|
101
|
+
|
|
102
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
103
|
+
fs.writeFileSync(outPath, `${JSON.stringify(envelope, null, 2)}\n`, 'utf8');
|
|
104
|
+
|
|
105
|
+
stdout.write(`${JSON.stringify(summarize(envelope, outPath), null, 2)}\n`);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
runAsCli(import.meta.url, async () => runCli(), {
|
|
110
|
+
source: 'audit-baselines',
|
|
111
|
+
propagateExitCode: true,
|
|
112
|
+
errorPrefix: '[audit-baselines] ❌ Fatal error',
|
|
113
|
+
usage: {
|
|
114
|
+
invocation:
|
|
115
|
+
'node .agents/scripts/audit-baselines.js --out <path> [--cwd <dir>] [--top-n <n>] [--hotspot-limit <n>] [--trend-depth <n>]',
|
|
116
|
+
summary:
|
|
117
|
+
'Read-only baseline hotspot engine: extract bounded per-gate outliers, cluster them per file, rank by severity x churn x import in-degree x friction, and report gate-surface health, trend deltas, and floor headroom.',
|
|
118
|
+
flags: [
|
|
119
|
+
['--out <path>', 'Write the JSON envelope here (required).'],
|
|
120
|
+
['--cwd <dir>', 'Repository root to analyse (default: cwd).'],
|
|
121
|
+
[
|
|
122
|
+
'--top-n <n>',
|
|
123
|
+
`Outlier rows extracted per gate (default: ${DEFAULT_TOP_N}).`,
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
'--hotspot-limit <n>',
|
|
127
|
+
`Hotspot clusters emitted (default: ${DEFAULT_HOTSPOT_LIMIT}).`,
|
|
128
|
+
],
|
|
129
|
+
['--trend-depth <n>', 'Baseline commits sampled per kind (default: 5).'],
|
|
130
|
+
],
|
|
131
|
+
notes: [
|
|
132
|
+
'Never writes under baselines/ and never runs a test, coverage, or mutation suite.',
|
|
133
|
+
'Exit codes:\n 0 evidence assembled (including every degraded input)\n 1 the envelope could not be built or written',
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
});
|
|
@@ -37,16 +37,19 @@ import fs from 'node:fs';
|
|
|
37
37
|
import path from 'node:path';
|
|
38
38
|
import process from 'node:process';
|
|
39
39
|
import { runAsCli } from './lib/cli-utils.js';
|
|
40
|
+
import {
|
|
41
|
+
buildGraph,
|
|
42
|
+
collectJsFiles,
|
|
43
|
+
DEFAULT_ROOTS,
|
|
44
|
+
parseRelativeImports,
|
|
45
|
+
} from './lib/import-graph.js';
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
* @type {string[]}
|
|
48
|
-
*/
|
|
49
|
-
export const DEFAULT_ROOTS = [path.join('.agents', 'scripts'), 'bin', 'lib'];
|
|
47
|
+
// The import-graph builder itself lives in `lib/import-graph.js` (Story
|
|
48
|
+
// #4902) so `audit-baselines.js` can rank hotspots by import in-degree
|
|
49
|
+
// against the same graph this ratchet detects cycles in. Re-exported here
|
|
50
|
+
// because this module's named exports are its unit-test surface and its
|
|
51
|
+
// documented contract; the behaviour is unchanged by the move.
|
|
52
|
+
export { buildGraph, collectJsFiles, DEFAULT_ROOTS, parseRelativeImports };
|
|
50
53
|
|
|
51
54
|
/**
|
|
52
55
|
* Parse argv for `--baseline <path>`, `--root <path>`, and `--json`.
|
|
@@ -80,90 +83,6 @@ export function parseArgv(argv = []) {
|
|
|
80
83
|
return { baselinePath, rootPath, json };
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
/**
|
|
84
|
-
* Recursively collect `.js` files under `rootDir`, skipping
|
|
85
|
-
* `node_modules`. Returns absolute paths, sorted for determinism.
|
|
86
|
-
*
|
|
87
|
-
* @param {string} rootDir
|
|
88
|
-
* @returns {string[]}
|
|
89
|
-
*/
|
|
90
|
-
export function collectJsFiles(rootDir) {
|
|
91
|
-
const out = [];
|
|
92
|
-
const walk = (dir) => {
|
|
93
|
-
let entries;
|
|
94
|
-
try {
|
|
95
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
96
|
-
} catch {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
for (const entry of entries) {
|
|
100
|
-
if (entry.name === 'node_modules') continue;
|
|
101
|
-
const full = path.join(dir, entry.name);
|
|
102
|
-
if (entry.isDirectory()) {
|
|
103
|
-
walk(full);
|
|
104
|
-
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
105
|
-
out.push(full);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
walk(rootDir);
|
|
110
|
-
return out.sort();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const IMPORT_RE = /from\s+['"](\.\.?\/[^'"]+\.js)['"]/g;
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Pure helper: extract relative static-import specifiers from source text.
|
|
117
|
-
*
|
|
118
|
-
* @param {string} source
|
|
119
|
-
* @returns {string[]}
|
|
120
|
-
*/
|
|
121
|
-
export function parseRelativeImports(source) {
|
|
122
|
-
const specs = [];
|
|
123
|
-
for (const m of source.matchAll(IMPORT_RE)) {
|
|
124
|
-
specs.push(m[1]);
|
|
125
|
-
}
|
|
126
|
-
return specs;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Build a directed import graph over the given files. Node identity is the
|
|
131
|
-
* file path relative to `rootDir`, posix-separated, so the graph (and any
|
|
132
|
-
* cycles found in it) serializes identically across platforms. Edges that
|
|
133
|
-
* resolve outside the scanned file set are dropped.
|
|
134
|
-
*
|
|
135
|
-
* @param {string[]} files absolute paths
|
|
136
|
-
* @param {string} rootDir
|
|
137
|
-
* @param {{ readFile?: (p: string) => string }} [opts]
|
|
138
|
-
* @returns {Map<string, string[]>}
|
|
139
|
-
*/
|
|
140
|
-
export function buildGraph(files, rootDir, { readFile } = {}) {
|
|
141
|
-
const read = readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
|
|
142
|
-
const toId = (abs) => path.relative(rootDir, abs).split(path.sep).join('/');
|
|
143
|
-
const idSet = new Set(files.map(toId));
|
|
144
|
-
const graph = new Map();
|
|
145
|
-
for (const file of files) {
|
|
146
|
-
const id = toId(file);
|
|
147
|
-
let source;
|
|
148
|
-
try {
|
|
149
|
-
source = read(file);
|
|
150
|
-
} catch {
|
|
151
|
-
graph.set(id, []);
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
const edges = [];
|
|
155
|
-
for (const spec of parseRelativeImports(source)) {
|
|
156
|
-
const target = path
|
|
157
|
-
.relative(rootDir, path.resolve(path.dirname(file), spec))
|
|
158
|
-
.split(path.sep)
|
|
159
|
-
.join('/');
|
|
160
|
-
if (idSet.has(target) && target !== id) edges.push(target);
|
|
161
|
-
}
|
|
162
|
-
graph.set(id, [...new Set(edges)].sort());
|
|
163
|
-
}
|
|
164
|
-
return graph;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
86
|
/**
|
|
168
87
|
* Pure helper: rotate a cycle (array of module ids, no repeated terminal
|
|
169
88
|
* element) so it starts at its lexicographically-smallest member. The same
|
|
@@ -118,21 +118,34 @@ export async function runCheckBaselineDrift({
|
|
|
118
118
|
return { exitCode: run.ok ? 0 : 1, output };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Run the drift check and *return* its exit code rather than calling
|
|
123
|
+
* `process.exit()` — this CLI prints one row per drifted baseline entry
|
|
124
|
+
* full-scope, so its report is exactly the kind of payload that outgrows the
|
|
125
|
+
* 64 KiB pipe buffer under a `| tee`. `process.exit()` terminates before a
|
|
126
|
+
* queued async pipe write drains, silently truncating it (Story #4783, the
|
|
127
|
+
* same defect `check-baselines.js` carried). Handing the code back lets
|
|
128
|
+
* `runAsCli`'s `propagateExitCode` path settle it through
|
|
129
|
+
* `settleCli`/`flushStdio` instead. The 0/1/2 contract documented at the top
|
|
130
|
+
* of this file is unchanged — only *when* the process leaves is.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Promise<number>} 0 no drift, 1 drift detected, 2 could not run.
|
|
133
|
+
*/
|
|
121
134
|
async function main() {
|
|
122
135
|
let result;
|
|
123
136
|
try {
|
|
124
137
|
result = await runCheckBaselineDrift({ argv: process.argv.slice(2) });
|
|
125
138
|
} catch (err) {
|
|
126
139
|
process.stdout.write(`${err?.message ?? String(err)}\n`);
|
|
127
|
-
|
|
128
|
-
return;
|
|
140
|
+
return 2;
|
|
129
141
|
}
|
|
130
142
|
process.stdout.write(`${result.output}\n`);
|
|
131
|
-
|
|
143
|
+
return result.exitCode;
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
runAsCli(import.meta.url, main, {
|
|
135
147
|
source: 'check-baseline-drift',
|
|
136
148
|
usage: HELP_TEXT,
|
|
137
149
|
exitCode: 2,
|
|
150
|
+
propagateExitCode: true,
|
|
138
151
|
});
|
|
@@ -65,6 +65,22 @@ export {
|
|
|
65
65
|
selectEnabledGates,
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Run the dispatcher and *return* its exit code rather than calling
|
|
70
|
+
* `process.exit()`.
|
|
71
|
+
*
|
|
72
|
+
* The return is load-bearing (Story #4783's defect, reintroduced here):
|
|
73
|
+
* `process.exit()` terminates before a queued async pipe write drains, so a
|
|
74
|
+
* full `--gate coverage` report — a quarter of a megabyte of JSON — arrived
|
|
75
|
+
* truncated at the 64 KiB pipe boundary under the `| tee` in CI's coverage
|
|
76
|
+
* step, while still exiting 0. Handing the code back to `runAsCli`'s
|
|
77
|
+
* `propagateExitCode` path settles it through `settleCli`/`flushStdio`
|
|
78
|
+
* instead, which assigns `process.exitCode` and lets Node terminate once
|
|
79
|
+
* stdout has drained. The 0/1/2/3/4 contract in `lib/baselines/exit-codes.js`
|
|
80
|
+
* is unchanged — only *when* the process leaves is.
|
|
81
|
+
*
|
|
82
|
+
* @returns {Promise<number>} An exit code from the `EXIT_*` contract.
|
|
83
|
+
*/
|
|
68
84
|
async function main() {
|
|
69
85
|
let result;
|
|
70
86
|
try {
|
|
@@ -74,14 +90,14 @@ async function main() {
|
|
|
74
90
|
process.stdout.write(
|
|
75
91
|
`${JSON.stringify({ schemaVersion: '1', error: message }, null, 2)}\n`,
|
|
76
92
|
);
|
|
77
|
-
|
|
78
|
-
return;
|
|
93
|
+
return EXIT_CONFIG;
|
|
79
94
|
}
|
|
80
95
|
process.stdout.write(`${result.output}\n`);
|
|
81
|
-
|
|
96
|
+
return result.exitCode;
|
|
82
97
|
}
|
|
83
98
|
|
|
84
99
|
runAsCli(import.meta.url, main, {
|
|
85
100
|
source: 'check-baselines',
|
|
86
101
|
usage: HELP_TEXT,
|
|
102
|
+
propagateExitCode: true,
|
|
87
103
|
});
|