mandrel 2.53.0 → 2.55.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.
Files changed (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +40 -16
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +8 -1
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +34 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -0,0 +1,512 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Test-portability guard (Story #5284).
5
+ *
6
+ * Windows-only breaks reached `main` three times in one week through two
7
+ * source shapes a static reader can see, because the `windows-smoke` job is
8
+ * advisory: it is not a required check, so auto-merge lands a PR whose only
9
+ * red is that leg. Making the leg required is an operator ruleset decision;
10
+ * catching the shapes at authoring time is not, and that is what this guard
11
+ * does.
12
+ *
13
+ * Three shapes, all invisible on POSIX and all fatal on Windows:
14
+ *
15
+ * 1. **A `RegExp` built from an interpolated path.** `new RegExp(`source:
16
+ * ${src}`)` is accidentally literal on POSIX — a temp path carries no
17
+ * regex metacharacters. On Windows every separator is a backslash, so
18
+ * `C:\Users\…` compiles to a pattern matching `C:Users…` and the
19
+ * assertion can never match (PR #5276 fixed three of these).
20
+ *
21
+ * 2. **A dynamic `import()` of a plain filesystem path.** An absolute
22
+ * Windows path is not a valid ESM specifier: the drive letter reads as
23
+ * a URL scheme. The portable form is `pathToFileURL(p).href`, which is
24
+ * why the suite's own dynamic imports launder through it.
25
+ *
26
+ * 3. **A file URL's `.pathname` read as a filesystem path.** `new
27
+ * URL(import.meta.url).pathname` yields `/D:/a/repo` on Windows, and the
28
+ * leading slash survives `path.resolve` as a second drive letter, so the
29
+ * test dies on `D:\D:\a\repo\package.json`. `fileURLToPath` is the one
30
+ * spelling that round-trips on both platforms. This shape reached `main`
31
+ * the same day this guard did, through a test the guard did not yet read.
32
+ *
33
+ * ## What is deliberately NOT flagged
34
+ *
35
+ * Interpolating into a `RegExp` is not itself the defect — the suite does it
36
+ * constantly with heading text, flag names and label ids, and flagging those
37
+ * would make the guard noise. Only an interpolation whose *expression text*
38
+ * names a filesystem path counts (see {@link PATH_EXPRESSION}).
39
+ *
40
+ * The import rule is the same shape, and for the same reason. It fires on a
41
+ * path expression reaching `import()` unlaundered — not on "anything that is
42
+ * not a relative specifier". The suite's dynamic imports are overwhelmingly
43
+ * `import(SUT_URL)` and `` import(`${SUT_URL}?t=${tag}`) ``, where the URL was
44
+ * laundered once at module scope and the interpolation is a cache-busting
45
+ * query; those carry no path expression and are silent here, while
46
+ * `import(path.join(LIB, 'x.js'))` is exactly what reds.
47
+ *
48
+ * An interpolation that escapes itself for a regex — `.replace(/\\/g, …)`,
49
+ * `escapeRegExp(…)` — is laundered and not reported either.
50
+ *
51
+ * A line (or the line above it) carrying `portability-allow` opts out.
52
+ *
53
+ * Scope is `tests/**` plus `.agents/scripts/ ** /__tests__/**` by default.
54
+ * Unlike `check-test-temp-hygiene.js`'s raw-tmpdir lint this is not scoped to
55
+ * caller-passed globs: it runs over this repository's own tree in CI and
56
+ * ships in the payload for a consumer to wire up the same way.
57
+ *
58
+ * Exit codes: 1 when any finding is reported, 0 otherwise.
59
+ */
60
+
61
+ import fs from 'node:fs';
62
+ import path from 'node:path';
63
+ import { fileURLToPath } from 'node:url';
64
+ import { runAsCli } from './lib/cli-utils.js';
65
+
66
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
67
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
68
+
69
+ /** Directories that are never test source, skipped wholesale during the walk. */
70
+ const SKIP_DIRS = new Set([
71
+ 'node_modules',
72
+ '.worktrees',
73
+ '.git',
74
+ 'temp',
75
+ 'coverage',
76
+ ]);
77
+
78
+ /** Repo-relative directory prefixes scanned for test sources. */
79
+ const SCAN_ROOTS = ['tests', '.agents/scripts'];
80
+
81
+ /** Opt-out marker, honoured on the finding's line or the line above it. */
82
+ const LINT_ESCAPE = 'portability-allow';
83
+
84
+ /**
85
+ * Expression text that names a filesystem path.
86
+ *
87
+ * Two families, kept separate because they fail differently. The call family
88
+ * is unambiguous — nothing but a path comes out of `path.join()`. The name
89
+ * family is a heuristic over identifiers, so it is anchored to the *final*
90
+ * segment of a member expression and deliberately excludes `…Path`: the
91
+ * suite uses that suffix for non-filesystem discriminators too (an
92
+ * `evidencePath` of `'per-run'`), and a guard that reds on those would be
93
+ * turned off rather than obeyed. `…Dir` / `…Root` / `src` / `dest` carry no
94
+ * such second meaning here.
95
+ */
96
+ const PATH_EXPRESSION =
97
+ /(?:\b(?:path\.(?:join|resolve|dirname|relative|normalize)|os\.tmpdir|process\.cwd|fileURLToPath|makeTempDir|mkdtemp(?:Sync)?)\s*\(|\b__(?:dirname|filename)\b|(?:^|[^\w$.])(?:src|dst|dest|dir|cwd|tmpdir|workCwd|repoRoot)\b|[\w$]+(?:Dir|Root|Cwd|Tmpdir)\b)/;
98
+
99
+ /**
100
+ * An argument already laundered into a URL, in any of the three shapes the
101
+ * suite uses.
102
+ */
103
+ const URL_LAUNDERED =
104
+ /\bpathToFileURL\s*\(|\bnew\s+URL\s*\(|\bimport\.meta\.url\b/;
105
+
106
+ /**
107
+ * An interpolation that escapes itself before reaching the pattern. The
108
+ * separator problem is solved once the backslashes are doubled, so escaping a
109
+ * path in is a correct use, not the defect.
110
+ */
111
+ const REGEX_ESCAPED =
112
+ /\.replace(?:All)?\s*\(|\bescapeReg(?:Exp|ex)\b|\bregexEscape\b/;
113
+
114
+ /**
115
+ * A **file** URL's `.pathname` read as though it were a filesystem path.
116
+ *
117
+ * Scoped to URLs built from a file source (`import.meta.url`, `pathToFileURL`,
118
+ * a `file://` literal) because `.pathname` on an http URL is a correct read
119
+ * with no filesystem meaning. On Windows the property yields `/D:/a/repo`,
120
+ * whose leading slash survives `path.resolve` as a second drive letter —
121
+ * `D:\D:\a\repo` — so the failure is an ENOENT naming an impossible path
122
+ * rather than anything that points at the real defect.
123
+ */
124
+ const FILE_URL_PATHNAME =
125
+ /\bnew\s+URL\s*\([^()]*(?:import\.meta\.url|pathToFileURL|file:\/\/)[^()]*\)\s*\.pathname/g;
126
+
127
+ /**
128
+ * Blank out comments while preserving every byte offset and newline, so a
129
+ * finding's line number survives and the scan cannot be satisfied — or
130
+ * tripped — by prose.
131
+ *
132
+ * String and template literals are left intact on purpose: the suite builds
133
+ * child-process source inside template literals, and the `await import(...)`
134
+ * in one of those is real code that runs on Windows like any other.
135
+ *
136
+ * Regex literals are not tracked. A `//` inside one would be read as a
137
+ * comment start; the shapes this guard matches do not occur inside regex
138
+ * literals, so the trade is a simpler scanner for no measured loss.
139
+ *
140
+ * @param {string} src
141
+ * @returns {string} `src` with comment bodies replaced by spaces
142
+ */
143
+ function blankComments(src) {
144
+ const out = src.split('');
145
+ let i = 0;
146
+ const blank = (from, to) => {
147
+ for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' ';
148
+ };
149
+ while (i < src.length) {
150
+ const ch = src[i];
151
+ if (ch === '\\') {
152
+ i += 2;
153
+ continue;
154
+ }
155
+ if (ch === "'" || ch === '"' || ch === '`') {
156
+ i = skipStringLiteral(src, i);
157
+ continue;
158
+ }
159
+ if (ch === '/' && src[i + 1] === '/') {
160
+ const end = src.indexOf('\n', i);
161
+ const stop = end === -1 ? src.length : end;
162
+ blank(i, stop);
163
+ i = stop;
164
+ continue;
165
+ }
166
+ if (ch === '/' && src[i + 1] === '*') {
167
+ const end = src.indexOf('*/', i + 2);
168
+ const stop = end === -1 ? src.length : end + 2;
169
+ blank(i, stop);
170
+ i = stop;
171
+ continue;
172
+ }
173
+ i += 1;
174
+ }
175
+ return out.join('');
176
+ }
177
+
178
+ /**
179
+ * Index just past the string literal opening at `start`.
180
+ *
181
+ * @param {string} src
182
+ * @param {number} start index of the opening quote
183
+ * @returns {number}
184
+ */
185
+ function skipStringLiteral(src, start) {
186
+ const quote = src[start];
187
+ let i = start + 1;
188
+ while (i < src.length) {
189
+ const ch = src[i];
190
+ if (ch === '\\') {
191
+ i += 2;
192
+ continue;
193
+ }
194
+ if (ch === quote) return i + 1;
195
+ if (quote !== '`' && ch === '\n') return i;
196
+ i += 1;
197
+ }
198
+ return i;
199
+ }
200
+
201
+ /**
202
+ * Blank the contents of quoted string literals in `text`, leaving template
203
+ * literals alone.
204
+ *
205
+ * A specifier's own characters are not a path expression: without this,
206
+ * `import('../lib/auto-merge-cwd.js')` matches the `cwd` name signal on a
207
+ * filename. Template literals are left intact because their `${…}` bodies are
208
+ * exactly what both rules need to read.
209
+ *
210
+ * @param {string} text
211
+ * @returns {string}
212
+ */
213
+ function blankStrings(text) {
214
+ const out = text.split('');
215
+ let i = 0;
216
+ while (i < text.length) {
217
+ const ch = text[i];
218
+ if (ch === '\\') {
219
+ i += 2;
220
+ continue;
221
+ }
222
+ if (ch === '`') {
223
+ i = skipStringLiteral(text, i);
224
+ continue;
225
+ }
226
+ if (ch === "'" || ch === '"') {
227
+ const end = skipStringLiteral(text, i);
228
+ for (let k = i + 1; k < end - 1; k += 1) out[k] = ' ';
229
+ i = end;
230
+ continue;
231
+ }
232
+ i += 1;
233
+ }
234
+ return out.join('');
235
+ }
236
+
237
+ /**
238
+ * Capture the text between the parenthesis at `open` and its match.
239
+ *
240
+ * Balanced rather than line-based because both shapes span lines in practice
241
+ * — a `pathToFileURL(path.resolve(…)).href` argument runs to six.
242
+ *
243
+ * @param {string} src
244
+ * @param {number} open index of the opening `(`
245
+ * @returns {string|null} the argument text, or `null` when unbalanced
246
+ */
247
+ function captureArgs(src, open) {
248
+ let depth = 0;
249
+ let i = open;
250
+ while (i < src.length) {
251
+ const ch = src[i];
252
+ if (ch === "'" || ch === '"' || ch === '`') {
253
+ i = skipStringLiteral(src, i);
254
+ continue;
255
+ }
256
+ if (ch === '(') depth += 1;
257
+ else if (ch === ')') {
258
+ depth -= 1;
259
+ if (depth === 0) return src.slice(open + 1, i);
260
+ }
261
+ i += 1;
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /**
267
+ * Every `${…}` substitution body in `text`, at one level of nesting.
268
+ *
269
+ * @param {string} text
270
+ * @returns {string[]}
271
+ */
272
+ function substitutions(text) {
273
+ const found = [];
274
+ for (let i = 0; i < text.length - 1; i += 1) {
275
+ if (text[i] !== '$' || text[i + 1] !== '{') continue;
276
+ let depth = 0;
277
+ for (let k = i + 1; k < text.length; k += 1) {
278
+ if (text[k] === '{') depth += 1;
279
+ else if (text[k] === '}') {
280
+ depth -= 1;
281
+ if (depth === 0) {
282
+ found.push(text.slice(i + 2, k));
283
+ i = k;
284
+ break;
285
+ }
286
+ }
287
+ }
288
+ }
289
+ return found;
290
+ }
291
+
292
+ /**
293
+ * Line number (1-based) of `index` within `src`.
294
+ *
295
+ * @param {string} src
296
+ * @param {number} index
297
+ * @returns {number}
298
+ */
299
+ function lineOf(src, index) {
300
+ let line = 1;
301
+ for (let i = 0; i < index && i < src.length; i += 1) {
302
+ if (src[i] === '\n') line += 1;
303
+ }
304
+ return line;
305
+ }
306
+
307
+ /**
308
+ * Whether the finding at `line` (1-based) is opted out by a marker on that
309
+ * line or the one above it.
310
+ *
311
+ * @param {string[]} lines original source lines
312
+ * @param {number} line
313
+ * @returns {boolean}
314
+ */
315
+ function isSuppressed(lines, line) {
316
+ const here = lines[line - 1] ?? '';
317
+ const above = line >= 2 ? (lines[line - 2] ?? '') : '';
318
+ return here.includes(LINT_ESCAPE) || above.includes(LINT_ESCAPE);
319
+ }
320
+
321
+ /**
322
+ * Scan one file's source for both shapes.
323
+ *
324
+ * @param {string} rel repo-relative POSIX path, used in the report
325
+ * @param {string} source raw file contents
326
+ * @returns {{ file: string, line: number, shape: string, text: string }[]}
327
+ */
328
+ function scanSource(rel, source) {
329
+ const src = blankComments(source);
330
+ const lines = source.split('\n');
331
+ const findings = [];
332
+ const add = (index, shape, detail) => {
333
+ const line = lineOf(src, index);
334
+ if (isSuppressed(lines, line)) return;
335
+ findings.push({ file: rel, line, shape, text: detail });
336
+ };
337
+
338
+ for (const m of src.matchAll(/\bnew\s+RegExp\s*\(/g)) {
339
+ const open = m.index + m[0].length - 1;
340
+ const args = captureArgs(src, open);
341
+ if (args === null) continue;
342
+ const offender = substitutions(args).find(
343
+ (sub) =>
344
+ PATH_EXPRESSION.test(blankStrings(sub)) && !REGEX_ESCAPED.test(sub),
345
+ );
346
+ if (offender !== undefined) {
347
+ add(m.index, 'regexp-from-path', `\${${offender.trim()}}`);
348
+ }
349
+ }
350
+
351
+ for (const m of src.matchAll(FILE_URL_PATHNAME)) {
352
+ add(m.index, 'url-pathname-as-path', m[0].trim().replace(/\s+/g, ' '));
353
+ }
354
+
355
+ for (const m of src.matchAll(/(?<![\w$.])import\s*\(/g)) {
356
+ const open = m.index + m[0].length - 1;
357
+ const args = captureArgs(src, open);
358
+ if (args === null) continue;
359
+ if (PATH_EXPRESSION.test(blankStrings(args)) && !URL_LAUNDERED.test(args)) {
360
+ add(m.index, 'import-raw-path', args.trim().replace(/\s+/g, ' '));
361
+ }
362
+ }
363
+
364
+ return findings;
365
+ }
366
+
367
+ /**
368
+ * Is `rel` a test source this guard scans?
369
+ *
370
+ * @param {string} rel repo-relative POSIX path
371
+ * @returns {boolean}
372
+ */
373
+ function isTestSource(rel) {
374
+ if (!/\.(?:js|mjs|cjs)$/.test(rel)) return false;
375
+ if (rel.startsWith('tests/')) return true;
376
+ return rel.startsWith('.agents/scripts/') && rel.includes('/__tests__/');
377
+ }
378
+
379
+ /**
380
+ * Walk `root` for scannable test sources, POSIX-relative and sorted.
381
+ *
382
+ * @param {string} root
383
+ * @param {typeof fs} fsImpl
384
+ * @returns {string[]}
385
+ */
386
+ function listTestSources(root, fsImpl) {
387
+ const out = [];
388
+ const walk = (dir, prefix) => {
389
+ if (!fsImpl.existsSync(dir)) return;
390
+ for (const ent of fsImpl.readdirSync(dir, { withFileTypes: true })) {
391
+ if (SKIP_DIRS.has(ent.name)) continue;
392
+ const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
393
+ if (ent.isDirectory()) walk(path.join(dir, ent.name), rel);
394
+ else if (ent.isFile() && isTestSource(rel)) out.push(rel);
395
+ }
396
+ };
397
+ for (const scanRoot of SCAN_ROOTS) {
398
+ walk(path.join(root, scanRoot), scanRoot);
399
+ }
400
+ return out.sort();
401
+ }
402
+
403
+ /**
404
+ * Collect every finding under `root`.
405
+ *
406
+ * @param {string} root repository root to scan
407
+ * @param {{ fsImpl?: typeof fs }} [deps]
408
+ * @returns {{ file: string, line: number, shape: string, text: string }[]}
409
+ */
410
+ function findPortabilityIssues(root, { fsImpl = fs } = {}) {
411
+ const findings = [];
412
+ for (const rel of listTestSources(root, fsImpl)) {
413
+ const source = fsImpl.readFileSync(path.join(root, rel), 'utf8');
414
+ findings.push(...scanSource(rel, source));
415
+ }
416
+ return findings;
417
+ }
418
+
419
+ /** Remedy line printed under each shape, keyed by the shape's id. */
420
+ const REMEDIES = Object.freeze({
421
+ 'regexp-from-path':
422
+ 'a path interpolated into a RegExp is literal on POSIX and escaped on Windows — assert with `includes()`, or escape the interpolation.',
423
+ 'import-raw-path':
424
+ 'a filesystem path is not a valid ESM specifier on Windows — import `pathToFileURL(p).href`.',
425
+ 'url-pathname-as-path':
426
+ "a file URL's `.pathname` is a URL path, not a filesystem path: on Windows it reads `/D:/…`, and resolving it yields `D:\\D:\\…` — use `fileURLToPath(url)`.",
427
+ });
428
+
429
+ /**
430
+ * Parse argv into normalised options.
431
+ *
432
+ * @param {string[]} argv
433
+ * @returns {{ root: string, json: boolean }}
434
+ */
435
+ function parseArgv(argv) {
436
+ let root = REPO_ROOT;
437
+ let json = false;
438
+ for (let i = 0; i < argv.length; i += 1) {
439
+ if (argv[i] === '--json') json = true;
440
+ else if (argv[i] === '--root') {
441
+ i += 1;
442
+ root = path.resolve(String(argv[i] ?? '.'));
443
+ }
444
+ }
445
+ return { root, json };
446
+ }
447
+
448
+ /**
449
+ * Execute the guard and return the process exit code.
450
+ *
451
+ * @param {{ root: string, json: boolean }} opts
452
+ * @param {(line: string) => void} [log]
453
+ * @param {{ fsImpl?: typeof fs }} [deps]
454
+ * @returns {number}
455
+ */
456
+ function runPortability(
457
+ opts,
458
+ log = (l) => process.stdout.write(`${l}\n`),
459
+ deps,
460
+ ) {
461
+ const findings = findPortabilityIssues(opts.root, deps);
462
+ if (opts.json) {
463
+ log(JSON.stringify({ ok: findings.length === 0, findings }, null, 2));
464
+ return findings.length === 0 ? 0 : 1;
465
+ }
466
+ if (findings.length === 0) {
467
+ log(
468
+ '[test-portability] OK — no path-derived RegExp or raw-path dynamic import in test sources.',
469
+ );
470
+ return 0;
471
+ }
472
+ log(
473
+ `[test-portability] FAIL — ${findings.length} Windows-hostile shape(s) in test sources:`,
474
+ );
475
+ for (const f of findings) {
476
+ log(` ${f.file}:${f.line} [${f.shape}] ${f.text}`);
477
+ }
478
+ for (const shape of new Set(findings.map((f) => f.shape))) {
479
+ log(`[test-portability] ${shape}: ${REMEDIES[shape]}`);
480
+ }
481
+ log(
482
+ `[test-portability] the windows-smoke job is advisory, so these land on main unnoticed. Mark a deliberate exception with '${LINT_ESCAPE}: <reason>' on the line or the line above.`,
483
+ );
484
+ return 1;
485
+ }
486
+
487
+ runAsCli(
488
+ import.meta.url,
489
+ async () => runPortability(parseArgv(process.argv.slice(2))),
490
+ {
491
+ source: 'check-test-portability',
492
+ propagateExitCode: true,
493
+ usage: {
494
+ invocation:
495
+ 'node .agents/scripts/check-test-portability.js [--json] [--root <dir>]',
496
+ summary:
497
+ 'Static guard for the two Windows-only shapes that reach main through the advisory windows-smoke job: a RegExp built from an interpolated filesystem path, and a dynamic import() of a raw path instead of a pathToFileURL href.',
498
+ flags: [
499
+ [
500
+ '--json',
501
+ 'Emit { ok, findings[] } as JSON instead of the text report.',
502
+ ],
503
+ ['--root <dir>', 'Repository root to scan (default: this repo root).'],
504
+ ],
505
+ notes: [
506
+ "A line (or the line above it) carrying 'portability-allow' opts out.",
507
+ 'Scope: tests/** plus .agents/scripts/**/__tests__/**.',
508
+ 'Exit codes:\n 0 clean\n 1 at least one finding',
509
+ ],
510
+ },
511
+ },
512
+ );
@@ -17,8 +17,17 @@
17
17
  * `npm run test:coverage`, serialized behind the host-level full-suite
18
18
  * lock (Story #5173) so two concurrent runs on one checkout do not race;
19
19
  * write a fresh capture stamp on success and propagate the exit code.
20
- * With `delivery.execution.requireCreditedCapture` set, step 4 refuses
21
- * instead of spawning, so the cost is never paid unannounced.
20
+ * With `--require-credited`, step 4 refuses instead of spawning, so the
21
+ * cost is never paid unannounced.
22
+ *
23
+ * **`--require-credited` is an argument, not a config read (Story #5278).**
24
+ * `delivery.execution.requireCreditedCapture` is a policy about *close*: the
25
+ * close gate must not silently pay for a suite the worker was supposed to
26
+ * have deposited. Reading it here applied the refusal to every invocation
27
+ * including the worker's depositing one, so turning the policy on left no
28
+ * path that could ever deposit and bricked the CRAP gate outright. The policy
29
+ * now lives where it is enforced — `close-validation/gates.js` passes this
30
+ * flag when the consumer sets it — and a bare invocation always runs.
22
31
  *
23
32
  * Step 3 is preceded by the changed-file skip when
24
33
  * `delivery.quality.gates.crap.incrementalCoverage.skipWhenUnchanged` is on
@@ -29,7 +38,7 @@
29
38
  * 0 — coverage is fresh (or capture skipped/succeeded).
30
39
  * 1 — capture run failed (broken tests or coverage-threshold breach), or
31
40
  * the run was refused because it carried no credit and
32
- * `delivery.execution.requireCreditedCapture` is set. The caller MUST
41
+ * `--require-credited` was passed. The caller MUST
33
42
  * surface this — silently passing here would defeat the CRAP gate's
34
43
  * `requireCoverage: true` policy.
35
44
  */
@@ -56,17 +65,20 @@ import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
56
65
  * Parse the full `process.argv` (index 2 onward) into the capture options.
57
66
  *
58
67
  * @param {string[]} argv
59
- * @returns {{ skipWhenNoCrapFiles: boolean, ref: string, cwd: string }}
68
+ * @returns {{ skipWhenNoCrapFiles: boolean, requireCredited: boolean, ref: string, cwd: string }}
60
69
  */
61
70
  export function parseArgs(argv) {
62
71
  const out = {
63
72
  skipWhenNoCrapFiles: false,
73
+ // Story #5278 — an ARGUMENT, never a config read. See `runCoverageCapture`.
74
+ requireCredited: false,
64
75
  ref: 'main',
65
76
  cwd: process.cwd(),
66
77
  };
67
78
  for (let i = 2; i < argv.length; i += 1) {
68
79
  const a = argv[i];
69
80
  if (a === '--skip-when-no-crap-files') out.skipWhenNoCrapFiles = true;
81
+ else if (a === '--require-credited') out.requireCredited = true;
70
82
  else if (a === '--ref') out.ref = argv[++i] ?? out.ref;
71
83
  else if (a === '--cwd') out.cwd = argv[++i] ?? out.cwd;
72
84
  }
@@ -116,11 +128,6 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
116
128
  const args = parseArgs(argv);
117
129
  const config = resolveConfigImpl({ cwd: args.cwd });
118
130
  const { crap, coverage } = getQualityImpl(config);
119
- // Read once here, where the config is already in scope, and thread it into
120
- // whichever capture path reaches a spawn. Default false — an unconfigured
121
- // consumer gets the announcement and the run, exactly as before.
122
- const requireCreditedCapture =
123
- config?.delivery?.execution?.requireCreditedCapture === true;
124
131
 
125
132
  if (crap.enabled === false) {
126
133
  logger.info('[coverage-capture] CRAP gate disabled — skipping capture.');
@@ -154,7 +161,7 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
154
161
  // Whichever capture path gets here spawns through both without knowing
155
162
  // about either.
156
163
  const capture = creditedCapture(lockedCapture(runCaptureImpl, config), {
157
- requireCredited: requireCreditedCapture,
164
+ requireCredited: args.requireCredited,
158
165
  logger,
159
166
  });
160
167
 
@@ -41,6 +41,7 @@ import {
41
41
  hashCommandConfig,
42
42
  recordPass,
43
43
  shouldSkip,
44
+ treeFingerprint,
44
45
  } from './lib/validation-evidence.js';
45
46
 
46
47
  /**
@@ -94,6 +95,28 @@ function resolveHeadShaDefault(cwd, gitSpawnFn) {
94
95
  return sha.length > 0 ? sha : null;
95
96
  }
96
97
 
98
+ /**
99
+ * The pair of keys an evidence record is written and read under: the commit
100
+ * this gate ran at, and the content identity of the tree it ran against
101
+ * (Story #5278).
102
+ *
103
+ * Both are read from the **spawn** cwd — the Story worktree when one is
104
+ * supplied — so the keys describe the tree the gate actually saw. The tree
105
+ * fingerprint is what keeps this gate credited across close's own base-sync
106
+ * fast-forward, which moves HEAD between the deposit and the gates that would
107
+ * spend it; see `validation-evidence.js#treeFingerprint`.
108
+ *
109
+ * @param {{ spawnCwd: string, gitSpawnFn: Function, useEvidence: boolean }} args
110
+ * @returns {{ headSha: string|null, inputFingerprint: string|null }}
111
+ */
112
+ function resolveEvidenceKeys({ spawnCwd, gitSpawnFn, useEvidence }) {
113
+ if (!useEvidence) return { headSha: null, inputFingerprint: null };
114
+ return {
115
+ headSha: resolveHeadShaDefault(spawnCwd, gitSpawnFn),
116
+ inputFingerprint: treeFingerprint(spawnCwd, gitSpawnFn),
117
+ };
118
+ }
119
+
97
120
  /**
98
121
  * Runner-shaped entry-point: takes the parsed wrapper args + runner args and
99
122
  * executes the gate. Pure-ish (modulo IO) — all side-effects are routed via
@@ -168,9 +191,11 @@ export async function runEvidenceGate(params, deps = {}) {
168
191
  const spawnCwd = worktreePath ?? cwd;
169
192
  const [cmd, ...cmdArgs] = runnerArgs;
170
193
  const configHash = hashCommandConfig({ cmd, args: cmdArgs, cwd: spawnCwd });
171
- const headSha = useEvidence
172
- ? resolveHeadShaDefault(spawnCwd, gitSpawnFn)
173
- : null;
194
+ const { headSha, inputFingerprint } = resolveEvidenceKeys({
195
+ spawnCwd,
196
+ gitSpawnFn,
197
+ useEvidence,
198
+ });
174
199
 
175
200
  if (useEvidence && headSha) {
176
201
  const verdict = shouldSkipFn(
@@ -179,13 +204,14 @@ export async function runEvidenceGate(params, deps = {}) {
179
204
  gateName: gate,
180
205
  currentSha: headSha,
181
206
  configHash,
207
+ inputFingerprint,
182
208
  },
183
209
  evidenceStoreOpts,
184
210
  );
185
211
  if (verdict.skip) {
186
212
  const ts = verdict.record?.timestamp ?? 'n/a';
187
213
  logger.info(
188
- `[evidence-gate] ⏭ ${gate} skipped (evidence match: SHA=${headSha.slice(0, 7)}, recorded ${ts})`,
214
+ `[evidence-gate] ⏭ ${gate} skipped (${verdict.reason}: SHA=${headSha.slice(0, 7)}, recorded ${ts})`,
189
215
  );
190
216
  return { status: 0, skipped: true };
191
217
  }
@@ -220,6 +246,7 @@ export async function runEvidenceGate(params, deps = {}) {
220
246
  configHash,
221
247
  exitCode: 0,
222
248
  durationMs: Date.now() - startedAt,
249
+ inputFingerprint,
223
250
  },
224
251
  evidenceStoreOpts,
225
252
  );