mandrel 2.15.0 → 2.17.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/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +28 -6
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-light.js +31 -3
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/process.js +61 -15
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
- package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +38 -15
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-context.js +4 -1
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/.agents/workflows/helpers/deliver-light.md +34 -8
- package/.agents/workflows/helpers/plan-reference.md +27 -6
- package/.agents/workflows/plan.md +4 -2
- package/.agents/workflows/prototype.md +104 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +41 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -133,6 +133,7 @@ function resolveGeneratedAt(explicit) {
|
|
|
133
133
|
* rows: Array<object>,
|
|
134
134
|
* kernelVersion: string,
|
|
135
135
|
* generatedAt?: string,
|
|
136
|
+
* extras?: Record<string, unknown>,
|
|
136
137
|
* }} params
|
|
137
138
|
* @returns {{
|
|
138
139
|
* $schema: string,
|
|
@@ -148,6 +149,7 @@ export function buildEnvelope({
|
|
|
148
149
|
rows,
|
|
149
150
|
kernelVersion,
|
|
150
151
|
generatedAt,
|
|
152
|
+
extras,
|
|
151
153
|
} = {}) {
|
|
152
154
|
if (typeof kind !== 'string' || !KNOWN_KINDS.includes(kind)) {
|
|
153
155
|
throw new TypeError(
|
|
@@ -176,10 +178,15 @@ export function buildEnvelope({
|
|
|
176
178
|
throw new TypeError('envelope.buildEnvelope: rows must be an array');
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
// Per-kind envelope-level stamps (Story #4775). A kind whose SCORING
|
|
182
|
+
// SEMANTICS can change independently of its kernel version contributes them
|
|
183
|
+
// here; `assertEnvelope` still validates the result against the kind's
|
|
184
|
+
// schema, so an unrecognised extra fails closed rather than being persisted.
|
|
179
185
|
return {
|
|
180
186
|
$schema: schemaRefFor(kind),
|
|
181
187
|
kernelVersion,
|
|
182
188
|
generatedAt: resolveGeneratedAt(generatedAt),
|
|
189
|
+
...(extras && typeof extras === 'object' ? extras : {}),
|
|
183
190
|
rollup,
|
|
184
191
|
rows,
|
|
185
192
|
};
|
|
@@ -50,7 +50,9 @@ import {
|
|
|
50
50
|
} from './kinds/coverage.js';
|
|
51
51
|
import {
|
|
52
52
|
applyEpsilon as crapApplyEpsilon,
|
|
53
|
+
assertBaselineCompatible as crapAssertBaselineCompatible,
|
|
53
54
|
compare as crapCompare,
|
|
55
|
+
envelopeExtras as crapEnvelopeExtras,
|
|
54
56
|
kernelVersion as crapKernelVersion,
|
|
55
57
|
keyField as crapKeyField,
|
|
56
58
|
mergeRows as crapMergeRows,
|
|
@@ -139,6 +141,12 @@ function bindKindModule(members) {
|
|
|
139
141
|
compare: members.compare,
|
|
140
142
|
applyEpsilon: members.applyEpsilon,
|
|
141
143
|
mergeRows: members.mergeRows,
|
|
144
|
+
// Optional per-kind hooks (Story #4775). `envelopeExtras` contributes
|
|
145
|
+
// envelope-level stamps the shared writer would not otherwise know about;
|
|
146
|
+
// `assertBaselineCompatible` lets a kind refuse a loaded baseline whose
|
|
147
|
+
// scoring semantics predate the running scorer.
|
|
148
|
+
envelopeExtras: members.envelopeExtras,
|
|
149
|
+
assertBaselineCompatible: members.assertBaselineCompatible,
|
|
142
150
|
});
|
|
143
151
|
}
|
|
144
152
|
|
|
@@ -179,6 +187,8 @@ const KIND_MODULES = Object.freeze({
|
|
|
179
187
|
compare: crapCompare,
|
|
180
188
|
applyEpsilon: crapApplyEpsilon,
|
|
181
189
|
mergeRows: crapMergeRows,
|
|
190
|
+
envelopeExtras: crapEnvelopeExtras,
|
|
191
|
+
assertBaselineCompatible: crapAssertBaselineCompatible,
|
|
182
192
|
}),
|
|
183
193
|
maintainability: bindKindModule({
|
|
184
194
|
name: maintainabilityName,
|
|
@@ -270,6 +280,27 @@ export function currentKernelVersion(kind) {
|
|
|
270
280
|
return getKindModule(kind).kernelVersion();
|
|
271
281
|
}
|
|
272
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Ask a kind whether a loaded baseline is compatible with the running
|
|
285
|
+
* scorer's SEMANTICS — a dimension `kernelVersion` cannot express, because a
|
|
286
|
+
* kind's scoring can change while the upstream package it stamps does not
|
|
287
|
+
* (Story #4775). Kinds without the hook always answer "compatible".
|
|
288
|
+
*
|
|
289
|
+
* @param {string} kind
|
|
290
|
+
* @param {object|null} baseline
|
|
291
|
+
* @returns {string|null} Operator-facing message, or null when compatible.
|
|
292
|
+
*/
|
|
293
|
+
export function checkBaselineSemantics(kind, baseline) {
|
|
294
|
+
let mod;
|
|
295
|
+
try {
|
|
296
|
+
mod = getKindModule(kind);
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
if (typeof mod.assertBaselineCompatible !== 'function') return null;
|
|
301
|
+
return mod.assertBaselineCompatible(baseline);
|
|
302
|
+
}
|
|
303
|
+
|
|
273
304
|
/**
|
|
274
305
|
* Compare a baseline's stamped version against the currently running
|
|
275
306
|
* kernel for the same kind. Returns `{ match, current }` so callers can
|
|
@@ -75,6 +75,41 @@ export function kernelVersion() {
|
|
|
75
75
|
return '0.0.0';
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Scoring-semantics stamp (Story #4775, fix part 5).
|
|
80
|
+
*
|
|
81
|
+
* `kernelVersion()` above tracks the `typhonjs-escomplex` package and
|
|
82
|
+
* `escomplexVersion` tracks the same dependency — so a change in how THIS
|
|
83
|
+
* repo joins escomplex methods to istanbul coverage moves neither. Rows
|
|
84
|
+
* scored by the pre-#4775 join (exact transpiled-line equality, methods
|
|
85
|
+
* dropped when unresolved) are not comparable to rows scored by the join
|
|
86
|
+
* that replaced it (original-source coordinates, containment matching,
|
|
87
|
+
* honest `requireCoverage: false`): the same method can carry a different
|
|
88
|
+
* `crap`, a different `startLine`, or exist in one baseline and not the
|
|
89
|
+
* other. Comparing across that boundary produces phantom regressions and,
|
|
90
|
+
* worse, phantom passes.
|
|
91
|
+
*
|
|
92
|
+
* The stamp makes the boundary explicit and fails closed. Bump it whenever
|
|
93
|
+
* the coverage join, the line coordinate system, or the unresolved-method
|
|
94
|
+
* policy changes.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately module-local: `envelopeExtras()` is the single production door
|
|
97
|
+
* to this value, so exporting the bare constant would add a second entry
|
|
98
|
+
* point that nothing in production reaches. Callers and tests that need the
|
|
99
|
+
* string read it off `envelopeExtras().scoringSemantics`.
|
|
100
|
+
*/
|
|
101
|
+
const SCORING_SEMANTICS = 'coverage-join-v2';
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Envelope-level stamps this kind contributes beyond the shared envelope
|
|
105
|
+
* keys. Consumed by `writer.write` via the kind-module protocol.
|
|
106
|
+
*
|
|
107
|
+
* @returns {{scoringSemantics: string}}
|
|
108
|
+
*/
|
|
109
|
+
export function envelopeExtras() {
|
|
110
|
+
return { scoringSemantics: SCORING_SEMANTICS };
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
export function projectRow(row) {
|
|
79
114
|
return {
|
|
80
115
|
path: canonicalise(row.path ?? row.file),
|
|
@@ -372,6 +407,23 @@ export const CRAP_COMPAT_AXES = [
|
|
|
372
407
|
: null,
|
|
373
408
|
},
|
|
374
409
|
kernelDriftAxis('CRAP'),
|
|
410
|
+
{
|
|
411
|
+
name: 'scoring-semantics-drift',
|
|
412
|
+
severity: 'fatal',
|
|
413
|
+
check: ({ baseline }) => {
|
|
414
|
+
if (!baseline) return null;
|
|
415
|
+
const stamped = baseline.scoringSemantics ?? null;
|
|
416
|
+
if (stamped === SCORING_SEMANTICS) return null;
|
|
417
|
+
return (
|
|
418
|
+
`[CRAP] scoring semantics changed: baseline=${stamped ?? '<unstamped>'} ` +
|
|
419
|
+
`running=${SCORING_SEMANTICS}. Rows scored by the previous per-method ` +
|
|
420
|
+
'coverage join are not comparable to rows scored by the current one, ' +
|
|
421
|
+
'so this baseline cannot be compared — it must be re-derived. Run ' +
|
|
422
|
+
"'npm run test:coverage' then 'npm run crap:update -- --full-scope' " +
|
|
423
|
+
"and commit the result with a 'baseline-refresh:' subject."
|
|
424
|
+
);
|
|
425
|
+
},
|
|
426
|
+
},
|
|
375
427
|
{
|
|
376
428
|
name: 'ts-transpiler-drift',
|
|
377
429
|
severity: 'warn',
|
|
@@ -401,6 +453,30 @@ export function evaluateBaselineCompatibility(ctx) {
|
|
|
401
453
|
return reduceCompatAxes(CRAP_COMPAT_AXES, ctx);
|
|
402
454
|
}
|
|
403
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Kind-module hook (Story #4775): the subset of the compat table that a
|
|
458
|
+
* *loaded* v2 envelope can be judged against on its own, with no running
|
|
459
|
+
* dependency versions to compare. The unified `check-baselines` gate calls it
|
|
460
|
+
* straight after `reader.load` and turns a message into a fail-closed
|
|
461
|
+
* schema-class error, so a baseline written by the previous scoring semantics
|
|
462
|
+
* can never be silently compared against new-semantics scores.
|
|
463
|
+
*
|
|
464
|
+
* The version-drift axes stay out: the v2 envelope does not carry
|
|
465
|
+
* `escomplexVersion` / `tsTranspilerVersion`, and running those checks against
|
|
466
|
+
* an absent field would compare `undefined` to `undefined` and pass
|
|
467
|
+
* vacuously — worse than not running them.
|
|
468
|
+
*
|
|
469
|
+
* @param {object|null} baseline A loaded v2 baseline envelope.
|
|
470
|
+
* @returns {string|null} Operator-facing message, or null when compatible.
|
|
471
|
+
*/
|
|
472
|
+
export function assertBaselineCompatible(baseline) {
|
|
473
|
+
if (!baseline) return null;
|
|
474
|
+
const axis = CRAP_COMPAT_AXES.find(
|
|
475
|
+
(a) => a.name === 'scoring-semantics-drift',
|
|
476
|
+
);
|
|
477
|
+
return axis ? axis.check({ baseline }) : null;
|
|
478
|
+
}
|
|
479
|
+
|
|
404
480
|
/**
|
|
405
481
|
* Pure helper: resolve the CRAP baseline either from the working tree
|
|
406
482
|
* (via `getCrapBaseline`) or, when `epicRef` is supplied, from
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
// hand-edited while inside a story worktree — so downstream
|
|
19
19
|
// consumers see canonical repo-relative paths.
|
|
20
20
|
// 5. Returns the envelope's headline fields plus rows/rollup as a
|
|
21
|
-
// narrow contract: `{ rollup, rows, kernelVersion, generatedAt
|
|
21
|
+
// narrow contract: `{ rollup, rows, kernelVersion, generatedAt,
|
|
22
|
+
// scoringSemantics }`.
|
|
22
23
|
//
|
|
23
24
|
// Reader-only: the writer side lives in a sibling module (Story #1891).
|
|
24
25
|
// No I/O happens here beyond reading the JSON file itself.
|
|
@@ -215,6 +216,11 @@ function readAndShape(kind, absolutePath) {
|
|
|
215
216
|
rows,
|
|
216
217
|
kernelVersion: parsed.kernelVersion,
|
|
217
218
|
generatedAt: parsed.generatedAt,
|
|
219
|
+
// Story #4775 — carry the per-kind scoring-semantics stamp through the
|
|
220
|
+
// narrowing. The gate's compat check reads it off the LOADED envelope, so
|
|
221
|
+
// dropping it here would make every baseline look unstamped and fail the
|
|
222
|
+
// whole repo closed on a stamp that is actually present on disk.
|
|
223
|
+
scoringSemantics: parsed.scoringSemantics,
|
|
218
224
|
};
|
|
219
225
|
}
|
|
220
226
|
|
|
@@ -303,6 +309,11 @@ export function loadFile(absolutePath, opts = {}) {
|
|
|
303
309
|
rows,
|
|
304
310
|
kernelVersion: parsed.kernelVersion,
|
|
305
311
|
generatedAt: parsed.generatedAt,
|
|
312
|
+
// Story #4775 — carry the per-kind scoring-semantics stamp through the
|
|
313
|
+
// narrowing. The gate's compat check reads it off the LOADED envelope, so
|
|
314
|
+
// dropping it here would make every baseline look unstamped and fail the
|
|
315
|
+
// whole repo closed on a stamp that is actually present on disk.
|
|
316
|
+
scoringSemantics: parsed.scoringSemantics,
|
|
306
317
|
};
|
|
307
318
|
}
|
|
308
319
|
|
|
@@ -355,11 +355,17 @@ const KIND_SCORER_BUILDERS = Object.freeze({
|
|
|
355
355
|
* rather than crashing the refresh. The production crap/maintainability paths
|
|
356
356
|
* never rely on this fallback — they inject an explicit, configured scorer.
|
|
357
357
|
*
|
|
358
|
+
* Exported since Story #4776 so the full-scope drift detector
|
|
359
|
+
* (`check-baseline-drift.js`) re-scores through the *same* scorer that
|
|
360
|
+
* writes the baseline. A drift check scoring by a second, parallel
|
|
361
|
+
* implementation would report the two implementations' disagreement as
|
|
362
|
+
* drift, which is exactly the false signal it exists to rule out.
|
|
363
|
+
*
|
|
358
364
|
* @param {string} kind
|
|
359
365
|
* @param {{ cwd: string }} opts
|
|
360
366
|
* @returns {((files: string[], opts: object) => Promise<object[]> | object[]) | undefined}
|
|
361
367
|
*/
|
|
362
|
-
function resolveDefaultScorer(kind, { cwd } = {}) {
|
|
368
|
+
export function resolveDefaultScorer(kind, { cwd } = {}) {
|
|
363
369
|
const builder = KIND_SCORER_BUILDERS[kind];
|
|
364
370
|
if (typeof builder !== 'function') return undefined;
|
|
365
371
|
const effectiveCwd = cwd ?? process.cwd();
|
|
@@ -184,6 +184,8 @@ export function write({
|
|
|
184
184
|
rollup,
|
|
185
185
|
kernelVersion: kernelVersion ?? currentKernelVersion(kind),
|
|
186
186
|
generatedAt,
|
|
187
|
+
extras:
|
|
188
|
+
typeof mod.envelopeExtras === 'function' ? mod.envelopeExtras() : null,
|
|
187
189
|
});
|
|
188
190
|
assertEnvelope(envelope);
|
|
189
191
|
return envelope;
|
|
@@ -258,10 +260,18 @@ export function writeFile(absPath, envelope, opts = {}) {
|
|
|
258
260
|
// Canonical key order on the top-level envelope keeps diffs stable
|
|
259
261
|
// across runs and platforms. Per-kind row keys retain their natural
|
|
260
262
|
// declaration order; the row sort is done by `sortRows()`.
|
|
263
|
+
//
|
|
264
|
+
// Story #4775: the projection is deliberately explicit, so any per-kind
|
|
265
|
+
// envelope stamp (`scoringSemantics`) must be carried through by name or it
|
|
266
|
+
// is silently dropped on the way to disk — the stamp would then be present
|
|
267
|
+
// in memory, validated, and absent from the file it exists to protect.
|
|
261
268
|
const canonical = {
|
|
262
269
|
$schema: envelope.$schema,
|
|
263
270
|
kernelVersion: envelope.kernelVersion,
|
|
264
271
|
generatedAt: envelope.generatedAt,
|
|
272
|
+
...(envelope.scoringSemantics === undefined
|
|
273
|
+
? {}
|
|
274
|
+
: { scoringSemantics: envelope.scoringSemantics }),
|
|
265
275
|
rollup: envelope.rollup,
|
|
266
276
|
rows: envelope.rows,
|
|
267
277
|
};
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* fan-out target, or `&` shell backgrounding) within a small line window.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import
|
|
29
|
+
import nodeFs from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
|
|
32
32
|
const SCAN_ROOT_DEFAULT = '.agents';
|
|
@@ -54,14 +54,20 @@ const BACKGROUND_TOKENS = [
|
|
|
54
54
|
* and `.md` sources. Skips `node_modules`, `.worktrees`, and directories
|
|
55
55
|
* starting with `.git`.
|
|
56
56
|
*
|
|
57
|
+
* The optional final `fsImpl` parameter defaults to the real `node:fs`
|
|
58
|
+
* (`.agents/rules/test-seams.md` rule 1) and is forwarded to the recursive
|
|
59
|
+
* call rather than re-acquired there (rule 4), so a test drives the whole walk
|
|
60
|
+
* through a plain stub object instead of module mocking (rule 5).
|
|
61
|
+
*
|
|
57
62
|
* @param {string} dir
|
|
63
|
+
* @param {typeof nodeFs} [fsImpl]
|
|
58
64
|
* @returns {string[]}
|
|
59
65
|
*/
|
|
60
|
-
function walkSources(dir) {
|
|
66
|
+
export function walkSources(dir, fsImpl = nodeFs) {
|
|
61
67
|
const out = [];
|
|
62
68
|
let entries;
|
|
63
69
|
try {
|
|
64
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
70
|
+
entries = fsImpl.readdirSync(dir, { withFileTypes: true });
|
|
65
71
|
} catch {
|
|
66
72
|
return out;
|
|
67
73
|
}
|
|
@@ -75,7 +81,7 @@ function walkSources(dir) {
|
|
|
75
81
|
) {
|
|
76
82
|
continue;
|
|
77
83
|
}
|
|
78
|
-
out.push(...walkSources(full));
|
|
84
|
+
out.push(...walkSources(full, fsImpl));
|
|
79
85
|
continue;
|
|
80
86
|
}
|
|
81
87
|
if (!entry.isFile()) continue;
|
|
@@ -94,11 +100,14 @@ function walkSources(dir) {
|
|
|
94
100
|
* any dedicated check module (this file): the source-of-truth
|
|
95
101
|
* implementation legitimately mentions itself.
|
|
96
102
|
*
|
|
103
|
+
* Pure: it is handed the already-read source, so it needs no filesystem seam
|
|
104
|
+
* of its own.
|
|
105
|
+
*
|
|
97
106
|
* @param {string} file
|
|
98
107
|
* @param {string} src
|
|
99
108
|
* @returns {Array<{ line: number, kind: string }>}
|
|
100
109
|
*/
|
|
101
|
-
function scanFile(file, src) {
|
|
110
|
+
export function scanFile(file, src) {
|
|
102
111
|
const offences = [];
|
|
103
112
|
// Don't flag the actual story-init script, self-references, or the
|
|
104
113
|
// parallel-tooling helper — the helper documents both Rule 2
|
|
@@ -149,15 +158,21 @@ export default {
|
|
|
149
158
|
scope: ['story-close', 'retro'],
|
|
150
159
|
autoCorrect: 'refuse-and-print',
|
|
151
160
|
|
|
152
|
-
|
|
161
|
+
/**
|
|
162
|
+
* @param {{ cwd?: string, scanRoot?: string, scope?: string }} [state]
|
|
163
|
+
* @param {typeof nodeFs} [fsImpl] Optional final filesystem seam; defaults
|
|
164
|
+
* to the real `node:fs` (`.agents/rules/test-seams.md` rule 1) and is
|
|
165
|
+
* forwarded to {@link walkSources} rather than re-acquired (rule 4).
|
|
166
|
+
*/
|
|
167
|
+
detect(state, fsImpl = nodeFs) {
|
|
153
168
|
const cwd = state?.cwd ?? process.cwd();
|
|
154
169
|
const root = state?.scanRoot ?? path.join(cwd, SCAN_ROOT_DEFAULT);
|
|
155
|
-
const files = walkSources(root);
|
|
170
|
+
const files = walkSources(root, fsImpl);
|
|
156
171
|
const offences = [];
|
|
157
172
|
for (const file of files) {
|
|
158
173
|
let src;
|
|
159
174
|
try {
|
|
160
|
-
src = readFileSync(file, 'utf8');
|
|
175
|
+
src = fsImpl.readFileSync(file, 'utf8');
|
|
161
176
|
} catch {
|
|
162
177
|
continue;
|
|
163
178
|
}
|
|
@@ -18,6 +18,7 @@ import path from 'node:path';
|
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { respondToHelp } from './cli-usage.js';
|
|
20
20
|
import { formatCliError } from './error-redactor.js';
|
|
21
|
+
import { flushStdio } from './stdio-flush.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Is the current module being executed directly as a CLI (as opposed to
|
|
@@ -32,11 +33,48 @@ export function isDirectInvocation(importMetaUrl) {
|
|
|
32
33
|
return fileURLToPath(importMetaUrl) === path.resolve(entry);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Settle one CLI run: await `main`, translate its outcome into an exit code,
|
|
38
|
+
* then flush stdio.
|
|
39
|
+
*
|
|
40
|
+
* **The exit is never eager** (Story #4783). This helper assigns
|
|
41
|
+
* `process.exitCode` and returns, letting Node terminate once the event loop
|
|
42
|
+
* is empty — which is *after* the pending stdout writes drain. The previous
|
|
43
|
+
* `process.exit()` call terminated first, discarding anything still queued
|
|
44
|
+
* behind a full pipe buffer, so any CLI emitting more than 64 KiB into a pipe
|
|
45
|
+
* truncated silently while still reporting success. The resulting exit code is
|
|
46
|
+
* identical for every caller: `code ?? 0` on the `propagateExitCode` path,
|
|
47
|
+
* `options.exitCode` (default 1) on the fatal-error path, and an untouched 0
|
|
48
|
+
* everywhere else.
|
|
49
|
+
*
|
|
50
|
+
* @param {() => Promise<unknown>} main
|
|
51
|
+
* @param {{ source: string, exitCode: number, onError?: (err: Error) => void,
|
|
52
|
+
* propagateExitCode: boolean, errorPrefix?: string }} settings
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
*/
|
|
55
|
+
async function settleCli(main, settings) {
|
|
56
|
+
const { source, exitCode, onError, propagateExitCode, errorPrefix } =
|
|
57
|
+
settings;
|
|
58
|
+
try {
|
|
59
|
+
const code = await main();
|
|
60
|
+
if (propagateExitCode) process.exitCode = code ?? 0;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (typeof onError === 'function') {
|
|
63
|
+
onError(err);
|
|
64
|
+
} else {
|
|
65
|
+
const prefix = errorPrefix ?? `[${source}] Fatal error`;
|
|
66
|
+
console.error(`${prefix}: ${formatCliError(err)}`);
|
|
67
|
+
process.exitCode = exitCode;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await flushStdio();
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
/**
|
|
36
74
|
* Run `main` as the CLI entry point for the caller's module. No-op when the
|
|
37
75
|
* module is imported rather than invoked directly. Promise rejection from
|
|
38
76
|
* `main` is funnelled through either the caller-supplied `onError` callback
|
|
39
|
-
* or the default handler (prefixed stderr line + `process.
|
|
77
|
+
* or the default handler (prefixed stderr line + `process.exitCode`).
|
|
40
78
|
*
|
|
41
79
|
* A `usage` option makes the script self-describing: when the argv carries
|
|
42
80
|
* `--help` / `-h`, the rendered usage block goes to stdout and `main` is
|
|
@@ -51,6 +89,9 @@ export function isDirectInvocation(importMetaUrl) {
|
|
|
51
89
|
* @param {string} [options.source='CLI'] Prefix used in the default error message.
|
|
52
90
|
* @param {number} [options.exitCode=1] Exit code used by the default error handler.
|
|
53
91
|
* @param {(err: Error) => void} [options.onError] Full override of the error handler.
|
|
92
|
+
* @param {boolean} [options.propagateExitCode=false] Adopt `main`'s resolved
|
|
93
|
+
* value as the process exit code.
|
|
94
|
+
* @param {string} [options.errorPrefix] Overrides the `[source] Fatal error` prefix.
|
|
54
95
|
* @param {object|string} [options.usage] Usage spec (or pre-rendered
|
|
55
96
|
* text) printed for `--help`; see `lib/cli-usage.js`.
|
|
56
97
|
*/
|
|
@@ -65,17 +106,11 @@ export function runAsCli(importMetaUrl, main, options = {}) {
|
|
|
65
106
|
usage,
|
|
66
107
|
} = options;
|
|
67
108
|
if (usage && respondToHelp(process.argv.slice(2), usage)) return;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
onError(err);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
const prefix = errorPrefix ?? `[${source}] Fatal error`;
|
|
78
|
-
console.error(`${prefix}: ${formatCliError(err)}`);
|
|
79
|
-
process.exit(exitCode);
|
|
109
|
+
void settleCli(main, {
|
|
110
|
+
source,
|
|
111
|
+
exitCode,
|
|
112
|
+
onError,
|
|
113
|
+
propagateExitCode,
|
|
114
|
+
errorPrefix,
|
|
80
115
|
});
|
|
81
116
|
}
|
|
@@ -11,22 +11,48 @@ import { spawn } from 'node:child_process';
|
|
|
11
11
|
* Pipe a child stream's output line-by-line through `emit`, prepending
|
|
12
12
|
* `prefix` to each line. Tail bytes without a trailing newline flush on
|
|
13
13
|
* `end` so the operator never loses the last line of a gate's output.
|
|
14
|
+
*
|
|
15
|
+
* ## The drain must stay cheap (Story #4766)
|
|
16
|
+
*
|
|
17
|
+
* This handler runs on the reader side of the child's stdout/stderr pipe.
|
|
18
|
+
* Every microsecond spent here is a microsecond the pipe is not being read,
|
|
19
|
+
* and once the OS pipe buffer fills, the child's own write blocks — or, on a
|
|
20
|
+
* non-blocking pipe, fails outright with `EAGAIN`. A gate child is not
|
|
21
|
+
* obliged to survive that: Biome's `biome_console` `.unwrap()`s the error and
|
|
22
|
+
* aborts the process with exit 101, so a green lint verdict presents as a
|
|
23
|
+
* failed close. Two consequences bind everything on this path:
|
|
24
|
+
*
|
|
25
|
+
* 1. Splitting is O(chunk), not O(chunk × lines) — the scan advances a
|
|
26
|
+
* `start` index instead of re-slicing the buffer once per line, so a
|
|
27
|
+
* 64KB chunk carrying 500 lines does not copy 16MB.
|
|
28
|
+
* 2. **`emit` MUST NOT block.** A synchronous per-line file write is
|
|
29
|
+
* exactly the stall this path cannot afford; the close path's capture
|
|
30
|
+
* sink (`single-story-close/gate-log.js`) buffers to an async stream for
|
|
31
|
+
* that reason.
|
|
14
32
|
*/
|
|
15
33
|
function pipePrefixed(stream, prefix, emit) {
|
|
16
34
|
let buf = '';
|
|
17
35
|
stream.setEncoding('utf8');
|
|
18
36
|
stream.on('data', (chunk) => {
|
|
19
37
|
buf += chunk;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
emit(prefix + buf.slice(
|
|
24
|
-
|
|
38
|
+
let start = 0;
|
|
39
|
+
let nl = buf.indexOf('\n', start);
|
|
40
|
+
while (nl !== -1) {
|
|
41
|
+
emit(prefix + buf.slice(start, nl));
|
|
42
|
+
start = nl + 1;
|
|
43
|
+
nl = buf.indexOf('\n', start);
|
|
25
44
|
}
|
|
45
|
+
if (start > 0) buf = buf.slice(start);
|
|
26
46
|
});
|
|
27
47
|
stream.on('end', () => {
|
|
28
|
-
if (buf.length > 0)
|
|
48
|
+
if (buf.length > 0) {
|
|
49
|
+
emit(prefix + buf);
|
|
50
|
+
buf = '';
|
|
51
|
+
}
|
|
29
52
|
});
|
|
53
|
+
// A pipe-level error (EIO on a vanished child) must not become an
|
|
54
|
+
// unhandled 'error' event that takes the whole close down.
|
|
55
|
+
stream.on('error', () => {});
|
|
30
56
|
}
|
|
31
57
|
|
|
32
58
|
/** Wire the AbortSignal so an abort kills the child. Returns the cleanup fn. */
|
|
@@ -68,6 +94,18 @@ export function gateExitCode(code, sig) {
|
|
|
68
94
|
const BIOME_NO_FILES_PROCESSED =
|
|
69
95
|
'No files were processed in the specified paths';
|
|
70
96
|
|
|
97
|
+
/**
|
|
98
|
+
* How many trailing gate lines the "No files were processed" probe retains.
|
|
99
|
+
*
|
|
100
|
+
* The marker only ever appears when biome processed nothing, and in that case
|
|
101
|
+
* its whole output is a handful of lines — so a bounded tail always contains
|
|
102
|
+
* it when it is there at all. Retaining a tail rather than the full transcript
|
|
103
|
+
* keeps the drain path's per-line work O(1) in the volume of gate output
|
|
104
|
+
* (Story #4766): the previous `captured += line` grew a string without limit,
|
|
105
|
+
* on the one gate (biome/format) whose output is the loudest.
|
|
106
|
+
*/
|
|
107
|
+
const MARKER_PROBE_TAIL_LINES = 32;
|
|
108
|
+
|
|
71
109
|
/**
|
|
72
110
|
* Whether biome's combined gate output carries the "No files were processed"
|
|
73
111
|
* marker. Pure function — no I/O. Exported for unit coverage (Story #4292).
|
|
@@ -85,8 +123,11 @@ export function isBiomeNoFilesProcessed(output) {
|
|
|
85
123
|
* Default async gate runner — used by `runCloseValidation` when no `runner`
|
|
86
124
|
* is injected. Spawns the gate via `child_process.spawn`, prefixes every
|
|
87
125
|
* stdout/stderr line with `[gate-name] ` (so concurrent gates don't bleed
|
|
88
|
-
* into each other in the operator's terminal), and resolves only
|
|
89
|
-
* child
|
|
126
|
+
* into each other in the operator's terminal), and resolves only once the
|
|
127
|
+
* child has exited and both stdio pipes are drained.
|
|
128
|
+
*
|
|
129
|
+
* `opts.log` is the drain sink and **must not block** — see `pipePrefixed`
|
|
130
|
+
* above for what a synchronous per-line write costs the child (Story #4766).
|
|
90
131
|
*
|
|
91
132
|
* Honours `opts.signal`: a TERM is delivered to the child the moment the
|
|
92
133
|
* signal fires, so a sibling gate's failure aborts the rest of the wave
|
|
@@ -119,13 +160,14 @@ export function defaultGateRunner(cmd, args, opts = {}) {
|
|
|
119
160
|
const prefix = gateName ? `[${gateName}] ` : '';
|
|
120
161
|
const emit =
|
|
121
162
|
typeof log === 'function' ? log : (m) => process.stdout.write(`${m}\n`);
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
163
|
+
// Retain a bounded tail only when we may need to inspect it for the biome
|
|
164
|
+
// "No files were processed" marker — otherwise the stream is purely piped
|
|
165
|
+
// through to the operator (no retained buffer).
|
|
166
|
+
const recent = [];
|
|
126
167
|
const tap = tolerateNoFilesProcessed
|
|
127
168
|
? (line) => {
|
|
128
|
-
|
|
169
|
+
recent.push(line);
|
|
170
|
+
if (recent.length > MARKER_PROBE_TAIL_LINES) recent.shift();
|
|
129
171
|
emit(line);
|
|
130
172
|
}
|
|
131
173
|
: emit;
|
|
@@ -133,13 +175,17 @@ export function defaultGateRunner(cmd, args, opts = {}) {
|
|
|
133
175
|
pipePrefixed(child.stderr, prefix, tap);
|
|
134
176
|
const detach = attachGateAbortHandler(child, signal);
|
|
135
177
|
return new Promise((resolve) => {
|
|
136
|
-
|
|
178
|
+
// 'close', not 'exit' (Story #4766): 'close' fires only once the child has
|
|
179
|
+
// exited AND both stdio pipes have been fully drained and closed, so no
|
|
180
|
+
// gate ever reports its status while lines are still in flight. Resolving
|
|
181
|
+
// on 'exit' raced the tail of a high-volume gate's output.
|
|
182
|
+
child.on('close', (code, sig) => {
|
|
137
183
|
detach();
|
|
138
184
|
const status = gateExitCode(code, sig);
|
|
139
185
|
if (
|
|
140
186
|
status !== 0 &&
|
|
141
187
|
tolerateNoFilesProcessed &&
|
|
142
|
-
isBiomeNoFilesProcessed(
|
|
188
|
+
isBiomeNoFilesProcessed(recent.join('\n'))
|
|
143
189
|
) {
|
|
144
190
|
emit(
|
|
145
191
|
`${prefix}↳ biome processed zero files (all changed paths are config-ignored); treating as a clean skip`,
|