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.
- package/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +158 -7
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +19 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/findings/route-finding.js +4 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +40 -16
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +8 -1
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +34 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- 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
|
|
21
|
-
*
|
|
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
|
-
* `
|
|
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:
|
|
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 =
|
|
172
|
-
|
|
173
|
-
|
|
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 (
|
|
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
|
);
|