control-arm 0.1.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/.github/workflows/ci.yml +82 -0
- package/DESIGN.md +161 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/action.yml +95 -0
- package/bin/ca.mjs +245 -0
- package/fixtures/build.mjs +178 -0
- package/package.json +12 -0
- package/scripts/gh-api.mjs +90 -0
- package/scripts-analyze.mjs +86 -0
- package/scripts-recompute.mjs +53 -0
- package/src/assertions.mjs +350 -0
- package/src/html-report.mjs +125 -0
- package/src/identity.mjs +105 -0
- package/src/markdown-report.mjs +81 -0
- package/src/report.mjs +183 -0
- package/src/runner-json.mjs +127 -0
- package/src/runner.mjs +83 -0
- package/src/select-runner.mjs +58 -0
- package/src/tap.mjs +83 -0
- package/src/verdict.mjs +168 -0
- package/src/verify.mjs +322 -0
- package/src/worktree.mjs +197 -0
- package/test/assertions.test.mjs +130 -0
- package/test/fixtures.test.mjs +41 -0
- package/test/runner-json.test.mjs +69 -0
- package/test/select-runner.test.mjs +78 -0
- package/test/verdict.test.mjs +113 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-roll commit verdicts from a saved audit CSV using the CURRENT rollUp.
|
|
3
|
+
*
|
|
4
|
+
* Per-case verdicts are what the two arms measured; the commit verdict is a pure function
|
|
5
|
+
* of them. So a change to the roll-up rule does not need the 7-minute run again — and
|
|
6
|
+
* re-running would also change the sample, which is the wrong thing to do when comparing
|
|
7
|
+
* a rule change.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { rollUp } from './src/verdict.mjs';
|
|
11
|
+
|
|
12
|
+
function parseCsv(text) {
|
|
13
|
+
const out = []; const lines = text.split('\n').filter(Boolean); const hdr = lines.shift().split(',');
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const f = []; let cur = '', q = false;
|
|
16
|
+
for (let i = 0; i < line.length; i++) { const c = line[i];
|
|
17
|
+
if (q) { if (c === '"' && line[i+1] === '"') { cur += '"'; i++; } else if (c === '"') q = false; else cur += c; }
|
|
18
|
+
else if (c === '"') q = true; else if (c === ',') { f.push(cur); cur = ''; } else cur += c; }
|
|
19
|
+
f.push(cur); out.push(Object.fromEntries(hdr.map((h, i) => [h, f[i]])));
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
const runnerOf = f => !f ? 'none'
|
|
24
|
+
: f.startsWith('tests/') || f.startsWith('packages/') ? 'node:test'
|
|
25
|
+
: f.startsWith('apps/web/') ? 'vitest' : f.startsWith('apps/mobile/') ? 'jest'
|
|
26
|
+
: f.startsWith('apps/e2e/') ? 'playwright' : 'other';
|
|
27
|
+
|
|
28
|
+
const rows = parseCsv(readFileSync(process.argv[2], 'utf8'));
|
|
29
|
+
const commits = new Map();
|
|
30
|
+
for (const r of rows) {
|
|
31
|
+
if (!commits.has(r.sha)) commits.set(r.sha, { ...r, cases: [] });
|
|
32
|
+
if (r.case) commits.get(r.sha).cases.push(r);
|
|
33
|
+
}
|
|
34
|
+
const list = [...commits.values()].map(c => {
|
|
35
|
+
const nv = c.cases.length ? rollUp(c.cases.map(x => ({ verdict: x.case_verdict }))) : c.commit_verdict;
|
|
36
|
+
const rs = [...new Set(c.cases.map(x => runnerOf(x.file)).filter(x => x !== 'none'))];
|
|
37
|
+
return { ...c, was: c.commit_verdict, now: nv, seg: rs.length === 1 ? rs[0] : rs.length ? 'mixed' : 'none' };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const V = ['CAUGHT', 'BLIND', 'FLAKY', 'INCONCLUSIVE', 'SKIPPED'];
|
|
41
|
+
const changed = list.filter(c => c.was !== c.now);
|
|
42
|
+
console.log(`\n ${list.length} commits · ${changed.length} changed verdict under the corrected roll-up\n`);
|
|
43
|
+
for (const c of changed) console.log(` ${c.was.padEnd(13)} -> ${c.now.padEnd(13)} ${c.sha.slice(0,8)} ${c.subject.slice(0,70)}`);
|
|
44
|
+
|
|
45
|
+
const node = list.filter(c => c.seg === 'node:test' || c.seg === 'mixed');
|
|
46
|
+
const dec = node.filter(c => c.now === 'CAUGHT' || c.now === 'BLIND');
|
|
47
|
+
const caught = dec.filter(c => c.now === 'CAUGHT').length;
|
|
48
|
+
console.log(`\n SUPPORTED SEGMENT (node:test, incl. mixed): ${node.length} commits`);
|
|
49
|
+
for (const v of V) { const n = node.filter(c => c.now === v).length; if (n) console.log(` ${v.padEnd(14)} ${String(n).padStart(4)} ${(n/node.length*100).toFixed(1)}%`); }
|
|
50
|
+
console.log(`\n ANSWERABLE: ${dec.length} CAUGHT ${caught} (${(caught/dec.length*100).toFixed(1)}%) BLIND ${dec.length-caught} (${((dec.length-caught)/dec.length*100).toFixed(1)}%)\n`);
|
|
51
|
+
console.log(' BLIND after correction:\n');
|
|
52
|
+
for (const c of list.filter(c => c.now === 'BLIND')) console.log(` ${c.sha.slice(0,8)} ${c.date} ${c.subject.slice(0,84)}`);
|
|
53
|
+
console.log('');
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHY is this test weak? — static analysis of a single test case's assertions.
|
|
3
|
+
*
|
|
4
|
+
* The two-arm run answers "did this case discriminate". It does not say what is wrong
|
|
5
|
+
* with a case that did not, and "NON-DISCRIMINATING" on its own is not actionable: the
|
|
6
|
+
* reader still has to open the file and work out whether they are looking at a deliberate
|
|
7
|
+
* regression guard or an assertion that cannot fail.
|
|
8
|
+
*
|
|
9
|
+
* This reads the case body and names the shape. It is HEURISTIC and it says so — every
|
|
10
|
+
* finding carries a confidence, and `weak` is never asserted where `unknown` is honest.
|
|
11
|
+
*
|
|
12
|
+
* WHY REGEX AND NOT AN AST. An AST parse fails closed on any syntax the parser does not
|
|
13
|
+
* know (JSX, TS decorators, a newer proposal), and the input here is other people's test
|
|
14
|
+
* files across three runners. A regex that recognises less is better than a parser that
|
|
15
|
+
* refuses whole files. The cost is real and bounded: unrecognised shapes report `unknown`,
|
|
16
|
+
* which renders as "not analysed" rather than as a clean bill of health.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Assertion shapes that pass for a huge class of values, including the wrong one. */
|
|
20
|
+
const SHAPES = [
|
|
21
|
+
{
|
|
22
|
+
id: 'truthy-only',
|
|
23
|
+
re: /\b(?:assert\.ok|assert)\(\s*([A-Za-z_$][\w$.\[\]]*)\s*(?:,[^)]*)?\)/,
|
|
24
|
+
severity: 'weak',
|
|
25
|
+
say: (m) => `\`assert.ok(${m[1]})\` passes for ANY truthy value — the right answer and the wrong one both satisfy it`,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'truthy-matcher',
|
|
29
|
+
re: /\bexpect\(\s*([^)]{1,40})\s*\)\s*\.\s*(?:toBeTruthy|toBeDefined|not\.toBeNull|not\.toBeUndefined)\s*\(/,
|
|
30
|
+
severity: 'weak',
|
|
31
|
+
say: (m) => `\`expect(${m[1]}).toBeTruthy()\` passes for any non-empty value, right or wrong`,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'directional',
|
|
35
|
+
re: /\b(?:assert\.ok|expect)\s*\(\s*([^)]{1,60}?)\s*([<>]=?)\s*([^)]{1,20}?)\s*\)/,
|
|
36
|
+
severity: 'weak',
|
|
37
|
+
say: (m) => `asserts a DIRECTION (\`${m[1].trim()} ${m[2]} ${m[3].trim().replace(/,.*$/, '')}\`) — a wrong value on the same side still passes`,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'not-equal-nullish',
|
|
41
|
+
re: /\bassert\.notEqual\s*\(\s*([^,]{1,40}),\s*(undefined|null)\s*\)/,
|
|
42
|
+
severity: 'weak',
|
|
43
|
+
say: (m) => `\`notEqual(${m[1].trim()}, ${m[2]})\` excludes exactly one value out of every possible answer`,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'snapshot',
|
|
47
|
+
re: /\.\s*toMatchSnapshot\s*\(/,
|
|
48
|
+
severity: 'suspect',
|
|
49
|
+
say: () => `pins a SNAPSHOT — it fails on any change, including a correct one, and passes a wrong value that was recorded while the bug was live`,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'type-only',
|
|
53
|
+
re: /\b(?:assert\.ok|expect)\s*\(\s*typeof\s+([A-Za-z_$][\w$.]*)\s*(?:===?\s*['"]\w+['"])?\s*\)/,
|
|
54
|
+
severity: 'weak',
|
|
55
|
+
say: (m) => `asserts only the TYPE of \`${m[1]}\` — every wrong value of the right type passes`,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'length-only',
|
|
59
|
+
re: /\b(?:assert\.ok|expect)\s*\(\s*([A-Za-z_$][\w$.\[\]]*)\.length\s*\)/,
|
|
60
|
+
severity: 'weak',
|
|
61
|
+
say: (m) => `asserts only that \`${m[1]}\` is non-empty — the contents are unchecked`,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'does-not-throw',
|
|
65
|
+
re: /\b(?:assert\.doesNotThrow|expect\([^)]*\)\s*\.\s*not\s*\.\s*toThrow)\s*\(/,
|
|
66
|
+
severity: 'weak',
|
|
67
|
+
say: () => `asserts only that it did not throw — a wrong answer returned quietly still passes`,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'source-text',
|
|
71
|
+
re: /readFileSync\s*\([^)]*\)[\s\S]{0,200}?(?:includes|match|test)\s*\(/,
|
|
72
|
+
severity: 'weak',
|
|
73
|
+
say: () => `reads the SOURCE and matches text instead of executing it — it pins the code's spelling, not its behaviour`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'no-assertion',
|
|
77
|
+
re: null,
|
|
78
|
+
severity: 'weak',
|
|
79
|
+
say: () => `the case body contains no assertion at all`,
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** Shapes that are STRONG — an exact expected value the wrong answer cannot satisfy. */
|
|
84
|
+
const STRONG = new RegExp([
|
|
85
|
+
// node:assert — an exact expected value
|
|
86
|
+
'assert\\.(?:equal|strictEqual|deepEqual|deepStrictEqual|notDeepEqual|match|throws|rejects)\\s*\\(',
|
|
87
|
+
// jest / vitest matchers that pin a value, a shape or a call
|
|
88
|
+
'expect\\([\\s\\S]{0,200}?\\)\\s*(?:\\.\\s*(?:not|resolves|rejects)\\s*)*\\.\\s*(?:'
|
|
89
|
+
+ 'toBe|toEqual|toStrictEqual|toMatchObject|toMatch|toMatchInlineSnapshot'
|
|
90
|
+
+ '|toContain|toContainEqual|toHaveLength|toHaveProperty|toBeCloseTo|toBeNull'
|
|
91
|
+
+ '|toHaveBeenCalledWith|toHaveBeenCalledTimes|toThrow|toThrowError'
|
|
92
|
+
+ ')\\s*\\(',
|
|
93
|
+
].join('|'));
|
|
94
|
+
|
|
95
|
+
const ASSERT_ANY = /\b(?:assert[.(]|expect\s*\()/;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Pull one test case's body out of a file by its name. Brace-matched, not line-based.
|
|
99
|
+
*
|
|
100
|
+
* PROGRESSIVE REDUCTION, and it is the difference between analysing a third of the corpus
|
|
101
|
+
* and analysing all of it. A nested suite reports the CONCATENATED name —
|
|
102
|
+
*
|
|
103
|
+
* describe('auction client', () => { it('AUCWEB-001 calls GET /auction/:vin', …) })
|
|
104
|
+
* reported as: "auction client AUCWEB-001 calls GET /auction/:vin"
|
|
105
|
+
*
|
|
106
|
+
* — while the source contains only the inner title. Searching for the full string finds
|
|
107
|
+
* nothing. Measured on 400 real cases: 256 of them (64%) failed to resolve for exactly
|
|
108
|
+
* this reason, which read as "could not analyse" when the body was right there.
|
|
109
|
+
*
|
|
110
|
+
* So: try the full name, then drop leading words one at a time and take the LONGEST
|
|
111
|
+
* remainder that resolves. Longest-first matters — a short suffix like "returns null"
|
|
112
|
+
* could match several cases in one file, and matching the wrong body is worse than
|
|
113
|
+
* matching none. A remainder under 8 characters is refused for the same reason.
|
|
114
|
+
*/
|
|
115
|
+
export function extractCase(source, caseName) {
|
|
116
|
+
const words = String(caseName).split(/\s+/);
|
|
117
|
+
for (let start = 0; start < words.length; start++) {
|
|
118
|
+
const candidate = words.slice(start).join(' ');
|
|
119
|
+
// The length floor guards against an AMBIGUOUS SUFFIX resolving to an arbitrary
|
|
120
|
+
// case ("returns null" could name three tests in one file). It must NOT apply to
|
|
121
|
+
// the full name: a test legitimately called `p is 1` is six characters, and
|
|
122
|
+
// refusing it made every short-named case unfindable. Found by a unit test whose
|
|
123
|
+
// own fixture had a short name — the floor silently rejected it.
|
|
124
|
+
if (start > 0 && candidate.length < 8) break;
|
|
125
|
+
const body = extractExact(source, candidate);
|
|
126
|
+
if (body != null) return body;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Find a case by title and return its body — by SCANNING, not by regex.
|
|
133
|
+
*
|
|
134
|
+
* Two things a regex got wrong on real files, both measured:
|
|
135
|
+
*
|
|
136
|
+
* 1. ESCAPED QUOTES. `it('METER-038: usage on the user\'s LOCAL today is counted')` is
|
|
137
|
+
* reported by the runner as `...the user's LOCAL today...`, with the backslash gone.
|
|
138
|
+
* A regex matching `(['"`])<name>\1` never matches, because the source holds `\'`
|
|
139
|
+
* where the name holds `'`. So: parse the literal properly and UNESCAPE it before
|
|
140
|
+
* comparing.
|
|
141
|
+
*
|
|
142
|
+
* 2. BRACES INSIDE STRINGS. Counting `{` and `}` literally unbalances on a body
|
|
143
|
+
* containing `'{'`, a regex like /[{]/, a template literal, or a comment with a brace
|
|
144
|
+
* in it — and then the body is reported as not found. So: skip strings, template
|
|
145
|
+
* literals, comments and regex literals while matching.
|
|
146
|
+
*
|
|
147
|
+
* Together these were the whole of the remaining "could not analyse" pile.
|
|
148
|
+
*/
|
|
149
|
+
function unescapeLiteral(raw) {
|
|
150
|
+
return raw.replace(/\\(['"`\\nrt])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t' }[c] ?? c));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Read a quoted literal starting at `i` (which must be the quote). Returns {text, end}. */
|
|
154
|
+
function readLiteral(src, i) {
|
|
155
|
+
const q = src[i];
|
|
156
|
+
let out = '';
|
|
157
|
+
for (let j = i + 1; j < src.length; j++) {
|
|
158
|
+
const c = src[j];
|
|
159
|
+
if (c === '\\') { out += c + (src[j + 1] ?? ''); j++; continue; }
|
|
160
|
+
if (c === q) return { text: unescapeLiteral(out), raw: out, tmpl: q === '`', end: j };
|
|
161
|
+
if (c === '\n' && q !== '`') return null; // unterminated: not a title
|
|
162
|
+
out += c;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Walk from `open` (a `{`) to its matching `}`, ignoring braces that are not code.
|
|
169
|
+
* Regex detection is the approximate part: a `/` is treated as starting a regex only
|
|
170
|
+
* when the previous non-space character cannot end an expression. That is the standard
|
|
171
|
+
* heuristic and it is wrong only for exotic code; when it is wrong the scan fails closed
|
|
172
|
+
* and the case reports as not analysed, never as a wrong body.
|
|
173
|
+
*/
|
|
174
|
+
function matchBrace(src, open) {
|
|
175
|
+
let depth = 0;
|
|
176
|
+
let prev = '';
|
|
177
|
+
for (let i = open; i < src.length; i++) {
|
|
178
|
+
const c = src[i];
|
|
179
|
+
if (c === '/' && src[i + 1] === '/') { i = src.indexOf('\n', i); if (i === -1) return -1; continue; }
|
|
180
|
+
if (c === '/' && src[i + 1] === '*') { i = src.indexOf('*/', i + 2); if (i === -1) return -1; i++; continue; }
|
|
181
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
182
|
+
const lit = readLiteral(src, i);
|
|
183
|
+
if (!lit) return -1;
|
|
184
|
+
i = lit.end;
|
|
185
|
+
prev = c;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// REGEX DETECTION, deliberately narrow. The usual heuristic — "a slash after
|
|
189
|
+
// anything that cannot end an expression starts a regex" — is WRONG on JSX: in
|
|
190
|
+
// `</div>` the slash follows `<`, so the scan treats the rest of the component as
|
|
191
|
+
// a regex literal and the body is never found. Measured: every .tsx case in the
|
|
192
|
+
// corpus failed this way. So require the previous character to be one of a small
|
|
193
|
+
// set of operators that really can precede a regex, and never `<`.
|
|
194
|
+
if (c === '/' && REGEX_PREV.has(prev) && !'/*>='.includes(src[i + 1] ?? '')) {
|
|
195
|
+
// regex literal: run to the unescaped closing slash
|
|
196
|
+
let j = i + 1, inClass = false;
|
|
197
|
+
for (; j < src.length; j++) {
|
|
198
|
+
const d = src[j];
|
|
199
|
+
if (d === '\\') { j++; continue; }
|
|
200
|
+
if (d === '[') inClass = true;
|
|
201
|
+
else if (d === ']') inClass = false;
|
|
202
|
+
else if (d === '/' && !inClass) break;
|
|
203
|
+
else if (d === '\n') return -1;
|
|
204
|
+
}
|
|
205
|
+
i = j;
|
|
206
|
+
prev = '/';
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (c === '{') depth++;
|
|
210
|
+
else if (c === '}') { depth--; if (depth === 0) return i; }
|
|
211
|
+
if (!/\s/.test(c)) prev = c;
|
|
212
|
+
}
|
|
213
|
+
return -1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Characters after which a `/` genuinely begins a regex literal. `<` is NOT one. */
|
|
217
|
+
const REGEX_PREV = new Set(['=', '(', ',', '[', ':', '!', '&', '|', '?', '{', ';', '+', '-', '*', '%', '^', '~', 'return'.slice(-1)]);
|
|
218
|
+
|
|
219
|
+
const CALL = /\b(?:test|it)(?:\.\w+)?\s*\(\s*(?=['"`])/g;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Does this source title name the case the runner reported?
|
|
223
|
+
*
|
|
224
|
+
* Exact for a plain string. For a TEMPLATE LITERAL WITH INTERPOLATION it cannot be exact,
|
|
225
|
+
* because the runtime name contains a value the source does not:
|
|
226
|
+
*
|
|
227
|
+
* for (const rel of PUBLIC_PAGES) {
|
|
228
|
+
* it(`${rel}: no docs/(planning|LEGAL|spec|marketing) reference`, …)
|
|
229
|
+
*
|
|
230
|
+
* reported as: "app/page.tsx: no docs/(planning|LEGAL|spec|marketing) reference"
|
|
231
|
+
*
|
|
232
|
+
* Every parameterised test has this shape, and there were 30-odd of them in one file
|
|
233
|
+
* alone. So each `${…}` becomes a non-greedy wildcard and the rest is matched literally.
|
|
234
|
+
*
|
|
235
|
+
* All N generated cases resolve to the SAME body, which is correct: they share one. The
|
|
236
|
+
* assertion analysis is therefore about the shared body, which is the thing worth reading.
|
|
237
|
+
*/
|
|
238
|
+
function titleMatches(lit, caseName) {
|
|
239
|
+
if (lit.text === caseName) return true;
|
|
240
|
+
if (!lit.tmpl || !lit.raw.includes('${')) return false;
|
|
241
|
+
const pattern = lit.raw
|
|
242
|
+
.split(/\$\{[^}]*\}/)
|
|
243
|
+
.map(part => unescapeLiteral(part).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
244
|
+
.join('[\\s\\S]*?');
|
|
245
|
+
try { return new RegExp('^' + pattern + '$').test(caseName); } catch { return false; }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function extractExact(source, caseName) {
|
|
249
|
+
CALL.lastIndex = 0;
|
|
250
|
+
let m;
|
|
251
|
+
while ((m = CALL.exec(source)) !== null) {
|
|
252
|
+
const qi = m.index + m[0].length;
|
|
253
|
+
const lit = readLiteral(source, qi);
|
|
254
|
+
if (!lit) continue;
|
|
255
|
+
if (!titleMatches(lit, caseName)) continue;
|
|
256
|
+
const open = source.indexOf('{', lit.end);
|
|
257
|
+
if (open === -1) continue;
|
|
258
|
+
const close = matchBrace(source, open);
|
|
259
|
+
// A failed brace scan on ONE site must not abandon the search — a later site may
|
|
260
|
+
// carry the same title and scan cleanly. Fail closed per site, not per file.
|
|
261
|
+
if (close === -1) continue;
|
|
262
|
+
return source.slice(open + 1, close);
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @returns {{verdict:'strong'|'weak'|'suspect'|'unknown', findings:Array, snippet:string}}
|
|
269
|
+
*/
|
|
270
|
+
/**
|
|
271
|
+
* A stub at FILE scope that shadows nothing — because the real symbol was never imported.
|
|
272
|
+
*
|
|
273
|
+
* `const priority = () => 1;` next to `import { SLA_HOURS } from '../src/priority.mjs'`
|
|
274
|
+
* means the unit under test is never called: the case asserts against the stub and passes
|
|
275
|
+
* whatever the real code does. Caught only by reading the WHOLE file, so this cannot live
|
|
276
|
+
* in the per-case shapes above — the case body does not contain it.
|
|
277
|
+
*
|
|
278
|
+
* Deliberately narrow: it fires only when a locally-defined arrow function has the same
|
|
279
|
+
* name as something the test file imports from, or names in, its own source path. A
|
|
280
|
+
* broader rule would flag every legitimate helper.
|
|
281
|
+
*/
|
|
282
|
+
function fileScopeStubs(source) {
|
|
283
|
+
const out = [];
|
|
284
|
+
const imported = new Set();
|
|
285
|
+
for (const m of source.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
|
|
286
|
+
for (const nm of m[1].split(',')) {
|
|
287
|
+
const n = nm.trim().split(/\s+as\s+/).pop().trim();
|
|
288
|
+
if (n) imported.add(n);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
for (const m of source.matchAll(/^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/gm)) {
|
|
292
|
+
const name = m[1];
|
|
293
|
+
// FALSE POSITIVE FOUND ON A REAL RUN. This used to substring-match the name against
|
|
294
|
+
// every import path, so a two-letter helper named `at` matched `@acme/core` —
|
|
295
|
+
// because the word "acme" contains "at" — and every case in that file was reported
|
|
296
|
+
// as testing a stub. A wrong explanation is worse than none: it sends the reader to
|
|
297
|
+
// look at code that is fine.
|
|
298
|
+
//
|
|
299
|
+
// Now: the name must be either an EXACT imported binding, or the BASENAME of an
|
|
300
|
+
// imported module (`import … from '../src/priority.mjs'` for a stub named
|
|
301
|
+
// `priority`). And never a name under three characters, where any rule is a guess.
|
|
302
|
+
if (name.length < 3) continue;
|
|
303
|
+
const basenameMatch = [...source.matchAll(/from\s*['"]([^'"]+)['"]/g)]
|
|
304
|
+
.some(im => im[1].split('/').pop().replace(/\.[mc]?[jt]sx?$/, '') === name);
|
|
305
|
+
if (imported.has(name) || basenameMatch) {
|
|
306
|
+
out.push({
|
|
307
|
+
id: 'stubbed-subject', severity: 'weak',
|
|
308
|
+
note: `\`${name}\` is redefined as a local stub at file scope — the real \`${name}\` is never called, so this case passes whatever the shipped code does`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function analyseCase(source, caseName) {
|
|
316
|
+
const body = extractCase(source, caseName);
|
|
317
|
+
if (body == null) return { verdict: 'unknown', findings: [], snippet: '', reason: 'case body not found' };
|
|
318
|
+
|
|
319
|
+
// Comments carry example code and prose; stripping them first stops both from matching.
|
|
320
|
+
const code = body.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
321
|
+
|
|
322
|
+
const findings = [];
|
|
323
|
+
for (const s of SHAPES) {
|
|
324
|
+
if (s.id === 'no-assertion') continue;
|
|
325
|
+
const m = code.match(s.re);
|
|
326
|
+
if (m) findings.push({ id: s.id, severity: s.severity, note: s.say(m) });
|
|
327
|
+
}
|
|
328
|
+
if (!ASSERT_ANY.test(code)) {
|
|
329
|
+
const s = SHAPES.find(x => x.id === 'no-assertion');
|
|
330
|
+
findings.push({ id: s.id, severity: s.severity, note: s.say() });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
findings.push(...fileScopeStubs(source));
|
|
334
|
+
|
|
335
|
+
const hasStrong = STRONG.test(code);
|
|
336
|
+
const weak = findings.some(f => f.severity === 'weak');
|
|
337
|
+
|
|
338
|
+
// A case with BOTH an exact assertion and a loose one is not weak — the exact one
|
|
339
|
+
// still fails on a wrong answer. Report the loose shape, but do not call it weak.
|
|
340
|
+
let verdict;
|
|
341
|
+
const stubbed = findings.some(f => f.id === 'stubbed-subject');
|
|
342
|
+
if (stubbed) verdict = 'weak'; // an exact assertion against a stub proves nothing
|
|
343
|
+
else if (weak && !hasStrong) verdict = 'weak';
|
|
344
|
+
else if (findings.length && hasStrong) verdict = 'suspect';
|
|
345
|
+
else if (hasStrong) verdict = 'strong';
|
|
346
|
+
else verdict = 'unknown';
|
|
347
|
+
|
|
348
|
+
const snippet = code.split('\n').map(l => l.trim()).filter(Boolean).slice(0, 4).join('\n');
|
|
349
|
+
return { verdict, findings, snippet, hasStrong };
|
|
350
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTML report — and the hand-off, which is the part that matters.
|
|
3
|
+
*
|
|
4
|
+
* A verdict list with no next step is an instrument with no output tray. This turns
|
|
5
|
+
* findings into work: every STILL OPEN row is selectable, and the selection becomes either
|
|
6
|
+
* prefilled GitHub issues or a `gh issue create` command to paste.
|
|
7
|
+
*
|
|
8
|
+
* WHY PREFILLED URLS AND NOT AN API CALL. A report is a static file, opened from disk or
|
|
9
|
+
* from a CI artifact. It has no server, no token, and in most viewers no permission to
|
|
10
|
+
* reach github.com at all. `…/issues/new?title=&body=&labels=` is a plain link: GitHub
|
|
11
|
+
* renders the issue form already filled in, and a person presses Submit. No credential
|
|
12
|
+
* ever touches the page, and the human stays in the loop by construction.
|
|
13
|
+
*
|
|
14
|
+
* The automated path is the CLI (`ca issues --apply`), which has a token and needs no UI.
|
|
15
|
+
* Both exist on purpose: a reviewer triaging twelve findings wants to pick four; a nightly
|
|
16
|
+
* job wants to file them all without asking.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
20
|
+
|
|
21
|
+
export function issueBody(f, repoName) {
|
|
22
|
+
return `### ❌ ${f.stillOpen === 'OPEN' ? '🔴 Still open' : 'Finding'} — found by \`control-arm\`
|
|
23
|
+
|
|
24
|
+
The test shipped with this fix does not fail on the code it was written to catch.
|
|
25
|
+
|
|
26
|
+
| | |
|
|
27
|
+
|---|---|
|
|
28
|
+
| commit | \`${f.sha.slice(0, 8)}\` |
|
|
29
|
+
| date | ${f.date} |
|
|
30
|
+
| subject | ${f.subject} |
|
|
31
|
+
| verdict | \`${f.verdict}\`${f.stillOpen ? ` — \`${f.stillOpen}\`` : ''} |
|
|
32
|
+
|
|
33
|
+
### What this means
|
|
34
|
+
|
|
35
|
+
The fix changed source, and shipped a test. Replaying that test against the parent commit
|
|
36
|
+
(the code **with** the bug still in it) did not make it fail. So nothing in this commit
|
|
37
|
+
would have caught the bug it was fixing.
|
|
38
|
+
|
|
39
|
+
${f.cases?.length ? `### Cases examined\n\n${f.cases.map(c => `- \`${c.verdict}\` ${c.name}${c.why ? `\n - ${c.why}` : ''}`).join('\n')}` : ''}
|
|
40
|
+
|
|
41
|
+
### Reproduce
|
|
42
|
+
|
|
43
|
+
\`\`\`bash
|
|
44
|
+
ca verify ${f.sha.slice(0, 8)} --repo ${repoName}
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
<sub>Verdict is a measurement, not a judgement of the author — a regression guard is
|
|
48
|
+
indistinguishable from a weak test at this level. Confirm before acting.</sub>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderHtml({ findings, meta, repoSlug = 'OWNER/REPO', repoName = '.' }) {
|
|
52
|
+
const rows = findings.map((f, i) => {
|
|
53
|
+
const url = `https://github.com/${repoSlug}/issues/new?title=${encodeURIComponent(`test gap: ${f.subject.slice(0, 70)}`)}&body=${encodeURIComponent(issueBody(f, repoName))}&labels=${encodeURIComponent('test-gap,needs:dev')}`;
|
|
54
|
+
return `<tr data-i="${i}">
|
|
55
|
+
<td><input type="checkbox" class="pick" id="p${i}" data-url="${esc(url)}" data-sha="${esc(f.sha.slice(0,8))}"></td>
|
|
56
|
+
<td><code>${esc(f.sha.slice(0, 8))}</code></td>
|
|
57
|
+
<td class="dt">${esc(f.date)}</td>
|
|
58
|
+
<td><label for="p${i}">${esc(f.subject)}</label>
|
|
59
|
+
${f.cases?.length ? `<details><summary>${f.cases.length} cases</summary><ul>${f.cases.map(c => `<li><span class="v v-${c.verdict.toLowerCase().replace(/[^a-z]/g,'')}">${esc(c.verdict)}</span> ${esc(c.name)}${c.why ? `<div class="why">${esc(c.why)}</div>` : ''}</li>`).join('')}</ul></details>` : ''}</td>
|
|
60
|
+
<td><span class="v v-${(f.stillOpen||'').toLowerCase()}">${esc(f.stillOpen || '—')}</span></td>
|
|
61
|
+
</tr>`;
|
|
62
|
+
}).join('\n');
|
|
63
|
+
|
|
64
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
65
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
66
|
+
<title>control-arm report — ${esc(meta.repo || '')}</title>
|
|
67
|
+
<style>
|
|
68
|
+
:root{--bg:#f6f7f8;--sf:#fff;--ink:#15181c;--dim:#6d7781;--rule:#d9dde1;--ok:#1a6b45;--bad:#9a2f24;--warn:#8a5a09;--acc:#1b4f72;color-scheme:light}
|
|
69
|
+
@media(prefers-color-scheme:dark){:root{--bg:#101317;--sf:#171b20;--ink:#e6eaee;--dim:#848e98;--rule:#2a3138;--ok:#5cc08d;--bad:#e08279;--warn:#d7a545;--acc:#78b4dd;color-scheme:dark}}
|
|
70
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif}
|
|
71
|
+
.w{max-width:1040px;margin:0 auto;padding:32px 20px 80px}
|
|
72
|
+
h1{font-size:1.6rem;margin:0 0 .3rem}.sub{color:var(--dim);margin:0 0 1.6rem;font-size:.9rem}
|
|
73
|
+
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-bottom:1.8rem}
|
|
74
|
+
.tile{background:var(--sf);border:1px solid var(--rule);border-radius:6px;padding:12px 14px}
|
|
75
|
+
.tile b{display:block;font-size:1.5rem;font-variant-numeric:tabular-nums}
|
|
76
|
+
.tile span{color:var(--dim);font-size:.76rem;text-transform:uppercase;letter-spacing:.06em}
|
|
77
|
+
table{width:100%;border-collapse:collapse;background:var(--sf);border:1px solid var(--rule);border-radius:6px;overflow:hidden}
|
|
78
|
+
th,td{text-align:left;padding:.6rem .7rem;border-bottom:1px solid var(--rule);vertical-align:top}
|
|
79
|
+
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:var(--dim)}
|
|
80
|
+
td.dt{white-space:nowrap;color:var(--dim);font-size:.85rem}
|
|
81
|
+
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.86em}
|
|
82
|
+
.v{font-size:.7rem;font-weight:600;padding:.1em .45em;border-radius:3px;white-space:nowrap}
|
|
83
|
+
.v-caught,.v-repaired{background:color-mix(in srgb,var(--ok) 15%,transparent);color:var(--ok)}
|
|
84
|
+
.v-blind,.v-open{background:color-mix(in srgb,var(--bad) 15%,transparent);color:var(--bad)}
|
|
85
|
+
.v-inconclusive,.v-unknown,.v-nondiscriminating{background:color-mix(in srgb,var(--warn) 15%,transparent);color:var(--warn)}
|
|
86
|
+
.why{color:var(--dim);font-size:.82rem;margin:.15rem 0 .4rem 0;padding-left:.6rem;border-left:2px solid var(--rule)}
|
|
87
|
+
details{margin-top:.35rem}summary{cursor:pointer;color:var(--acc);font-size:.84rem}
|
|
88
|
+
ul{margin:.4rem 0;padding-left:1.1rem}li{margin:.25rem 0}
|
|
89
|
+
.bar{position:sticky;bottom:0;background:var(--sf);border-top:1px solid var(--rule);padding:12px 20px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:0 -20px -80px;padding-bottom:calc(12px + env(safe-area-inset-bottom,0px))}
|
|
90
|
+
button{font:inherit;padding:.45rem .9rem;border-radius:5px;border:1px solid var(--acc);background:var(--acc);color:#fff;cursor:pointer}
|
|
91
|
+
button.ghost{background:transparent;color:var(--acc)}
|
|
92
|
+
button:disabled{opacity:.45;cursor:not-allowed}
|
|
93
|
+
#cnt{color:var(--dim);font-size:.86rem}
|
|
94
|
+
pre{background:var(--bg);border:1px solid var(--rule);border-radius:5px;padding:.7rem;overflow-x:auto;font-size:.8rem;margin:.6rem 0 0}
|
|
95
|
+
</style></head><body><div class="w">
|
|
96
|
+
<h1>control-arm</h1>
|
|
97
|
+
<p class="sub">${esc(meta.repo || '')} · ${esc(meta.n)} commits sampled · seed ${esc(meta.seed)} · ${esc(meta.when)}</p>
|
|
98
|
+
<div class="tiles">
|
|
99
|
+
<div class="tile"><b>${meta.caught}</b><span>caught</span></div>
|
|
100
|
+
<div class="tile"><b>${meta.blind}</b><span>blind</span></div>
|
|
101
|
+
<div class="tile"><b>${meta.inconclusive}</b><span>inconclusive</span></div>
|
|
102
|
+
<div class="tile"><b>${meta.pct}%</b><span>of answerable</span></div>
|
|
103
|
+
</div>
|
|
104
|
+
<table><thead><tr><th style="width:2rem"></th><th>commit</th><th>date</th><th>fix</th><th>still open?</th></tr></thead>
|
|
105
|
+
<tbody>${rows || '<tr><td colspan="5" style="color:var(--dim)">No blind findings in this sample.</td></tr>'}</tbody></table>
|
|
106
|
+
<pre id="cmd" hidden></pre>
|
|
107
|
+
<div class="bar">
|
|
108
|
+
<button id="all" class="ghost">Select all</button>
|
|
109
|
+
<span id="cnt">0 selected</span>
|
|
110
|
+
<button id="open" disabled>Open GitHub issues</button>
|
|
111
|
+
<button id="copy" class="ghost" disabled>Copy gh command</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div><script>
|
|
114
|
+
const picks=()=>[...document.querySelectorAll('.pick:checked')];
|
|
115
|
+
const cnt=document.getElementById('cnt'),ob=document.getElementById('open'),cb=document.getElementById('copy'),cmd=document.getElementById('cmd');
|
|
116
|
+
function sync(){const n=picks().length;cnt.textContent=n+' selected';ob.disabled=cb.disabled=!n;}
|
|
117
|
+
document.addEventListener('change',e=>{if(e.target.classList.contains('pick'))sync()});
|
|
118
|
+
document.getElementById('all').onclick=()=>{const b=picks().length===0;document.querySelectorAll('.pick').forEach(c=>c.checked=b);sync()};
|
|
119
|
+
ob.onclick=()=>{picks().forEach((c,i)=>setTimeout(()=>window.open(c.dataset.url,'_blank','noopener'),i*260))};
|
|
120
|
+
cb.onclick=async()=>{const t=picks().map(c=>'ca issues '+c.dataset.sha+' --apply').join('\\n');cmd.hidden=false;cmd.textContent=t;
|
|
121
|
+
try{await navigator.clipboard.writeText(t);cb.textContent='Copied'}catch{cb.textContent='Shown below'}
|
|
122
|
+
setTimeout(()=>cb.textContent='Copy gh command',1800)};
|
|
123
|
+
sync();
|
|
124
|
+
</script></body></html>`;
|
|
125
|
+
}
|
package/src/identity.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove the control arm loaded the control arm's code.
|
|
3
|
+
*
|
|
4
|
+
* EARNED ON THE FIRST REAL RUN. Verifying a carefully tested commit, arm B
|
|
5
|
+
* reported 4 pass / 0 fail — a BLIND verdict on a commit whose test provably cannot pass
|
|
6
|
+
* on the broken code. The harness had symlinked the target repo's whole `node_modules`
|
|
7
|
+
* into the worktree. Workspace packages inside it are symlinks relative to the REAL repo:
|
|
8
|
+
*
|
|
9
|
+
* node_modules/@acme/core -> ../../packages/core
|
|
10
|
+
* resolved: file:///home/.../the-repo/packages/core/src/parser.js
|
|
11
|
+
*
|
|
12
|
+
* so the test imported the CURRENT source, not the parent's. Arm B never loaded the code
|
|
13
|
+
* under test. Rebuilt with workspace links pointed into the worktree, the same run
|
|
14
|
+
* returned CAUGHT with the real assertion message.
|
|
15
|
+
*
|
|
16
|
+
* Every language has this trap somewhere (venvs, GOPATH/module cache, classpaths). So the
|
|
17
|
+
* tool does not assume the checkout worked — it asks the runtime where each of the test's
|
|
18
|
+
* imports actually resolved, and refuses to answer if any of them landed outside the
|
|
19
|
+
* worktree.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFile } from 'node:child_process';
|
|
23
|
+
import { promisify } from 'node:util';
|
|
24
|
+
import { readFile } from 'node:fs/promises';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
const exec = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
const IMPORT_RE = /^\s*import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/gm;
|
|
30
|
+
|
|
31
|
+
export async function specifiersOf(testFilePath) {
|
|
32
|
+
const src = await readFile(testFilePath, 'utf8');
|
|
33
|
+
const out = new Set();
|
|
34
|
+
for (const m of src.matchAll(IMPORT_RE)) {
|
|
35
|
+
const spec = m[1];
|
|
36
|
+
if (spec.startsWith('node:')) continue; // builtins cannot be stale
|
|
37
|
+
out.add(spec);
|
|
38
|
+
}
|
|
39
|
+
return [...out];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Ask node, inside the worktree, where each specifier resolves. Any resolution outside
|
|
44
|
+
* `worktreeRoot` means we are testing somebody else's code.
|
|
45
|
+
*/
|
|
46
|
+
export async function proveIdentity({ worktreeRoot, repoRoot, testFilePath, timeoutMs = 30_000 }) {
|
|
47
|
+
let specs;
|
|
48
|
+
try {
|
|
49
|
+
specs = await specifiersOf(testFilePath);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return { proven: false, reason: `could not read test file: ${e.message}` };
|
|
52
|
+
}
|
|
53
|
+
if (specs.length === 0) return { proven: true, resolved: {}, note: 'no external imports' };
|
|
54
|
+
|
|
55
|
+
const probe = `
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const s of ${JSON.stringify(specs)}) {
|
|
58
|
+
try { out[s] = await import.meta.resolve(s); }
|
|
59
|
+
catch (e) { out[s] = 'UNRESOLVED:' + e.code; }
|
|
60
|
+
}
|
|
61
|
+
console.log(JSON.stringify(out));
|
|
62
|
+
`;
|
|
63
|
+
let stdout;
|
|
64
|
+
try {
|
|
65
|
+
({ stdout } = await exec(process.execPath, ['--input-type=module', '-e', probe], {
|
|
66
|
+
cwd: path.dirname(testFilePath), timeout: timeoutMs, maxBuffer: 8 << 20,
|
|
67
|
+
}));
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return { proven: false, reason: `resolution probe failed: ${(e.stderr || e.message || '').split('\n')[0]}` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let resolved;
|
|
73
|
+
try { resolved = JSON.parse(stdout.trim().split('\n').pop()); }
|
|
74
|
+
catch { return { proven: false, reason: 'resolution probe produced no JSON' }; }
|
|
75
|
+
|
|
76
|
+
const rootUrl = new URL('file://' + path.resolve(worktreeRoot) + '/').href;
|
|
77
|
+
// Third-party packages are DELIBERATELY shared with the target repo's node_modules —
|
|
78
|
+
// they are version-pinned content, not the source under test, and installing them per
|
|
79
|
+
// worktree would cost an install per commit for no change in behaviour. So resolving
|
|
80
|
+
// to `<repo>/node_modules/...` is correct and must not read as an identity failure.
|
|
81
|
+
//
|
|
82
|
+
// Measured: without this, one shared third-party package alone withheld the verdict
|
|
83
|
+
// on 5 of 120 commits. A workspace package is the opposite case and still fails, because node
|
|
84
|
+
// follows its symlink to `<repo>/packages/core/...` — inside the repo, OUTSIDE
|
|
85
|
+
// node_modules. That is the distinction this gate exists to make.
|
|
86
|
+
const vendorUrl = repoRoot ? new URL('file://' + path.join(path.resolve(repoRoot), 'node_modules') + '/').href : null;
|
|
87
|
+
const escapees = [];
|
|
88
|
+
for (const [spec, url] of Object.entries(resolved)) {
|
|
89
|
+
if (typeof url !== 'string') continue;
|
|
90
|
+
if (url.startsWith('UNRESOLVED:')) {
|
|
91
|
+
// Genuinely missing at the parent (the fix added it). Not an identity failure
|
|
92
|
+
// — the run will surface it as an error, which reads INCONCLUSIVE anyway.
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!url.startsWith('file:')) continue; // node: / data: are fine
|
|
96
|
+
const real = decodeURIComponent(url);
|
|
97
|
+
if (real.startsWith(rootUrl)) continue; // inside the worktree: good
|
|
98
|
+
if (vendorUrl && real.startsWith(vendorUrl)) continue; // shared third-party: fine
|
|
99
|
+
escapees.push(`${spec} -> ${real}`);
|
|
100
|
+
}
|
|
101
|
+
if (escapees.length) {
|
|
102
|
+
return { proven: false, reason: `resolved OUTSIDE the worktree: ${escapees.join('; ')}`, resolved };
|
|
103
|
+
}
|
|
104
|
+
return { proven: true, resolved };
|
|
105
|
+
}
|