mandrel 2.16.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/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-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/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/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +14 -0
- 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-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/README.md +14 -6
- package/docs/CHANGELOG.md +24 -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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* story-deliver-terminal-schema.js — load `story-deliver-terminal.schema.json`
|
|
3
|
+
* and validate envelopes against it.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `story-deliver-terminal.js` so the envelope WRITER holds only
|
|
6
|
+
* the contract's shape and vocabulary, and this module holds the one thing
|
|
7
|
+
* the writer must never depend on at call time: the filesystem.
|
|
8
|
+
*
|
|
9
|
+
* That separation is the fix, not just tidiness. `single-story-close.js`
|
|
10
|
+
* invoked by a *worktree-relative* path runs the Story worktree's own copy of
|
|
11
|
+
* the script and then **reaps that worktree** as one of its phases. The schema
|
|
12
|
+
* used to be read lazily, on the first envelope build — which happens after
|
|
13
|
+
* the reap — so the read hit a path that no longer existed. The throw landed
|
|
14
|
+
* inside the close CLI's error path, so a Story whose PR had merged, whose
|
|
15
|
+
* label was `agent::done`, and whose post-land tail was green exited non-zero
|
|
16
|
+
* emitting NO envelope at all: the delivery engine's documented return
|
|
17
|
+
* contract lost to a success, recoverable only by a second close run from the
|
|
18
|
+
* main checkout.
|
|
19
|
+
*
|
|
20
|
+
* Two guarantees close that, and both live here:
|
|
21
|
+
*
|
|
22
|
+
* 1. The schema is read and parsed ONCE, at module load. The parsed schema
|
|
23
|
+
* outlives the file, so what happens to the directory afterwards is
|
|
24
|
+
* irrelevant. Compilation stays lazy — it needs no filesystem — so an
|
|
25
|
+
* import costs one small read and nothing else.
|
|
26
|
+
* 2. An unreadable schema DEGRADES validation rather than throwing. A
|
|
27
|
+
* schema violation still fails loudly; see {@link validateTerminalEnvelope}.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
35
|
+
import addFormats from 'ajv-formats';
|
|
36
|
+
|
|
37
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Absolute path to the shipped schema — the SSOT this module reads.
|
|
41
|
+
*
|
|
42
|
+
* Module-private, like every other `SCHEMA_PATH` in the tree
|
|
43
|
+
* (`validation-evidence.js`, `signal-validator.js`): the path is an
|
|
44
|
+
* implementation detail of loading, and callers want the verdict, not the
|
|
45
|
+
* location.
|
|
46
|
+
*/
|
|
47
|
+
const SCHEMA_PATH = path.resolve(
|
|
48
|
+
__dirname,
|
|
49
|
+
'..',
|
|
50
|
+
'..',
|
|
51
|
+
'..',
|
|
52
|
+
'schemas',
|
|
53
|
+
'story-deliver-terminal.schema.json',
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read and parse the shipped schema. Never throws: a read failure is recorded
|
|
58
|
+
* on the returned source and degrades validation downstream, because importing
|
|
59
|
+
* this module must never be what breaks a delivery.
|
|
60
|
+
*
|
|
61
|
+
* @returns {{ schema: object|null, error: string|null }}
|
|
62
|
+
*/
|
|
63
|
+
function loadSchemaSource() {
|
|
64
|
+
try {
|
|
65
|
+
return {
|
|
66
|
+
schema: JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8')),
|
|
67
|
+
error: null,
|
|
68
|
+
};
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return { schema: null, error: err?.message ?? String(err) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The schema, read and parsed at module load. Deliberately eager — see the
|
|
76
|
+
* module header for the failure that made it so.
|
|
77
|
+
*
|
|
78
|
+
* @type {{ schema: object|null, error: string|null }}
|
|
79
|
+
*/
|
|
80
|
+
const SCHEMA_SOURCE = loadSchemaSource();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compiled validators keyed by the source object they came from.
|
|
84
|
+
*
|
|
85
|
+
* A `WeakMap` rather than one module-level slot so an injected `schemaSource`
|
|
86
|
+
* (the test seam) can never poison the validator the production path memoizes.
|
|
87
|
+
*
|
|
88
|
+
* @type {WeakMap<object, Function>}
|
|
89
|
+
*/
|
|
90
|
+
const VALIDATORS = new WeakMap();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compile (once per source) and return the terminal-envelope validator, or
|
|
94
|
+
* `null` when the source carries no usable schema.
|
|
95
|
+
*
|
|
96
|
+
* @param {{ schema: object|null }} source
|
|
97
|
+
* @returns {Function|null}
|
|
98
|
+
*/
|
|
99
|
+
function getValidator(source) {
|
|
100
|
+
if (!source?.schema) return null;
|
|
101
|
+
const cached = VALIDATORS.get(source);
|
|
102
|
+
if (cached) return cached;
|
|
103
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
104
|
+
addFormats(ajv);
|
|
105
|
+
const validate = ajv.compile(source.schema);
|
|
106
|
+
VALIDATORS.set(source, validate);
|
|
107
|
+
return validate;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let _unvalidatedWarned = false;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Announce — once per process — that an envelope is going out unvalidated.
|
|
114
|
+
*
|
|
115
|
+
* Written straight to stderr rather than through `Logger.warn` for the same
|
|
116
|
+
* reason `emitTerminalEnvelope` bypasses `Logger.info`: the envelope itself is
|
|
117
|
+
* unsuppressible, so the notice that one was not checked has to be too. Under
|
|
118
|
+
* `AGENT_LOG_LEVEL=silent` a level-gated warning would vanish and the degrade
|
|
119
|
+
* would be invisible.
|
|
120
|
+
*
|
|
121
|
+
* @param {string|null|undefined} error
|
|
122
|
+
* @returns {void}
|
|
123
|
+
*/
|
|
124
|
+
function warnUnvalidated(error) {
|
|
125
|
+
if (_unvalidatedWarned) return;
|
|
126
|
+
_unvalidatedWarned = true;
|
|
127
|
+
process.stderr.write(
|
|
128
|
+
`[story-deliver-terminal] ⚠️ terminal-envelope schema unavailable (${error ?? 'unknown'}) — ` +
|
|
129
|
+
`emitting the envelope UNVALIDATED. The return contract is preserved; its shape is not checked. ` +
|
|
130
|
+
`Expected at: ${SCHEMA_PATH}\n`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validate a candidate envelope against the shipped schema.
|
|
136
|
+
*
|
|
137
|
+
* When the schema is unavailable the result reports `validated: false` and
|
|
138
|
+
* `valid: true` — a **deliberate degrade**, not an oversight. Losing the shape
|
|
139
|
+
* check costs a guard against a malformed envelope; throwing here costs the
|
|
140
|
+
* envelope entirely, and the envelope is the documented return contract of the
|
|
141
|
+
* delivery engine. An unvalidated terminal a caller can act on beats no
|
|
142
|
+
* terminal at all, so the unreadable-schema case degrades and says so on
|
|
143
|
+
* stderr while a schema *violation* still fails loudly at the writer.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} envelope
|
|
146
|
+
* @param {{ schemaSource?: { schema: object|null, error: string|null } }} [opts]
|
|
147
|
+
* `schemaSource` is a test seam — production always uses the eagerly loaded
|
|
148
|
+
* module-level source.
|
|
149
|
+
* @returns {{ valid: boolean, errors: string[], validated: boolean }}
|
|
150
|
+
*/
|
|
151
|
+
export function validateTerminalEnvelope(
|
|
152
|
+
envelope,
|
|
153
|
+
{ schemaSource = SCHEMA_SOURCE } = {},
|
|
154
|
+
) {
|
|
155
|
+
const validate = getValidator(schemaSource);
|
|
156
|
+
if (!validate) {
|
|
157
|
+
warnUnvalidated(schemaSource?.error);
|
|
158
|
+
return { valid: true, errors: [], validated: false };
|
|
159
|
+
}
|
|
160
|
+
const valid = validate(envelope);
|
|
161
|
+
if (valid) return { valid: true, errors: [], validated: true };
|
|
162
|
+
const errors = (validate.errors ?? []).map(
|
|
163
|
+
(e) => `${e.instancePath || '/'} ${e.message}`,
|
|
164
|
+
);
|
|
165
|
+
return { valid: false, errors, validated: true };
|
|
166
|
+
}
|
|
@@ -28,22 +28,12 @@
|
|
|
28
28
|
* pre-#4543 pipeline collapsed by treating budget exhaustion as a block.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import {
|
|
32
|
-
import path from 'node:path';
|
|
33
|
-
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { validateTerminalEnvelope } from './story-deliver-terminal-schema.js';
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const SCHEMA_PATH = path.resolve(
|
|
40
|
-
__dirname,
|
|
41
|
-
'..',
|
|
42
|
-
'..',
|
|
43
|
-
'..',
|
|
44
|
-
'schemas',
|
|
45
|
-
'story-deliver-terminal.schema.json',
|
|
46
|
-
);
|
|
33
|
+
// Re-exported so the schema split stays an implementation detail: every
|
|
34
|
+
// consumer still reaches the validator through the envelope module that owns
|
|
35
|
+
// the contract.
|
|
36
|
+
export { validateTerminalEnvelope };
|
|
47
37
|
|
|
48
38
|
export const TERMINAL_ENVELOPE_KIND = 'story-deliver-terminal';
|
|
49
39
|
|
|
@@ -152,39 +142,6 @@ export const NEXT_COMMANDS = Object.freeze({
|
|
|
152
142
|
escalateToPlan: (prompt) => `/plan "${quoteForPlan(prompt)}"`,
|
|
153
143
|
});
|
|
154
144
|
|
|
155
|
-
/** @type {Function|null} */
|
|
156
|
-
let _validator = null;
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Compile (once) and return the terminal-envelope validator.
|
|
160
|
-
*
|
|
161
|
-
* @returns {Function}
|
|
162
|
-
*/
|
|
163
|
-
function getValidator() {
|
|
164
|
-
if (_validator) return _validator;
|
|
165
|
-
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
|
|
166
|
-
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
167
|
-
addFormats(ajv);
|
|
168
|
-
_validator = ajv.compile(schema);
|
|
169
|
-
return _validator;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Validate a candidate envelope against the shipped schema.
|
|
174
|
-
*
|
|
175
|
-
* @param {object} envelope
|
|
176
|
-
* @returns {{ valid: boolean, errors: string[] }}
|
|
177
|
-
*/
|
|
178
|
-
export function validateTerminalEnvelope(envelope) {
|
|
179
|
-
const validate = getValidator();
|
|
180
|
-
const valid = validate(envelope);
|
|
181
|
-
if (valid) return { valid: true, errors: [] };
|
|
182
|
-
const errors = (validate.errors ?? []).map(
|
|
183
|
-
(e) => `${e.instancePath || '/'} ${e.message}`,
|
|
184
|
-
);
|
|
185
|
-
return { valid: false, errors };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
145
|
/**
|
|
189
146
|
* Drop `undefined`-valued keys so the schema's `additionalProperties: false`
|
|
190
147
|
* and its nullable unions both stay satisfiable from one optional-argument
|
|
@@ -207,9 +164,14 @@ function compact(obj) {
|
|
|
207
164
|
* Throws a `TypeError` naming the schema violations when the assembled
|
|
208
165
|
* object does not validate. That is deliberate: the whole point of the
|
|
209
166
|
* envelope is that a caller can trust its status without re-probing
|
|
210
|
-
* GitHub, so emitting
|
|
167
|
+
* GitHub, so emitting a *malformed* one would reintroduce the ambiguity
|
|
211
168
|
* this replaces.
|
|
212
169
|
*
|
|
170
|
+
* A schema that cannot be READ is the opposite case and does not throw —
|
|
171
|
+
* see {@link validateTerminalEnvelope}. "This envelope is wrong" is worth
|
|
172
|
+
* failing on; "I could not check this envelope" is not worth destroying
|
|
173
|
+
* the return contract over.
|
|
174
|
+
*
|
|
213
175
|
* @param {object} args
|
|
214
176
|
* @param {number|null} args.storyId `null` only for an `escalated` terminal,
|
|
215
177
|
* which by construction never authored a Story.
|
|
@@ -227,6 +189,9 @@ function compact(obj) {
|
|
|
227
189
|
* @param {number} args.elapsedSeconds
|
|
228
190
|
* @param {object|null} [args.waitBudget]
|
|
229
191
|
* @param {string} [args.timestamp]
|
|
192
|
+
* @param {{ schema: object|null, error: string|null }} [args.schemaSource]
|
|
193
|
+
* Test seam; never passed in production. Not part of the envelope — the
|
|
194
|
+
* envelope is assembled from named fields only.
|
|
230
195
|
* @returns {object} The validated envelope.
|
|
231
196
|
*/
|
|
232
197
|
export function buildTerminalEnvelope({
|
|
@@ -245,6 +210,7 @@ export function buildTerminalEnvelope({
|
|
|
245
210
|
elapsedSeconds = 0,
|
|
246
211
|
waitBudget,
|
|
247
212
|
timestamp = new Date().toISOString(),
|
|
213
|
+
schemaSource,
|
|
248
214
|
}) {
|
|
249
215
|
const envelope = compact({
|
|
250
216
|
kind: TERMINAL_ENVELOPE_KIND,
|
|
@@ -268,7 +234,12 @@ export function buildTerminalEnvelope({
|
|
|
268
234
|
timestamp,
|
|
269
235
|
});
|
|
270
236
|
|
|
271
|
-
|
|
237
|
+
// Passing `{ schemaSource }` unconditionally is safe: an `undefined`
|
|
238
|
+
// property value is exactly what triggers the destructuring default on the
|
|
239
|
+
// other side, so production still gets the eagerly-loaded module source.
|
|
240
|
+
const { valid, errors } = validateTerminalEnvelope(envelope, {
|
|
241
|
+
schemaSource,
|
|
242
|
+
});
|
|
272
243
|
if (!valid) {
|
|
273
244
|
throw new TypeError(
|
|
274
245
|
`buildTerminalEnvelope: assembled envelope violates story-deliver-terminal.schema.json:\n` +
|
|
@@ -449,15 +449,29 @@ function computeMissingBddScaffoldFindings(stories, reach, severity) {
|
|
|
449
449
|
* Tasks satisfy (a) or (b). When ≥2 such Stories sit in the same wave (no
|
|
450
450
|
* transitive `depends_on` between them), emit a single finding keyed by the
|
|
451
451
|
* registry path.
|
|
452
|
+
*
|
|
453
|
+
* Reached from the module's `_internal` named export. The pass is pure — it
|
|
454
|
+
* performs no filesystem I/O and spawns no process — so its optional final
|
|
455
|
+
* `deps` parameter seams the three collaborating predicates rather than a
|
|
456
|
+
* built-in; each entry defaults to the real implementation
|
|
457
|
+
* (`.agents/rules/test-seams.md` rules 1-3: the defaults live on the function,
|
|
458
|
+
* never on a module-level mutable variable).
|
|
459
|
+
*
|
|
460
|
+
* @param {object} input
|
|
461
|
+
* @param {{
|
|
462
|
+
* isRegistryPathImpl?: typeof isRegistryPath,
|
|
463
|
+
* inSameWaveImpl?: typeof inSameWave,
|
|
464
|
+
* registryRegistryImpl?: typeof registryRegistry,
|
|
465
|
+
* }} [deps]
|
|
452
466
|
*/
|
|
453
|
-
function computeRegistryFindings(
|
|
454
|
-
stories,
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
467
|
+
function computeRegistryFindings(
|
|
468
|
+
{ stories, reach, patterns, producers, assumptionEntries, severity },
|
|
469
|
+
{
|
|
470
|
+
isRegistryPathImpl = isRegistryPath,
|
|
471
|
+
inSameWaveImpl = inSameWave,
|
|
472
|
+
registryRegistryImpl = registryRegistry,
|
|
473
|
+
} = {},
|
|
474
|
+
) {
|
|
461
475
|
const findings = [];
|
|
462
476
|
// Build the matching registry path set from producer & creator paths.
|
|
463
477
|
const registryHits = new Map(); // registryPath -> Map<storySlug, producers[]>
|
|
@@ -474,7 +488,7 @@ function computeRegistryFindings({
|
|
|
474
488
|
// (a) direct registry edits — object-form `{ path, assumption }` entries
|
|
475
489
|
// from `indexAssumptionEntries` (and the producer index built from them).
|
|
476
490
|
for (const [path, entries] of producers.entries()) {
|
|
477
|
-
if (!
|
|
491
|
+
if (!isRegistryPathImpl(path, patterns)) continue;
|
|
478
492
|
for (const e of entries) {
|
|
479
493
|
bump(path, {
|
|
480
494
|
storySlug: e.storySlug,
|
|
@@ -485,7 +499,7 @@ function computeRegistryFindings({
|
|
|
485
499
|
}
|
|
486
500
|
}
|
|
487
501
|
for (const e of assumptionEntries) {
|
|
488
|
-
if (!
|
|
502
|
+
if (!isRegistryPathImpl(e.path, patterns)) continue;
|
|
489
503
|
bump(e.path, {
|
|
490
504
|
storySlug: e.storySlug,
|
|
491
505
|
taskSlug: e.taskSlug,
|
|
@@ -510,7 +524,7 @@ function computeRegistryFindings({
|
|
|
510
524
|
continue;
|
|
511
525
|
const childParent = parentDirOf(change.path);
|
|
512
526
|
if (!childParent) continue;
|
|
513
|
-
for (const reg of
|
|
527
|
+
for (const reg of registryRegistryImpl(
|
|
514
528
|
producers,
|
|
515
529
|
assumptionEntries,
|
|
516
530
|
patterns,
|
|
@@ -532,7 +546,7 @@ function computeRegistryFindings({
|
|
|
532
546
|
const cluster = new Set();
|
|
533
547
|
for (let i = 0; i < stories.length; i += 1) {
|
|
534
548
|
for (let j = i + 1; j < stories.length; j += 1) {
|
|
535
|
-
if (
|
|
549
|
+
if (inSameWaveImpl(reach, stories[i], stories[j])) {
|
|
536
550
|
cluster.add(stories[i]);
|
|
537
551
|
cluster.add(stories[j]);
|
|
538
552
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// .agents/scripts/lib/stdio-flush.js
|
|
2
|
+
/**
|
|
3
|
+
* Stdio drain helper (Story #4783).
|
|
4
|
+
*
|
|
5
|
+
* `process.stdout` / `process.stderr` are only synchronous when they point at
|
|
6
|
+
* a TTY or a regular file. On a **pipe** — every `cmd | consumer`, every
|
|
7
|
+
* `child_process` capture, every `$(...)` substitution — Node writes
|
|
8
|
+
* asynchronously once the 64 KiB kernel pipe buffer fills: `write()` returns
|
|
9
|
+
* `false` and the remaining bytes sit in the stream's internal queue until the
|
|
10
|
+
* reader drains it.
|
|
11
|
+
*
|
|
12
|
+
* `process.exit()` does not wait for that queue. Anything still buffered when
|
|
13
|
+
* the process terminates is discarded, so a CLI that emits more than a pipe
|
|
14
|
+
* buffer's worth of output and then exits eagerly silently truncates — the
|
|
15
|
+
* exit code still reads green, and the consumer parses a half-written
|
|
16
|
+
* envelope. `runAsCli` is the shared boundary where that used to happen for
|
|
17
|
+
* every `.agents/scripts` entry point.
|
|
18
|
+
*
|
|
19
|
+
* The fix is to stop exiting eagerly (set `process.exitCode` and let the loop
|
|
20
|
+
* drain naturally). This helper is the belt-and-braces half: an explicit await
|
|
21
|
+
* on the queued bytes, so the flush is complete before the CLI's last frame
|
|
22
|
+
* unwinds even when something further out terminates the process.
|
|
23
|
+
*
|
|
24
|
+
* @module stdio-flush
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Await the point at which one writable stream's queued bytes have been handed
|
|
29
|
+
* to the OS. Resolves immediately when the stream has nothing queued, is not
|
|
30
|
+
* writable, or is not a stream at all — a flush must never be the reason a
|
|
31
|
+
* process hangs.
|
|
32
|
+
*
|
|
33
|
+
* Both settle paths are covered: the zero-length `write()` callback (which
|
|
34
|
+
* fires after every previously queued chunk, since writes are ordered) and the
|
|
35
|
+
* `'drain'` event (which fires when a backed-up stream empties). Whichever
|
|
36
|
+
* lands first resolves; the other is detached.
|
|
37
|
+
*
|
|
38
|
+
* @param {NodeJS.WritableStream & { writableLength?: number, writableEnded?: boolean, destroyed?: boolean }} [stream]
|
|
39
|
+
* @returns {Promise<void>}
|
|
40
|
+
*/
|
|
41
|
+
function drainStream(stream) {
|
|
42
|
+
if (!stream || typeof stream.write !== 'function') return Promise.resolve();
|
|
43
|
+
if (stream.destroyed || stream.writableEnded) return Promise.resolve();
|
|
44
|
+
if ((stream.writableLength ?? 0) === 0) return Promise.resolve();
|
|
45
|
+
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let settled = false;
|
|
48
|
+
const done = () => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
stream.removeListener?.('drain', done);
|
|
52
|
+
resolve();
|
|
53
|
+
};
|
|
54
|
+
stream.once?.('drain', done);
|
|
55
|
+
// The write callback fires once this (empty) chunk — and therefore every
|
|
56
|
+
// chunk queued ahead of it — has been flushed to the underlying handle.
|
|
57
|
+
stream.write('', done);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Await the drain of the process's stdio streams. Never rejects: a stream that
|
|
63
|
+
* errored, closed, or was never writable resolves as already-flushed.
|
|
64
|
+
*
|
|
65
|
+
* @param {Array<NodeJS.WritableStream|undefined>} [streams] Defaults to
|
|
66
|
+
* `[process.stdout, process.stderr]`.
|
|
67
|
+
* @returns {Promise<void>}
|
|
68
|
+
*/
|
|
69
|
+
export async function flushStdio(streams = [process.stdout, process.stderr]) {
|
|
70
|
+
await Promise.all(streams.map((stream) => drainStream(stream)));
|
|
71
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createRequire, SourceMap } from 'node:module';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { Logger } from './Logger.js';
|
|
4
5
|
|
|
@@ -39,6 +40,71 @@ function isTypeScriptPath(filePath) {
|
|
|
39
40
|
return TS_EXTS.has(path.extname(String(filePath)).toLowerCase());
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Trailing `//# sourceMappingURL=…` comment `ts.transpileModule` appends
|
|
45
|
+
* when `sourceMap: true` is requested. Stripping it makes the emitted code
|
|
46
|
+
* byte-identical to the `sourceMap: false` emit, which is what keeps the
|
|
47
|
+
* maintainability path — and every MI score in the committed baseline —
|
|
48
|
+
* untouched by the CRAP path opting into a map.
|
|
49
|
+
*/
|
|
50
|
+
const SOURCE_MAPPING_URL_RE = /\n?\/\/# sourceMappingURL=[^\n]*\n?$/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a `transpiledLine → originalLine` resolver over a raw
|
|
54
|
+
* `sourceMapText` payload, using Node's built-in `SourceMap` (no new
|
|
55
|
+
* runtime dependency).
|
|
56
|
+
*
|
|
57
|
+
* `SourceMap#findEntry(line, column)` is 0-based and returns the mapping at
|
|
58
|
+
* or before the requested position, so a bare `findEntry(line, 0)` can
|
|
59
|
+
* silently answer with a *previous* line's mapping when the requested line
|
|
60
|
+
* carries no mapping at column 0. The resolver therefore walks the columns
|
|
61
|
+
* of the generated line and accepts only an entry that actually originates
|
|
62
|
+
* on that generated line; a line with no mapping at all resolves to `null`
|
|
63
|
+
* and the caller falls back to the un-remapped coordinate.
|
|
64
|
+
*
|
|
65
|
+
* Results are memoised per generated line — a file's methods are looked up
|
|
66
|
+
* once each, but the same line is often probed by several callers.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} sourceMapText Raw JSON source map emitted by TypeScript.
|
|
69
|
+
* @param {string} code The generated (transpiled) code the map describes.
|
|
70
|
+
* @returns {((line: number) => number|null)|null}
|
|
71
|
+
*/
|
|
72
|
+
function buildLineMapper(sourceMapText, code) {
|
|
73
|
+
let sourceMap;
|
|
74
|
+
try {
|
|
75
|
+
sourceMap = new SourceMap(JSON.parse(sourceMapText));
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const lines = String(code).split('\n');
|
|
80
|
+
const memo = new Map();
|
|
81
|
+
return function mapLine(generatedLine) {
|
|
82
|
+
if (typeof generatedLine !== 'number' || generatedLine < 1) return null;
|
|
83
|
+
if (memo.has(generatedLine)) return memo.get(generatedLine);
|
|
84
|
+
const zeroBased = generatedLine - 1;
|
|
85
|
+
const lineText = lines[zeroBased] ?? '';
|
|
86
|
+
let resolved = null;
|
|
87
|
+
for (let column = 0; column <= lineText.length; column += 1) {
|
|
88
|
+
let entry;
|
|
89
|
+
try {
|
|
90
|
+
entry = sourceMap.findEntry(zeroBased, column);
|
|
91
|
+
} catch {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
entry &&
|
|
96
|
+
entry.generatedLine === zeroBased &&
|
|
97
|
+
typeof entry.originalLine === 'number'
|
|
98
|
+
) {
|
|
99
|
+
resolved = entry.originalLine + 1;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
memo.set(generatedLine, resolved);
|
|
104
|
+
return resolved;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
42
108
|
/**
|
|
43
109
|
* Pre-transpile TypeScript or TSX sources to JavaScript that the
|
|
44
110
|
* Esprima-based escomplex kernel can parse. Returns the input unchanged
|
|
@@ -54,12 +120,33 @@ function isTypeScriptPath(filePath) {
|
|
|
54
120
|
* On transpile failure the helper returns `null` — callers treat that
|
|
55
121
|
* as "skip this file" rather than crashing the scan.
|
|
56
122
|
*
|
|
123
|
+
* **Line coordinates (Story #4775).** The transpile does not preserve line
|
|
124
|
+
* numbers: interface elision and the injected JSX-runtime import shift the
|
|
125
|
+
* emitted code relative to the original source. escomplex then reports each
|
|
126
|
+
* method's `lineStart` in *transpiled* coordinates while istanbul's `fnMap`
|
|
127
|
+
* is in *original source* coordinates — so a per-method coverage join keyed
|
|
128
|
+
* on the raw `lineStart` cannot match. Callers that need to join against
|
|
129
|
+
* coverage pass `{ withLineMap: true }` and receive
|
|
130
|
+
* `{ code, mapLine }`, where `mapLine(transpiledLine)` returns the original
|
|
131
|
+
* source line (or `null` when the line has no mapping).
|
|
132
|
+
*
|
|
133
|
+
* `withLineMap` is opt-in precisely so the maintainability path — which is
|
|
134
|
+
* module-level and never joins coverage — keeps paying nothing for a map it
|
|
135
|
+
* would not read, and keeps emitting byte-identical scores. A JavaScript
|
|
136
|
+
* input is a passthrough in both modes: `mapLine` is `null` because the
|
|
137
|
+
* coordinates already *are* original-source coordinates, so no remap is
|
|
138
|
+
* needed and none is computed.
|
|
139
|
+
*
|
|
57
140
|
* @param {string} filePath
|
|
58
141
|
* @param {string} source
|
|
59
|
-
* @
|
|
142
|
+
* @param {{withLineMap?: boolean}} [opts]
|
|
143
|
+
* @returns {string|null|{code: string, mapLine: ((line: number) => number|null)|null}}
|
|
60
144
|
*/
|
|
61
|
-
export function transpileIfNeeded(filePath, source) {
|
|
62
|
-
|
|
145
|
+
export function transpileIfNeeded(filePath, source, opts = {}) {
|
|
146
|
+
const withLineMap = opts?.withLineMap === true;
|
|
147
|
+
if (!isTypeScriptPath(filePath)) {
|
|
148
|
+
return withLineMap ? { code: source, mapLine: null } : source;
|
|
149
|
+
}
|
|
63
150
|
const ts = loadTypeScript();
|
|
64
151
|
if (!ts) {
|
|
65
152
|
Logger.warn(
|
|
@@ -78,12 +165,18 @@ export function transpileIfNeeded(filePath, source) {
|
|
|
78
165
|
importHelpers: false,
|
|
79
166
|
removeComments: false,
|
|
80
167
|
jsx: ts.JsxEmit.ReactJSX,
|
|
81
|
-
sourceMap:
|
|
168
|
+
sourceMap: withLineMap,
|
|
82
169
|
},
|
|
83
170
|
fileName: path.basename(filePath),
|
|
84
171
|
reportDiagnostics: false,
|
|
85
172
|
});
|
|
86
|
-
return result.outputText;
|
|
173
|
+
if (!withLineMap) return result.outputText;
|
|
174
|
+
const code = result.outputText.replace(SOURCE_MAPPING_URL_RE, '\n');
|
|
175
|
+
const mapLine =
|
|
176
|
+
typeof result.sourceMapText === 'string'
|
|
177
|
+
? buildLineMapper(result.sourceMapText, code)
|
|
178
|
+
: null;
|
|
179
|
+
return { code, mapLine };
|
|
87
180
|
} catch (err) {
|
|
88
181
|
Logger.warn(
|
|
89
182
|
`[Maintainability] ⚠ TS transpile failed for ${filePath}: ${err?.message ?? err}; skipping.`,
|
|
@@ -91,3 +184,37 @@ export function transpileIfNeeded(filePath, source) {
|
|
|
91
184
|
return null;
|
|
92
185
|
}
|
|
93
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Prepare a source file for scoring: read it, transpile TS/TSX with a
|
|
190
|
+
* line map, and hand back the JavaScript escomplex will parse alongside the
|
|
191
|
+
* transpiled → original-source line resolver the coverage join needs
|
|
192
|
+
* (Story #4775).
|
|
193
|
+
*
|
|
194
|
+
* Failure is reported as `{error: 'read'}` or `{error: 'transpile'}` rather
|
|
195
|
+
* than a bare null: CRAP drops the file either way, but the combined MI path
|
|
196
|
+
* distinguishes them (a read failure drops the MI score, a transpile failure
|
|
197
|
+
* scores it 0 — matching `calculateForFile`).
|
|
198
|
+
*
|
|
199
|
+
* @param {string} abs Absolute path of the source file.
|
|
200
|
+
* @param {{readFile?: (p: string) => string, transpile?: Function}} [deps]
|
|
201
|
+
* @returns {{code: string, mapLine: ((line: number) => number|null)|null}
|
|
202
|
+
* | {error: 'read'|'transpile'}}
|
|
203
|
+
*/
|
|
204
|
+
export function prepareSourceForScoring(abs, deps = {}) {
|
|
205
|
+
const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
|
|
206
|
+
const transpile = deps.transpile ?? transpileIfNeeded;
|
|
207
|
+
let source;
|
|
208
|
+
try {
|
|
209
|
+
source = readFile(abs);
|
|
210
|
+
} catch {
|
|
211
|
+
return { error: 'read' };
|
|
212
|
+
}
|
|
213
|
+
const prepared = transpile(abs, source, { withLineMap: true });
|
|
214
|
+
if (prepared === null || prepared === undefined)
|
|
215
|
+
return { error: 'transpile' };
|
|
216
|
+
// Tolerate a `deps.transpile` stub that still returns a bare string.
|
|
217
|
+
if (typeof prepared === 'string') return { code: prepared, mapLine: null };
|
|
218
|
+
if (typeof prepared.code !== 'string') return { error: 'transpile' };
|
|
219
|
+
return { code: prepared.code, mapLine: prepared.mapLine ?? null };
|
|
220
|
+
}
|