@videlic/connect 0.1.2 → 0.1.7

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/evidence.mjs ADDED
@@ -0,0 +1,3005 @@
1
+ // Managed by @videlic/connect — do not edit by hand.
2
+ //
3
+ // The client's half of the EvidenceBundle (spec-v3 §3.7): what the repository
4
+ // on the developer's disk looks like from where the session ran.
5
+ //
6
+ // WHY THIS IS A SECOND FILE AND NOT A DEPENDENCY. The spec says "the graph via
7
+ // `import-extract` as an npm dependency". It cannot be: `install.mjs` copies
8
+ // the hook to `~/.claude/videlic-hook.mjs` with `copyFileSync`, and the
9
+ // installed hook is a lone file with no `node_modules` anywhere near it — an
10
+ // `import` of a package would throw on every Stop on every machine. The
11
+ // extractor's home, `@ao/analyzers`, is `private: true` and has never been on
12
+ // npm either. So the hook grows a second copied file instead, and the drift
13
+ // that a second copy invites is answered the only way that actually works: a
14
+ // test runs THIS code and the stand's TypeScript over the same real
15
+ // repository — all 24 043 paths of it — and fails on the first disagreement.
16
+ //
17
+ // Everything here is a port, not a rewrite. Each function below is the twin of
18
+ // a named function in the analyzers package, and where a regex looks odd it is
19
+ // odd there too, for a reason recorded there. Read them together or not at all:
20
+ //
21
+ // extractModuleEdges / maskModuleSource packages/analyzers/src/engines/module-edges.ts
22
+ // isTestPath / expectedTestPaths packages/analyzers/src/observe/test-paths.ts
23
+ // isGraphSource / selectEvidence packages/analyzers/src/eval/replay/git-source.ts
24
+ // buildEvidence packages/analyzers/src/eval/replay/git-source.ts
25
+ //
26
+ // NOTHING HERE IS ALLOWED TO THROW ON A DEVELOPER'S MACHINE. A bundle is a
27
+ // nice-to-have that arrives after the session is already captured; a collector
28
+ // that crashed would cost the capture nothing and the developer a stack trace
29
+ // on their terminal, which is a bad trade at any quality of evidence.
30
+
31
+ import { execFileSync } from "node:child_process";
32
+ import { accessSync, constants, readFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+
35
+ /** The producer's own version, stamped on every bundle. Identity, not evidence. */
36
+ export const EVIDENCE_V = 1;
37
+
38
+ /** The graph format — `EVIDENCE_GRAPH_FORMAT` in `@ao/types`, whose docblock says why a reader needs it. */
39
+ export const EVIDENCE_GRAPH_FORMAT = 2;
40
+
41
+ // ───────────────────────────── module edges ─────────────────────────────
42
+ //
43
+ // The twin of packages/analyzers/src/engines/module-edges.ts, whose docblock
44
+ // defines what each edge kind means, what an absent edge does NOT mean, and
45
+ // why this is a lexer and not a regex over comment-stripped text. Between the
46
+ // two markers below is that file with its type annotations DELETED by range
47
+ // — not a rewrite, not a port by hand — and `client-parity.test.ts`
48
+ // regenerates it from the TypeScript and compares it byte for byte. A change
49
+ // made here and not there fails that test before it reaches anyone's disk.
50
+
51
+ // BEGIN module-edges.ts, types deleted
52
+ /**
53
+ * A TypeScript declaration file by TypeScript's own rule (`isDeclarationFileName`):
54
+ * `.d.ts`, `.d.mts`, `.d.cts`, and the `allowArbitraryExtensions` kind
55
+ * (`button.d.css.ts`), case as written — nothing in it ever executes. A
56
+ * `.d.tsx` or `.D.TS` is a module the runner transforms and runs.
57
+ */
58
+ const DECLARATION_FILE = /\.d\.(?:[cm]?ts|[^./]+\.ts)$/;
59
+ const JSX_FILE = /\.(?:[cm]?jsx?|tsx)$/i;
60
+ const TYPESCRIPT_FILE = /\.[cm]?tsx?$/i;
61
+ const SCRIPT_FILE = /\.c?js$/i;
62
+
63
+ export function isDeclarationFile(path) {
64
+ return DECLARATION_FILE.test(path);
65
+ }
66
+
67
+ export function moduleEdgeOptions(path) {
68
+ return { declaration: isDeclarationFile(path), jsx: JSX_FILE.test(path), typescript: TYPESCRIPT_FILE.test(path), script: SCRIPT_FILE.test(path) };
69
+ }
70
+
71
+ /**
72
+ * Every module reference in a source text, deduplicated by (specifier, kind)
73
+ * in order of first appearance.
74
+ */
75
+ export function extractModuleEdges(content, opts = {}, report) {
76
+ if (report) report.doubtAt = -1;
77
+ if (!content) return [];
78
+ const { code, topLevel, doubtAt, exprStarts } = lexModule(content, !!opts.jsx, !!opts.typescript, !!opts.script);
79
+ if (report) report.doubtAt = doubtAt;
80
+ // `end`: where the specifier ends — what the doubt is measured against, so a
81
+ // statement whose keyword precedes the doubt and whose specifier follows it
82
+ // is on the doubtful side.
83
+ const found = [];
84
+ const dynamics = [];
85
+ const aliases = new Set();
86
+ /** Where each static `import`/`export … from` statement lies — its bindings name a runner without using it. */
87
+ const statements = [];
88
+ /** Runner names this file binds to something of its own (`import vi from "./vi.json"`). */
89
+ const ownNames = new Set();
90
+ /** Names a runner may have been imported under from a module that is not one (`import { vi as v } from "./test-utils"`). */
91
+ const mockAliases = new Set();
92
+
93
+ KEYWORD.lastIndex = 0;
94
+ for (let m = KEYWORD.exec(code); m; m = KEYWORD.exec(code)) {
95
+ const word = m[0];
96
+ const at = m.index;
97
+ const after = at + word.length;
98
+ if (!standsAlone(code, at, after)) continue;
99
+ if (word === "require") {
100
+ const open = skipSpace(code, after);
101
+ if (code[open] !== "(") continue;
102
+ const lit = readLiteral(content, code, skipSpace(code, open + 1), true);
103
+ if (!lit || code[skipSpace(code, lit.end)] !== ")") continue;
104
+ // `import x = require("x")` is a declaration: at the top of the module
105
+ // or not at all (inside `declare module` it is a type's business). The
106
+ // text before is read up to its last significant character, so no
107
+ // comment, however long, stands between the `=` and what it belongs to.
108
+ const equals = importEqualsBefore(code, at);
109
+ if (equals && !topLevel.has(equals.importAt)) continue;
110
+ found.push({ at, end: lit.end, specifier: lit.value, kind: equals?.typeOnly ? "type-only" : "require" });
111
+ // A runner module REQUIRED hands its objects over under names this file
112
+ // chooses (`const { jest: j } = require("@jest/globals")`).
113
+ if (RUNNER_MODULES.has(lit.value)) found.push({ at, specifier: "(a runner module by require)", kind: "mocked-unknown" });
114
+ continue;
115
+ }
116
+ const next = skipSpace(code, after);
117
+ if (word === "import" && code[next] === "(") {
118
+ const lit = readLiteral(content, code, skipSpace(code, next + 1), true);
119
+ if (!lit) continue;
120
+ const close = skipSpace(code, lit.end);
121
+ // `import("./x" + y)` names no module; `import("./x", { with: … })` does.
122
+ if (code[close] !== ")" && code[close] !== ",") continue;
123
+ const end = code[close] === ")" ? close : matchClose(code, next);
124
+ const member = end >= 0 ? memberAfter(code, end + 1) : null;
125
+ const dynKind = opts.typescript ? typescriptImportCall(contextBefore(code, at, exprStarts), member) : "dynamic";
126
+ dynamics.push({ at, end: lit.end, specifier: lit.value, kind: dynKind });
127
+ // A type (`typeof import("vitest")`) hands nothing over at run time.
128
+ if (RUNNER_MODULES.has(lit.value) && dynKind !== "type-only") found.push({ at, specifier: "(a runner module by import())", kind: "mocked-unknown" });
129
+ continue;
130
+ }
131
+ if (word === "import" && code[next] === ".") continue; // import.meta
132
+ // A declaration lives at the top of a module and nowhere else.
133
+ if (!topLevel.has(at)) continue;
134
+ const stmt = scanStatement(content, code, word, after);
135
+ if (!stmt) continue;
136
+ found.push({ at, end: stmt.end, specifier: stmt.specifier, kind: stmt.typeOnly ? "type-only" : stmt.deferred ? "uncertain" : "import" });
137
+ statements.push([at, stmt.end]);
138
+ const toks = stmt.tokens;
139
+ if (word === "export") {
140
+ // A re-export binds nothing here — and of a runner module, it hands the
141
+ // runner on to whoever imports this file.
142
+ if (RUNNER_MODULES.has(stmt.specifier) && !stmt.typeOnly) found.push({ at, specifier: "(a runner module handed on)", kind: "mocked-unknown" });
143
+ } else if (RUNNER_MODULES.has(stmt.specifier)) {
144
+ for (const a of runnerAliases(toks)) aliases.add(a);
145
+ // Its objects under a name we do not follow: `import * as vt`, a default
146
+ // import — except node:test's, which is the `test` function itself
147
+ // (its `mock.module` is read by the mock call above).
148
+ const def = /^[A-Za-z_$][\w$]*$/.test(toks[0] ?? "") && toks[0] !== "type" && toks[0] !== "typeof";
149
+ if (!stmt.typeOnly && (toks.includes("*") || (def && stmt.specifier !== "node:test"))) {
150
+ found.push({ at, specifier: "(a runner module under a name we do not follow)", kind: "mocked-unknown" });
151
+ }
152
+ } else {
153
+ for (let k = 0; k < toks.length; k++) {
154
+ const t = toks[k];
155
+ if (!RUNNER_OBJECTS.has(t)) continue;
156
+ // `vi as v` from any module: `v` may be the runner a helper handed on,
157
+ // so a mock call through it is a mock; the file binds `v`, not `vi`.
158
+ if (toks[k + 1] === "as") {
159
+ const local = toks[k + 2];
160
+ if (local && /^[A-Za-z_$][\w$]*$/.test(local)) mockAliases.add(local);
161
+ continue;
162
+ }
163
+ // …and a local binding of the name itself is this file's own.
164
+ ownNames.add(t);
165
+ }
166
+ }
167
+ }
168
+
169
+ // Mocks, before the dynamic imports are kept: `vi.mock(import("./x"))`
170
+ // holds a dynamic import that is not one.
171
+ const claimed = new Set();
172
+ /** Where each recorded mock call's receiver starts. */
173
+ const mockAt = new Set();
174
+ const calls = [MOCK_CALL];
175
+ const callNames = new Set([...aliases, ...mockAliases]);
176
+ if (callNames.size > 0) {
177
+ const names = [...callNames].map((a) => a.replace(/\$/g, "\\$")).join("|");
178
+ calls.push(new RegExp(`(?<![\\w$.])(?:${names})\\s*\\.\\s*(?:mock|doMock|setMock|unstable_mockModule|module)(?=\\s*[<(])`, "g"));
179
+ }
180
+ for (const rx of calls) {
181
+ rx.lastIndex = 0;
182
+ for (let m = rx.exec(code); m; m = rx.exec(code)) {
183
+ let p = skipSpace(code, m.index + m[0].length);
184
+ if (code[p] === "<") {
185
+ p = skipTypeArguments(code, p);
186
+ if (p < 0) {
187
+ // A mock call whose type arguments we cannot read past is still a mock call.
188
+ found.push({ at: m.index, specifier: "<type arguments>", kind: "mocked-unknown" });
189
+ mockAt.add(m.index);
190
+ continue;
191
+ }
192
+ p = skipSpace(code, p);
193
+ }
194
+ if (code[p] !== "(") continue;
195
+ const target = mockTarget(content, code, p + 1);
196
+ if (target.claimed != null) claimed.add(target.claimed);
197
+ found.push({ at: m.index, specifier: target.specifier, kind: target.kind });
198
+ mockAt.add(m.index);
199
+ }
200
+ }
201
+ AUTOMOCK.lastIndex = 0;
202
+ for (let m = AUTOMOCK.exec(code); m; m = AUTOMOCK.exec(code)) {
203
+ found.push({ at: m.index, specifier: "jest.enableAutomock", kind: "mocked-unknown" });
204
+ mockAt.add(m.index);
205
+ }
206
+
207
+ // A RUNNER OBJECT REACHED ANY OTHER WAY. A mock is recorded above only as a
208
+ // direct call on the runner's own name. The same method reached otherwise —
209
+ // `jest.mock?.(…)`, `(jest.mock)(…)`, `jest.mock.call(jest, …)`, `const m =
210
+ // jest.mock`, `jest[k](…)`, or the object itself handed on (`const j =
211
+ // jest`, `{ mock } = jest`, `helper(vi)`) — replaces a module just as well
212
+ // and would leave no edge, in a setup file or a helper whose text a reader
213
+ // never has (V7 review, 27.09). So every other reference to a runner object
214
+ // is a mock we cannot aim. What stays silent: a member that is not a mock
215
+ // method (`jest.fn`, `vi.spyOn`, `jest.Mock` as a type), a key (`{ vi: … }`,
216
+ // a locale table), and the import statement that names it.
217
+ //
218
+ // A NAME THE FILE BINDS ITSELF is not the runner — an import from any other
219
+ // module (medusa's locale table imports `vi` from `./vi.json`), a `const`,
220
+ // `let`, `var`, `function` or `class` of that name — and a parameter of that
221
+ // name is that parameter (`.map((v, vi) => …)`). Without that, one locale
222
+ // file, which no runner names as a test, would veto every file of the
223
+ // repository.
224
+ DECLARES_RUNNER_NAME.lastIndex = 0;
225
+ for (let m = DECLARES_RUNNER_NAME.exec(code); m; m = DECLARES_RUNNER_NAME.exec(code)) ownNames.add(m[1]);
226
+ const runnerNames = [...[...RUNNER_OBJECTS].filter((n) => !ownNames.has(n)), ...[...aliases].filter((a) => a !== "mock")].map((a) => a.replace(/\$/g, "\\$"));
227
+ const runnerRef = runnerNames.length ? new RegExp(`(?<![\\w$.])(?:${runnerNames.join("|")})(?![\\w$])`, "g") : null;
228
+ const reached = (at, len, bare) => {
229
+ if (statements.some(([a, b]) => at >= a && at < b)) return false;
230
+ if (mockAt.has(at)) return false;
231
+ if (bare && isParameter(code, at, len)) return false;
232
+ // A label a `break`/`continue` jumps to.
233
+ // On the same line only: across one, ASI ends the `break` and the name is a statement of its own.
234
+ if (bare && /(?<![\w$.])(?:break|continue)[ \t]+$/.test(code.slice(Math.max(0, at - 40), at))) return false;
235
+ // A name in a type — `namespace jest {`, `typeof vi`, an interface — runs nothing.
236
+ if (TYPE_BEFORE.test(code.slice(Math.max(0, at - 48), at))) return false;
237
+ const p = skipSpace(code, at + len);
238
+ // A name followed by `:` is a key (`{ vi: … }`, a locale table), a
239
+ // member or parameter annotated with a type (`vi: string`, `(vi: T)`,
240
+ // `vi?: T`) or a label — none of them the runner — except where it ENDS
241
+ // an expression the grammar reads as a value: a ternary's branch
242
+ // (`cond ? () => vi : x`) or a `case`.
243
+ const colon = code[p] === ":" ? p : code[p] === "?" && code[skipSpace(code, p + 1)] === ":" ? skipSpace(code, p + 1) : -1;
244
+ if (bare && colon >= 0 && code[colon + 1] !== ":" && !endsBranchOrCase(code, at)) return false;
245
+ if (code[p] === "." && code[p + 1] !== ".") {
246
+ const member = /^\s*([A-Za-z_$][\w$]*)/.exec(code.slice(p + 1, p + 200));
247
+ if (member && !MOCK_METHOD_NAMES.has(member[1])) return false;
248
+ }
249
+ return true;
250
+ };
251
+ for (let m = runnerRef?.exec(code); m && runnerRef; m = runnerRef.exec(code)) {
252
+ if (reached(m.index, m[0].length, true)) found.push({ at: m.index, specifier: "(a runner object reached another way)", kind: "mocked-unknown" });
253
+ }
254
+ // The global object under the names this file gives it (`const g =
255
+ // globalThis as unknown as Record<string, any>`): one step, a declaration
256
+ // whose whole value is the global object behind casts.
257
+ const globals = ["globalThis", "global", "window", "self"];
258
+ GLOBAL_ALIAS.lastIndex = 0;
259
+ for (let m = GLOBAL_ALIAS.exec(code); m; m = GLOBAL_ALIAS.exec(code)) {
260
+ if (/^(?:\s*\)|\s*!|\s+as\s+[^;\n=]*)*\s*(?:;|\n|$)/.test(code.slice(m.index + m[0].length))) globals.push(m[1]);
261
+ }
262
+ const globalAlt = `(?:${globals.map((g) => g.replace(/\$/g, "\\$")).join("|")}|import\\s*\\.\\s*meta)`;
263
+ const holdsGlobal = new RegExp(`(?<![\\w$.])${globalAlt}(?![\\w$])`);
264
+ // …and the same objects as members of the global object or `import.meta`
265
+ // (`const m = globalThis.vi`, `(globalThis as any).vi`, `import.meta.jest`, `g.vi`).
266
+ const globalRunner = new RegExp(`(?:(?<![\\w$.])${globalAlt}|\\))\\s*\\??\\.\\s*(jest|vi|vitest)(?![\\w$])`, "g");
267
+ for (let m = globalRunner.exec(code); m; m = globalRunner.exec(code)) {
268
+ const at = m.index + m[0].length - m[1].length;
269
+ if (reached(at, m[1].length, false)) found.push({ at, specifier: "(a runner object reached another way)", kind: "mocked-unknown" });
270
+ }
271
+ const inStatement = (at) => statements.some(([a, b]) => at >= a && at < b);
272
+ if (RUNNER_NAME.test(content)) {
273
+ // …taken apart from it (`const { vi: m } = globalThis`, `{ a, vi: { doMock } } =
274
+ // (globalThis as any)`): EVERY runner name in the pattern is looked at,
275
+ // and one that is code (not prose in a comment, not a string value) or a
276
+ // quoted key is the runner. The pattern and the global object behind it
277
+ // are read on the mask, where a comment between them is space.
278
+ const tail = new RegExp(`^\\s*(?::[^=;]*)?=(?![=>])\\s*(?:\\(\\s*)*(?:<[^>]*>\\s*)?(?:\\(\\s*)*${globalAlt}(?![\\w$])`);
279
+ BRACES.lastIndex = 0;
280
+ for (let m = BRACES.exec(code); m; m = BRACES.exec(code)) {
281
+ if (!tail.test(code.slice(m.index + m[0].length))) continue;
282
+ NAME_IN_PATTERN.lastIndex = 0;
283
+ const body = content.slice(m.index, m.index + m[0].length);
284
+ for (let n = NAME_IN_PATTERN.exec(body); n; n = NAME_IN_PATTERN.exec(body)) {
285
+ const at = m.index + n.index;
286
+ const isCode = n[1] ? code[at] === n[1] && /^\s*:/.test(code.slice(at + n[0].length)) : code.slice(at, at + n[0].length) === n[0];
287
+ if (isCode && !inStatement(at)) {
288
+ found.push({ at, specifier: "(a runner object reached another way)", kind: "mocked-unknown" });
289
+ break;
290
+ }
291
+ }
292
+ }
293
+ // …or read off it by a string key (`(globalThis as any)["vi"]`,
294
+ // `global!["jest"]`, `globalThis?.self["vi"]`) — an index only after an
295
+ // expression (after anything else it opens an array), and not in a type
296
+ // (`typeof globalThis["vi"]`).
297
+ RUNNER_BY_STRING_KEY.lastIndex = 0;
298
+ for (let m = RUNNER_BY_STRING_KEY.exec(content); m; m = RUNNER_BY_STRING_KEY.exec(content)) {
299
+ if (code[m.index] !== content[m.index] || !indexes(code, m.index)) continue;
300
+ const r = receiverOf(code, m.index);
301
+ if (!holdsGlobal.test(code.slice(r, m.index)) || /(?<![\w$.])typeof\s*$/.test(code.slice(Math.max(0, r - 12), r))) continue;
302
+ if (!inStatement(m.index)) found.push({ at: m.index, specifier: "(a runner object reached another way)", kind: "mocked-unknown" });
303
+ }
304
+ }
305
+ // An identifier spelled with a unicode escape (`\u006aest.mock`) is the
306
+ // runner to the runtime and nothing to this lexer.
307
+ if (/\\u(?:[0-9a-fA-F]{4}|\{)/.test(code)) found.push({ at: code.search(/\\u(?:[0-9a-fA-F]{4}|\{)/), specifier: "(an escaped identifier)", kind: "mocked-unknown" });
308
+ for (const d of dynamics) {
309
+ if (!claimed.has(d.at)) found.push({ at: d.at, end: d.end, specifier: d.specifier, kind: d.kind });
310
+ }
311
+ // A module-replacing library replaces modules we cannot name from here.
312
+ for (const f of [...found]) {
313
+ if (f.kind === "type-only" || f.kind === "mocked" || f.kind === "mocked-unknown") continue;
314
+ const root = packageRoot(f.specifier);
315
+ if (MODULE_REPLACERS.has(root)) found.push({ at: f.at, specifier: root, kind: "mocked-unknown" });
316
+ }
317
+
318
+ // Past the lexer's first consequential guess a mock call may sit in text the
319
+ // guess re-paired into a string, and then no mock edge above was published
320
+ // for it. A reader must not read a doubted file as mock-free, so the file
321
+ // says what it cannot rule out: when the raw text past the doubt mentions a
322
+ // mock at all, a `mocked-unknown` — a veto on every witness through this
323
+ // file (V7 review, 27.09: a setup file doubted at `of / t` hid its
324
+ // `jest.mock`). Measured on the doubted files of videlic and medusa: only
325
+ // the two vendored `.yarn` bundles mention one, and those are not graph
326
+ // sources.
327
+ if (doubtAt >= 0 && DOUBT_MENTIONS_MOCK.test(content.slice(doubtAt))) found.push({ at: doubtAt, specifier: "(text past the doubt)", kind: "mocked-unknown" });
328
+
329
+ found.sort((a, b) => a.at - b.at);
330
+ // Past the lexer's first consequential guess, no load is a load: it may be
331
+ // one, it may be text the guess exposed. It is published as `uncertain` —
332
+ // no witness stands on it, and no claim that a module did NOT run may
333
+ // ignore it. A veto or a type costs no witness it should not, and stays.
334
+ if (doubtAt >= 0) for (const f of found) if ((f.end ?? f.at) >= doubtAt && LOADS.has(f.kind)) f.kind = "uncertain";
335
+ const seen = new Set();
336
+ const out = [];
337
+ for (const f of found) {
338
+ const kind = opts.declaration && (LOADS.has(f.kind) || f.kind === "uncertain") ? "type-only" : f.kind;
339
+ if (!f.specifier) continue;
340
+ // Nothing in a declaration file runs, a mock included.
341
+ if (opts.declaration && (kind === "mocked" || kind === "mocked-unknown")) continue;
342
+ const key = `${kind}\0${f.specifier}`;
343
+ if (seen.has(key)) continue;
344
+ seen.add(key);
345
+ out.push({ specifier: f.specifier, kind });
346
+ }
347
+ return out;
348
+ }
349
+
350
+ /**
351
+ * Where the lexer first had to guess in a way that decides what is code —
352
+ * the offset past which a file publishes no load — or `-1`. For measuring the
353
+ * cost of that rule, and for a reader that wants to say why an edge is absent.
354
+ */
355
+ export function moduleDoubt(content, opts = {}) {
356
+ return content ? lexModule(content, !!opts.jsx, !!opts.typescript, !!opts.script).doubtAt : -1;
357
+ }
358
+
359
+ /** The mask alone — what the scanners read. */
360
+ export function maskModuleSource(src, opts = {}) {
361
+ return lexModule(src, !!opts.jsx, !!opts.typescript, !!opts.script).code;
362
+ }
363
+
364
+ // None of these can match inside a string or a comment: the mask has neither.
365
+ // Word boundaries are checked by hand (`standsAlone`) because a regex's `\w`
366
+ // is ASCII and an identifier is not.
367
+ const KEYWORD = /import|export|require/g;
368
+ const MOCK_CALL =
369
+ /(?<![\w$])(?:(?:vi|jest|vitest|sb)\s*\.\s*(?:mock|doMock|setMock|unstable_mockModule)|mock\s*\.\s*module)(?=\s*[<(])/g;
370
+ /** Every module mocked at once, with no call per module to see. */
371
+ const AUTOMOCK = /(?<![\w$])jest\s*\.\s*(?:enableAutomock|autoMockOn)\s*\(/g;
372
+ /** A runner object's methods that replace a module. */
373
+ const MOCK_METHOD_NAMES = new Set(["mock", "doMock", "setMock", "unstable_mockModule", "enableAutomock", "autoMockOn", "module"]);
374
+ /** The runner objects a test file sees as globals. */
375
+ const RUNNER_OBJECTS = new Set(["jest", "vi", "vitest"]);
376
+ const DECLARES_RUNNER_NAME = /(?<![\w$.])(?:const|let|var|function|class)\s+(jest|vi|vitest)(?![\w$])/g;
377
+ /** A runner object as a member of the global object, `import.meta`, or a parenthesised value. */
378
+ /**
379
+ * Words after which a name is a type's, not a value's. A declaration keyword
380
+ * takes its name on the same line — `let type\nvi.mock(…)` is `type;` and then
381
+ * the runner — while `typeof` reads across one.
382
+ */
383
+ const TYPE_BEFORE = /(?:(?<![\w$.])(?:(?:namespace|module|interface|type|enum|declare)[ \t]+|typeof\s+))$/;
384
+ const RUNNER_NAME = /(?<![\w$])(?:jest|vi|vitest)(?![\w$])/;
385
+ /** A `{ … }` with at most one level inside: a destructuring pattern, or any block. */
386
+ const BRACES = /\{(?:[^{}]|\{[^{}]*\})*\}/g;
387
+ /** A runner name standing where a pattern's key or shorthand stands; group 1 its quote. */
388
+ const NAME_IN_PATTERN = /(?<![\w$.])(["'`]?)(?:jest|vi|vitest)\1(?=\s*[:,}])/g;
389
+ const RUNNER_BY_STRING_KEY = /\[\s*(["'`])(?:jest|vi|vitest)\1\s*\]/g;
390
+ /** `const g = globalThis` (behind parentheses and casts); what follows is checked separately. */
391
+ const GLOBAL_ALIAS = /(?<![\w$.])(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;\n]*)?=\s*(?:\(\s*)*(?:<[^>]*>\s*)?(?:\(\s*)*(?:globalThis|global|window|self)(?![\w$])/g;
392
+
393
+ /**
394
+ * Is the name at `at` a parameter being declared — `vi => …`, `(v, vi) =>`,
395
+ * `function f(vi) {` — rather than the runner used? A simple parameter list
396
+ * (names and commas) closed by `)` and followed by `=>` or a body.
397
+ */
398
+ function isParameter(code, at, len) {
399
+ const after = skipSpace(code, at + len);
400
+ if (code.startsWith("=>", after)) return true;
401
+ let b = at - 1;
402
+ while (b >= 0 && /[\s\w$,]/.test(code[b])) b--;
403
+ if (code[b] !== "(") return false;
404
+ // `with (jest) {`, `if (vi) {`: a value in parentheses, not a parameter list.
405
+ if (/(?<![\w$.])(?:with|if|while|switch|for)\s*$/.test(code.slice(Math.max(0, b - 12), b))) return false;
406
+ let f = at + len;
407
+ while (f < code.length && /[\s\w$,]/.test(code[f])) f++;
408
+ if (code[f] !== ")") return false;
409
+ const next = skipSpace(code, f + 1);
410
+ return code.startsWith("=>", next) || code[next] === "{";
411
+ }
412
+ /** `typeof ` immediately before an `import(`. */
413
+ const TYPEOF_BEFORE = /(?<![\w$.])typeof$/;
414
+ /**
415
+ * Other places only a TYPE can stand, immediately before an `import(`: the
416
+ * right side of a type alias (`type M = import("./x")`), after a type
417
+ * operator (`keyof`, `as`, `satisfies`, `extends`, `infer`, `is`,
418
+ * `readonly`, `unique`), a type argument, a union or intersection member (`<`,
419
+ * `|`, `&` — at runtime a promise compared or or-ed with something is not code
420
+ * anyone writes; `&&` and `||` are).
421
+ */
422
+ const TYPE_POSITION_BEFORE =
423
+ /(?:(?<![\w$.])type\s+[A-Za-z_$][\w$]*\s*(?:<[^;]*>)?\s*=|(?<![\w$.])(?:keyof|as|satisfies|extends|infer|is|readonly|unique)|<|(?<![|&])[|&])\s*$/;
424
+ /** Where an `import(…)` can only be a value, whatever lies between: awaited, voided, or the start of a statement. */
425
+ const VALUE_BEFORE = /(?:(?<![\w$.])(?:await|void)|[;{}])$/;
426
+ /** …and where only on the same line: a line end after `return` is a return of nothing, and the `import()` below it never runs. */
427
+ const SAME_LINE_VALUE_BEFORE = /(?<![\w$.])(?:return|yield|throw)$/;
428
+ /** The kinds that say a module RAN. */
429
+ const LOADS = new Set(["import", "require", "dynamic"]);
430
+
431
+ /**
432
+ * The code before `at` up to and including its last significant character,
433
+ * whether a line end lies between that character and `at`, and whether there
434
+ * is anything significant before `at` at all. Read up to 64 KB back, so a long
435
+ * comment or a blanked string cannot erase what precedes it — a fixed window
436
+ * over the mask did, and a type after a long comment read as a load.
437
+ */
438
+ function contextBefore(
439
+ code,
440
+ at,
441
+ exprStarts,
442
+ ) {
443
+ let k = at - 1;
444
+ const stop = Math.max(0, at - 65_536);
445
+ // Stepping back over the blank where a `${` or a JSX `{` opened: what is
446
+ // before that blank is not before this expression.
447
+ const opened = () => exprStarts?.get(k + 1);
448
+ while (k >= stop && isSpace(code[k]) && !opened()) k--;
449
+ const expr = opened();
450
+ const lineEnd = /[\n\r\u2028\u2029]/.test(code.slice(Math.max(0, k + 1), at));
451
+ if (expr) return { before: "", from: at, lineEnd, atStart: false, expr };
452
+ if (k < stop) return { before: "", from: at, lineEnd, atStart: k < 0 };
453
+ const from = Math.max(0, k - 199);
454
+ return { before: code.slice(from, k + 1), from, lineEnd, atStart: false };
455
+ }
456
+
457
+ /**
458
+ * `import x = ` or `import type x = ` ending at the `=` that stands last
459
+ * before `at` — read back over any white space and blanked comment, however
460
+ * long — or `null`.
461
+ */
462
+ function importEqualsBefore(code, at) {
463
+ const stop = Math.max(0, at - 65_536);
464
+ let k = at - 1;
465
+ const back = () => {
466
+ while (k >= stop && isSpace(code[k])) k--;
467
+ };
468
+ const word = () => {
469
+ const end = k + 1;
470
+ while (k >= stop && isIdentChar(code[k])) k--;
471
+ return code.slice(k + 1, end);
472
+ };
473
+ back();
474
+ if (code[k] !== "=") return null;
475
+ k--;
476
+ back();
477
+ if (!word()) return null;
478
+ back();
479
+ let w = word();
480
+ let typeOnly = false;
481
+ if (w === "type") {
482
+ typeOnly = true;
483
+ back();
484
+ w = word();
485
+ }
486
+ if (w !== "import" || isIdentChar(code[k]) || code[k] === ".") return null;
487
+ return { importAt: k + 1, typeOnly };
488
+ }
489
+
490
+ /**
491
+ * What a TypeScript `import(…)` is, from the code before it and the member
492
+ * read off it.
493
+ *
494
+ * In TypeScript an `import(…)` is a TYPE in more places than any list of them
495
+ * — `Record<K, import("./x")>`, `[import("./x")]`, a generic default, a
496
+ * function type's return, `import("./x").then` as a type's qualified name —
497
+ * and a VALUE in others that look the same (`{ load: import("./x") }`,
498
+ * `() => import("./x")`). So: a type where a type is certain (after `typeof`,
499
+ * a type operator, a type alias's `=`, `<`, `|`, `&`, or with a member no
500
+ * Promise has); a load where a value is certain (awaited, voided; returned,
501
+ * yielded or thrown on the same line; a statement of its own; a Promise
502
+ * member CALLED — `.then(…)`, since nothing in a type is called); and
503
+ * `uncertain` everywhere else — which is not "never runs", and must not be
504
+ * read as it.
505
+ */
506
+ function typescriptImportCall(
507
+ ctx,
508
+ member,
509
+ ) {
510
+ if (member != null && !PROMISE_MEMBERS.has(member.name)) return "type-only";
511
+ // Right inside a `${`: a template literal's value, or a template literal
512
+ // TYPE's — the mask cannot say which. Right inside a JSX `{`: a value.
513
+ if (ctx.expr === "tpl") return member?.call ? "dynamic" : "uncertain";
514
+ if (ctx.expr === "jsx") return "dynamic";
515
+ if (TYPEOF_BEFORE.test(ctx.before) || TYPE_POSITION_BEFORE.test(ctx.before)) return "type-only";
516
+ if (member != null && member.call) return "dynamic";
517
+ if (ctx.atStart || VALUE_BEFORE.test(ctx.before) || (!ctx.lineEnd && SAME_LINE_VALUE_BEFORE.test(ctx.before))) return "dynamic";
518
+ if (ctx.lineEnd && startsStatementAfterLineEnd(ctx.before)) return "dynamic";
519
+ return "uncertain";
520
+ }
521
+
522
+ /**
523
+ * A line end between the end of a value and `import(`: nothing continues a
524
+ * value into `import(…)`, so a new statement starts there (`… = Prism` then
525
+ * `import("prismjs/…")` on the next line, in medusa). Not after a backtick —
526
+ * in the mask it may be a template's OPENING — and not after a keyword that
527
+ * expects more (`typeof`, `in`) or ends the flow (`return` above an
528
+ * `import()` that never runs).
529
+ */
530
+ function startsStatementAfterLineEnd(before) {
531
+ const last = before[before.length - 1];
532
+ if (last === undefined || !/[\w$)\]'"]/.test(last)) return false;
533
+ if (!/[\w$]/.test(last)) return true;
534
+ const word = /([A-Za-z_$][\w$]*)$/.exec(before)?.[1];
535
+ return word === undefined || !CONTINUING_WORDS.has(word);
536
+ }
537
+ const CONTINUING_WORDS = new Set([
538
+ "return", "yield", "typeof", "in", "of", "instanceof", "new", "delete", "void", "throw", "case", "await", "default",
539
+ "extends", "keyof", "as", "satisfies", "infer", "is", "readonly", "unique", "else", "do",
540
+ ]);
541
+ /** Members of a Promise, which is what `import(…)` is in code. */
542
+ const PROMISE_MEMBERS = new Set(["then", "catch", "finally"]);
543
+ /** The modules a mock-bearing object is imported from, under a name of the test's choosing. */
544
+ const RUNNER_MODULES = new Set(["vitest", "@jest/globals", "node:test", "bun:test"]);
545
+ const RUNNER_MOCKERS = new Set(["vi", "vitest", "jest", "mock"]);
546
+ /** Libraries that replace modules by a mechanism of their own. */
547
+ const MODULE_REPLACERS = new Set(["proxyquire", "esmock", "testdouble", "mock-require", "rewiremock", "quibble", "mockery", "rewire"]);
548
+ /** Raw text that could be a mock call or a module replacer — what a doubted file cannot rule out. */
549
+ const DOUBT_MENTIONS_MOCK = new RegExp(`mock|${[...MODULE_REPLACERS].join("|")}`, "i");
550
+
551
+ function isSpace(c) {
552
+ if (c === undefined) return false;
553
+ if (c === " " || c === "\n" || c === "\t" || c === "\r" || c === "\f" || c === "\v") return true;
554
+ return c.charCodeAt(0) >= 128 && /\s/.test(c);
555
+ }
556
+
557
+ /**
558
+ * Where the expression that `[` at `at` indexes begins: back over `!`, `?.`,
559
+ * a parenthesised group (a cast: `(globalThis as Record<string, unknown>)`)
560
+ * and a dotted name.
561
+ */
562
+ function receiverOf(code, at) {
563
+ let q = at - 1;
564
+ for (;;) {
565
+ while (q >= 0 && /\s/.test(code[q])) q--;
566
+ if (code[q] === "!" || (code[q] === "." && code[q - 1] === "?")) {
567
+ q -= code[q] === "!" ? 1 : 2;
568
+ continue;
569
+ }
570
+ if (code[q] === "?" && code[q + 1] === ".") {
571
+ q--;
572
+ continue;
573
+ }
574
+ if (code[q] === ")" || code[q] === "]") {
575
+ const close = code[q];
576
+ const open = close === ")" ? "(" : "[";
577
+ let depth = 0;
578
+ for (; q >= 0; q--) {
579
+ if (code[q] === close) depth++;
580
+ else if (code[q] === open && --depth === 0) break;
581
+ }
582
+ q--;
583
+ continue;
584
+ }
585
+ if (q >= 0 && /[\w$.]/.test(code[q])) {
586
+ while (q >= 0 && /[\w$.]/.test(code[q])) q--;
587
+ // A name ends the walk unless it is a member of what stands before it
588
+ // (`f().vi`): `typeof globalThis` is two words, not one receiver.
589
+ if (code[q + 1] === ".") continue;
590
+ return q + 1;
591
+ }
592
+ return q + 1;
593
+ }
594
+ }
595
+
596
+ /** Is the `[` at `at` an index into the expression before it, rather than an array? */
597
+ function indexes(code, at) {
598
+ let q = at - 1;
599
+ while (q >= 0 && /\s/.test(code[q])) q--;
600
+ const ch = code[q];
601
+ if (ch === ")" || ch === "]" || ch === "." || ch === "}" || ch === "!") return true;
602
+ if (ch == null || !/[\w$]/.test(ch)) return false;
603
+ let w = q;
604
+ while (w > 0 && /[\w$]/.test(code[w - 1])) w--;
605
+ return !/^(?:return|typeof|in|of|case|yield|await|void|delete|throw|new|else|do|instanceof)$/.test(code.slice(w, q + 1));
606
+ }
607
+
608
+ /**
609
+ * Does the name at `at` end an expression that is a ternary's branch or a
610
+ * `case`'s value? Walked back at the name's own bracket depth: a `?` that
611
+ * opens a conditional (not `?.`, `??`, or an optional member's `?:`) or the
612
+ * word `case` says yes; `{`, `(`, `[` opening the name's depth, `,` or `;`
613
+ * say it stands where a key, a parameter, a member or a label stands.
614
+ */
615
+ function endsBranchOrCase(code, at) {
616
+ let depth = 0;
617
+ for (let q = at - 1, steps = 0; ; q--, steps++) {
618
+ // The file's start is a statement's: a label. A walk too long to finish
619
+ // says yes — the safe side.
620
+ if (q < 0) return false;
621
+ if (steps >= 8_000) return true;
622
+ const ch = code[q];
623
+ if (ch === ")" || ch === "]" || ch === "}") {
624
+ depth++;
625
+ continue;
626
+ }
627
+ if (ch === "(" || ch === "[" || ch === "{") {
628
+ if (depth === 0) return false;
629
+ depth--;
630
+ continue;
631
+ }
632
+ if (depth > 0) continue;
633
+ if (ch === "," || ch === ";") return false;
634
+ // A line end that ended a statement or a member (semicolon-free code): the
635
+ // line before ends in a name, a literal or a closing bracket, and the one
636
+ // after does not open with an operator that would continue it.
637
+ if (ch === "\n") {
638
+ const next = code[skipSpace(code, q + 1)] ?? "";
639
+ let pq = q - 1;
640
+ while (pq >= 0 && /\s/.test(code[pq])) pq--;
641
+ const prev = code[pq] ?? "";
642
+ // A word ending the line that is an operator (`a instanceof`, `x in`,
643
+ // `await`) continues it, as does a line opening with one; a `>` that
644
+ // closes a type's `<` on its own line ends it (`Promise<Remote>`).
645
+ const lineStart = code.lastIndexOf("\n", pq) + 1;
646
+ const lastWord = /[\w$]+$/.exec(code.slice(lineStart, pq + 1))?.[0] ?? "";
647
+ const operatorWord = /^(?:instanceof|in|of|await|typeof|void|new|delete|as|satisfies|keyof|yield|return|throw|case|extends)$/.test(lastWord);
648
+ const opensWithOperator = /^(?:in|instanceof|as|satisfies)\b/.test(code.slice(skipSpace(code, q + 1)));
649
+ const closesType = prev === ">" && code[pq - 1] !== "=" && code.slice(lineStart, pq).includes("<");
650
+ if (!/[?:+\-*/%&|^.,=<>!([`]/.test(next) && !opensWithOperator && ((/[\w$)\]}"'`]/.test(prev) && !operatorWord) || closesType)) return false;
651
+ continue;
652
+ }
653
+ if (ch === "?") {
654
+ if (code[q + 1] === "." || code[q + 1] === "?" || code[q - 1] === "?") continue;
655
+ if (code[skipSpace(code, q + 1)] === ":") continue;
656
+ return true;
657
+ }
658
+ if (ch === "e" && code.slice(q - 3, q + 1) === "case" && !/[\w$]/.test(code[q - 4] ?? "") && !/[\w$]/.test(code[q + 1] ?? "")) return true;
659
+ }
660
+ }
661
+
662
+ function skipSpace(code, p) {
663
+ while (p < code.length && isSpace(code[p])) p++;
664
+ return p;
665
+ }
666
+
667
+ /** A character that can continue an identifier. Anything outside ASCII that is not a space counts, which only ever makes a name longer. */
668
+ function isIdentChar(c) {
669
+ if (c === undefined) return false;
670
+ const k = c.charCodeAt(0);
671
+ if ((k >= 48 && k <= 57) || (k >= 65 && k <= 90) || (k >= 97 && k <= 122) || k === 36 || k === 95) return true;
672
+ return k >= 128 && !/\s/.test(c);
673
+ }
674
+
675
+ function startsWord(code, p, word) {
676
+ return code.startsWith(word, p) && !isIdentChar(code[p + word.length]);
677
+ }
678
+
679
+ /**
680
+ * A keyword that is a keyword: not the tail of a longer name (`reimport`,
681
+ * `λrequire`), not a private name (`this.#import`), not a property
682
+ * (`obj.require(…)`, `obj . import(…)`, `obj./*c*\/import(…)` — the mask has
683
+ * blanked the comment). A spread (`...require("x")`) is a call.
684
+ */
685
+ function standsAlone(code, at, after) {
686
+ if (isIdentChar(code[after])) return false;
687
+ const b = code[at - 1];
688
+ if (b !== undefined && (isIdentChar(b) || b === "#")) return false;
689
+ let k = at - 1;
690
+ while (k >= 0 && isSpace(code[k])) k--;
691
+ // One dot is a property; three are a spread; `1..import` is a number's
692
+ // property, and two dots are that.
693
+ return !(code[k] === "." && !(code[k - 1] === "." && code[k - 2] === "."));
694
+ }
695
+
696
+ /**
697
+ * The `.Name` right after `import(…)`, and whether it is called, or `null`. A
698
+ * Promise member CALLED (`.then(…)`) is the load being used; any other name is
699
+ * a type position — at runtime the property of a Promise named `Name` is
700
+ * `undefined`, and in a type `import("./x").then` is a qualified name.
701
+ */
702
+ function memberAfter(code, p) {
703
+ p = skipSpace(code, p);
704
+ if (code[p] !== ".") return null;
705
+ p = skipSpace(code, p + 1);
706
+ let q = p;
707
+ while (q < code.length && isIdentChar(code[q])) q++;
708
+ // Called on the same line: across a line end, `(` may start the next statement.
709
+ const open = skipSpace(code, q);
710
+ return q > p ? { name: code.slice(p, q), call: code[open] === "(" && !/[\n\r\u2028\u2029]/.test(code.slice(q, open)) } : null;
711
+ }
712
+
713
+ /** The `)` that closes the `(` at `open`, over the mask; `-1` when there is none close by. */
714
+ function matchClose(code, open) {
715
+ let depth = 0;
716
+ const stop = Math.min(code.length, open + 4_000);
717
+ for (let j = open; j < stop; j++) {
718
+ const c = code[j];
719
+ if (c === "(" || c === "[" || c === "{") depth++;
720
+ else if (c === ")" || c === "]" || c === "}") {
721
+ depth--;
722
+ if (depth === 0) return c === ")" ? j : -1;
723
+ if (depth < 0) return -1;
724
+ }
725
+ }
726
+ return -1;
727
+ }
728
+
729
+ /** Past `<…>` type arguments at `p` — `vi.mock<typeof import("./x")>(…)` — or `-1`. An arrow's `=>` inside does not close them. */
730
+ function skipTypeArguments(code, p) {
731
+ let depth = 0;
732
+ const stop = Math.min(code.length, p + 2_000);
733
+ for (let j = p; j < stop; j++) {
734
+ const c = code[j];
735
+ if (c === "<") depth++;
736
+ else if (c === ">" && code[j - 1] !== "=") {
737
+ depth--;
738
+ if (depth === 0) return j + 1;
739
+ }
740
+ }
741
+ return -1;
742
+ }
743
+
744
+ /**
745
+ * The literal whose opening delimiter sits at `p` in the mask, read back from
746
+ * the source. `null` when there is none, when it is a template with a `${…}`
747
+ * in it (that names no module), or when templates are not allowed there —
748
+ * a static `import … from` takes a quoted string and nothing else.
749
+ */
750
+ function readLiteral(src, code, p, templates) {
751
+ const q = code[p];
752
+ if (q !== '"' && q !== "'" && !(templates && q === "`")) return null;
753
+ for (let j = p + 1; j < src.length; j++) {
754
+ const c = src[j];
755
+ if (c === "\\") {
756
+ j += src[j + 1] === "\r" && src[j + 2] === "\n" ? 2 : 1;
757
+ continue;
758
+ }
759
+ if (q === "`" && c === "$" && src[j + 1] === "{") return null;
760
+ if (c === q) return { value: src.slice(p + 1, j), end: j + 1 };
761
+ if (c === "\n" && q !== "`") return null;
762
+ }
763
+ return null;
764
+ }
765
+
766
+ /** Is the next thing after `q` the end of an argument? */
767
+ function argumentEnds(code, q) {
768
+ const c = code[skipSpace(code, q)];
769
+ return c === ")" || c === ",";
770
+ }
771
+
772
+ /**
773
+ * What a mock call replaces, from just inside its `(`: a literal, an
774
+ * `import("x")`, a `require.resolve("x")` — or, for anything else, the
775
+ * argument's own text under `mocked-unknown`, because a mock we cannot aim is
776
+ * still a mock.
777
+ */
778
+ function mockTarget(src, code, p) {
779
+ p = skipSpace(code, p);
780
+ const lit = readLiteral(src, code, p, true);
781
+ if (lit && argumentEnds(code, lit.end)) return { specifier: lit.value, kind: "mocked" };
782
+ if (startsWord(code, p, "import")) {
783
+ const open = skipSpace(code, p + "import".length);
784
+ if (code[open] === "(") {
785
+ const l = readLiteral(src, code, skipSpace(code, open + 1), true);
786
+ if (l) {
787
+ const close = skipSpace(code, l.end);
788
+ if (code[close] === ")" && argumentEnds(code, close + 1)) return { specifier: l.value, kind: "mocked", claimed: p };
789
+ }
790
+ }
791
+ }
792
+ const resolve = /^require\s*\.\s*resolve\s*\(/.exec(code.slice(p, p + 40));
793
+ if (resolve) {
794
+ const l = readLiteral(src, code, skipSpace(code, p + resolve[0].length), true);
795
+ if (l) {
796
+ const close = skipSpace(code, l.end);
797
+ if (code[close] === ")" && argumentEnds(code, close + 1)) return { specifier: l.value, kind: "mocked" };
798
+ }
799
+ }
800
+ return { specifier: argumentText(code, p), kind: "mocked-unknown" };
801
+ }
802
+
803
+ /**
804
+ * The argument starting at `p`, up to its `,` or `)`, whitespace collapsed,
805
+ * at most 120 characters — read from the MASK, so a string's body or a
806
+ * comment (where a credential would be) never travels, only the code's shape:
807
+ * `modPath`, `" " + name`.
808
+ */
809
+ function argumentText(code, p) {
810
+ let depth = 0;
811
+ let j = p;
812
+ const stop = Math.min(code.length, p + 500);
813
+ for (; j < stop; j++) {
814
+ const c = code[j];
815
+ if (c === "(" || c === "[" || c === "{") depth++;
816
+ else if (c === ")" || c === "]" || c === "}") {
817
+ if (depth === 0) break;
818
+ depth--;
819
+ } else if (c === "," && depth === 0) break;
820
+ }
821
+ const text = code.slice(p, j).replace(/\s+/g, " ").trim().slice(0, 120);
822
+ return text || "?";
823
+ }
824
+
825
+ /** `@scope/name/sub` → `@scope/name`; `name/sub` → `name`. */
826
+ function packageRoot(specifier) {
827
+ const parts = specifier.split("/");
828
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
829
+ }
830
+
831
+ /** `import { vi as v, mock as m } from "vitest"` → `v`, `m`. */
832
+ function runnerAliases(tokens) {
833
+ const out = [];
834
+ for (let k = 0; k + 2 < tokens.length; k++) {
835
+ if (RUNNER_MOCKERS.has(tokens[k]) && tokens[k + 1] === "as" && /^[A-Za-z_$][\w$]*$/.test(tokens[k + 2])) out.push(tokens[k + 2]);
836
+ }
837
+ return out;
838
+ }
839
+
840
+ /**
841
+ * A static `import … from "x"`, `import "x"` or `export … from "x"` starting
842
+ * right after its keyword — or `null` when what follows is not one.
843
+ *
844
+ * Only the grammar of import bindings may stand between the keyword and
845
+ * `from`. Anything else — `=`, `(`, `:`, `;`, another `import` or `export` —
846
+ * ends the attempt, so a declaration (`export const`, `export function`,
847
+ * `export type X = …`) is never paired with a clause further down. That is
848
+ * what the keyword-anchored regex of #992 got wrong in code without
849
+ * semicolons: `export const C = f({…})\nexport type { P } from "./x"` gave
850
+ * `./x` the kind of the first statement.
851
+ */
852
+ /**
853
+ * What an `export` starting with these can never be: a re-export `from` a
854
+ * module. Only for `export`: `import async from "async"` is a default binding
855
+ * that happens to be spelled like a keyword.
856
+ */
857
+ const NOT_A_CLAUSE = new Set(["default", "enum", "class", "const", "let", "var", "function", "async", "abstract", "declare", "interface", "namespace", "module", "global"]);
858
+
859
+ function scanStatement(
860
+ src,
861
+ code,
862
+ word,
863
+ p,
864
+ ) {
865
+ const tokens = [];
866
+ let braces = 0;
867
+ for (;;) {
868
+ p = skipSpace(code, p);
869
+ if (p >= code.length) return null;
870
+ const c = code[p];
871
+ if (c === '"' || c === "'") {
872
+ const lit = readLiteral(src, code, p, false);
873
+ if (!lit) return null;
874
+ // `import "./register"` — a module loaded for its effects.
875
+ if (word === "import" && tokens.length === 0) return { specifier: lit.value, end: lit.end, typeOnly: false, deferred: false, tokens };
876
+ // `import { "a-b" as ab } from "./x"` — a quoted name, inside braces only.
877
+ if (braces === 0) return null;
878
+ tokens.push('"');
879
+ p = lit.end;
880
+ continue;
881
+ }
882
+ if (isIdentChar(c)) {
883
+ let q = p;
884
+ while (q < code.length && isIdentChar(code[q])) q++;
885
+ const name = code.slice(p, q);
886
+ if (name === "import" || name === "export") return null;
887
+ if (name === "from" && braces === 0 && !(word === "export" && NOT_A_CLAUSE.has(tokens[0] ?? ""))) {
888
+ const at = skipSpace(code, q);
889
+ if (code[at] === '"' || code[at] === "'") {
890
+ const lit = readLiteral(src, code, at, false);
891
+ // `import defer * as ns` evaluates the module on first use — maybe never.
892
+ const deferred = tokens[0] === "defer" && tokens[1] === "*";
893
+ return lit ? { specifier: lit.value, end: lit.end, typeOnly: bindingsAreTypeOnly(tokens), deferred, tokens } : null;
894
+ }
895
+ }
896
+ tokens.push(name);
897
+ p = q;
898
+ continue;
899
+ }
900
+ if (c === "{") braces++;
901
+ else if (c === "}") {
902
+ if (--braces < 0) return null;
903
+ } else if (c !== "," && c !== "*") return null;
904
+ tokens.push(c);
905
+ p++;
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Does this statement leave the module unrun, by its syntax alone?
911
+ *
912
+ * `import type X`, `import type { … }`, `import type * as ns`, `export type
913
+ * { … }`, `export type *`, Flow's `import typeof X` do; so does a source-phase
914
+ * `import source x`, which fetches the module and never evaluates it.
915
+ * `import type from "./x"` does NOT — that is a default binding named `type` —
916
+ * and neither does `import type, { a }`. Braces with nothing outside them are
917
+ * type-only when EVERY binding carries `type`; a single value binding, a
918
+ * default or a namespace loads the module. `{ type as foo }` is a value
919
+ * binding named `type`, renamed.
920
+ */
921
+ function bindingsAreTypeOnly(tokens) {
922
+ if ((tokens[0] === "type" || tokens[0] === "typeof") && tokens.length >= 2 && tokens[1] !== ",") return true;
923
+ if (tokens[0] === "source" && tokens.length === 2 && tokens[1] !== "," && tokens[1] !== "{" && tokens[1] !== "*") return true;
924
+ if (tokens[0] !== "{" || tokens[tokens.length - 1] !== "}") return false;
925
+ const groups = [[]];
926
+ for (const t of tokens.slice(1, -1)) {
927
+ if (t === "{" || t === "}") return false;
928
+ if (t === ",") groups.push([]);
929
+ else groups[groups.length - 1].push(t);
930
+ }
931
+ const named = groups.filter((g) => g.length > 0);
932
+ return named.length > 0 && named.every((g) => (g[0] === "type" || g[0] === "typeof") && g.length >= 2 && !(g.length === 3 && g[1] === "as"));
933
+ }
934
+
935
+ // ───────────────────────────────── the mask ─────────────────────────────────
936
+
937
+ /** Keywords after which a `/` starts a regex, and a `<` a JSX element. */
938
+ const EXPRESSION_AFTER_WORD = new Set([
939
+ "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw",
940
+ "case", "do", "else", "yield", "await", "default", "break", "continue", "debugger",
941
+ ]);
942
+ /**
943
+ * Of those, the ones that can also be an identifier — `of` in `for (const of
944
+ * of list)`, `await` and `yield` outside async functions and generators — so
945
+ * a `/` after them is a guess like one after `)`.
946
+ */
947
+ const AMBIGUOUS_WORDS = new Set(["of", "await", "yield"]);
948
+ /** Keywords whose `( … )` is a condition, after which a `/` starts a regex: `if (x) /re/.test(y)`. */
949
+ const CONDITION_WORDS = new Set(["if", "while", "for", "with"]);
950
+
951
+ /** Spaces for everything but line terminators, which keep their place: ASI and "same line" read them. */
952
+ function blank(s) {
953
+ return /[\n\r\u2028\u2029]/.test(s) ? s.replace(/[^\n\r\u2028\u2029]/g, " ") : " ".repeat(s.length);
954
+ }
955
+
956
+ /** Is the character at `at` preceded by an ODD number of backslashes? */
957
+ function isEscaped(src, at) {
958
+ let back = 0;
959
+ for (let k = at - 1; k >= 0 && src[k] === "\\"; k--) back++;
960
+ return back % 2 === 1;
961
+ }
962
+
963
+ /** JavaScript's line terminators. A `//` comment and a regex end at any of them; a quoted string at the first two. */
964
+ function isLineEnd(c) {
965
+ return c === "\n" || c === "\r" || c === "\u2028" || c === "\u2029";
966
+ }
967
+
968
+ /** Only white space between the start of this line and `at`. */
969
+ function lineStartsAt(src, at) {
970
+ for (let k = at - 1; k >= 0 && !isLineEnd(src[k]); k--) if (!isSpace(src[k])) return false;
971
+ return true;
972
+ }
973
+
974
+ /**
975
+ * Past balanced `<…>` starting at `p` in the SOURCE — a JSX element's type
976
+ * arguments, which run over lines, hold object types with `;` between their
977
+ * members (`<Table<{ id: string; label: string }>`, as Prettier prints them)
978
+ * and string types that may hold a `>` (`<Select<"a>b">`) — or `-1`. A `;`
979
+ * outside every brace ends a statement, not a type.
980
+ */
981
+ function skipAngles(src, p) {
982
+ let depth = 0;
983
+ let braces = 0;
984
+ const stop = Math.min(src.length, p + 1_000);
985
+ for (let j = p; j < stop; j++) {
986
+ const c = src[j];
987
+ if (c === '"' || c === "'" || c === "`") {
988
+ const close = src.indexOf(c, j + 1);
989
+ if (close < 0 || close >= stop) return -1;
990
+ j = close;
991
+ } else if (c === "{") braces++;
992
+ else if (c === "}") braces--;
993
+ else if (c === "<") depth++;
994
+ else if (c === ">" && src[j - 1] !== "=") {
995
+ if (--depth === 0) return braces === 0 ? j + 1 : -1;
996
+ } else if (c === ";" && braces === 0) return -1;
997
+ }
998
+ return -1;
999
+ }
1000
+
1001
+ /**
1002
+ * Where a regex starting at the `/` at `at` would end, or `-1` — the same
1003
+ * scan as the lexer's, without its memo and bounded, because it only asks
1004
+ * whether a guess matters: a regex literal a thousand characters long is not
1005
+ * worth the time to read twice.
1006
+ */
1007
+ function probeRegex(src, at) {
1008
+ let cls = false;
1009
+ const stop = Math.min(src.length, at + 1_000);
1010
+ for (let j = at + 1; j < stop; j++) {
1011
+ const s = src[j];
1012
+ if (isLineEnd(s)) return -1;
1013
+ if (s === "\\") {
1014
+ j++;
1015
+ continue;
1016
+ }
1017
+ if (s === "[") cls = true;
1018
+ else if (s === "]") cls = false;
1019
+ else if (s === "/" && !cls) return j + 1;
1020
+ }
1021
+ return -1;
1022
+ }
1023
+
1024
+ /** A numeric literal, from its first digit: `0x1F`, `1_000n`, `1.`, `1.5e-3`. */
1025
+ const NUMBER = /0[xXbBoO][\da-fA-F_]*n?|\d[\d_]*\.?[\d_]*(?:[eE][+-]?[\d_]+)?n?/y;
1026
+
1027
+ /**
1028
+ * How many JSX attempts a file may roll back. Past it, JSX is not lexed for
1029
+ * the rest of the file — and the point it stopped is the file's doubt, so no
1030
+ * load after it is published.
1031
+ */
1032
+ const JSX_ROLLBACKS = 64;
1033
+
1034
+ /**
1035
+ * The source with everything that is not code turned into spaces, the offsets
1036
+ * of the `import`/`export` keywords that stand at the top of the module, and
1037
+ * the first offset where the lexer had to GUESS in a way that decides what is
1038
+ * code — `doubtAt`, `-1` when it never did.
1039
+ *
1040
+ * A terminated quoted string keeps its quotes, so the scanner can find it and
1041
+ * read it back from the source; an unterminated one (a line end before the
1042
+ * closing quote — JavaScript ends it there, and a diff fragment is full of
1043
+ * them) is blanked whole. A template keeps its backticks and its `${…}`
1044
+ * expressions, which are code and are lexed as code, nested templates
1045
+ * included; its text, and the `${` and `}` around each expression, are
1046
+ * blanked. JSX — where the file can hold it — is blanked whole except the code
1047
+ * inside its `{…}`: tag names and their type arguments, attributes, attribute
1048
+ * strings (which have no escapes and may span lines), element-valued
1049
+ * attributes, and text, where an apostrophe is prose and not a quote.
1050
+ *
1051
+ * THE DOUBT. A lexer without a parser guesses, and a wrong guess about a
1052
+ * quote or a backtick does not stay local: it re-pairs every delimiter after
1053
+ * it, and text becomes code. Every guess that can do that is recorded, and
1054
+ * the first one's offset is `doubtAt`:
1055
+ * · a JSX attempt rolled back — its attribute strings (no escapes) and text
1056
+ * (an apostrophe is prose) are re-read as code;
1057
+ * · a `<` where an expression starts, in a file that can hold JSX, that is
1058
+ * not read as JSX on anything but TypeScript's own rule — a type alias's
1059
+ * right side, an interface body and `<T>(…)` are probably type
1060
+ * parameters, and probably is a guess; a `<` at a line start after a value
1061
+ * (JSX after ASI, or a comparison continuing);
1062
+ * · the rollback budget running out, after which JSX is not lexed at all;
1063
+ * · a `/` after `)`, `}`, a word that can be a name (`of`, `await`,
1064
+ * `yield`) or at a line start after a value, where the regex reading
1065
+ * would close on the same line — both readings are possible, and each
1066
+ * can re-pair what follows (the division reading re-lexes the would-be
1067
+ * closing `/`);
1068
+ * · a merge conflict's markers;
1069
+ * · a closing bracket that closes nothing open;
1070
+ * · an HTML-like comment in a `.js`/`.cjs` (a script's comment, a module's
1071
+ * operators).
1072
+ * Past `doubtAt` the extractor publishes no load — only `uncertain`.
1073
+ */
1074
+ function lexModule(
1075
+ src,
1076
+ jsx,
1077
+ typescript,
1078
+ script,
1079
+ ) {
1080
+ const n = src.length;
1081
+ const parts = [];
1082
+ let emitted = 0;
1083
+ const hide = (from, to) => {
1084
+ if (to <= from) return;
1085
+ if (from > emitted) parts.push(src.slice(emitted, from));
1086
+ parts.push(blank(src.slice(from, to)));
1087
+ emitted = to;
1088
+ };
1089
+
1090
+ let stack = [];
1091
+ const topLevel = new Set();
1092
+ let doubtAt = -1;
1093
+ const doubt = (at) => {
1094
+ if (doubtAt < 0 || at < doubtAt) doubtAt = at;
1095
+ };
1096
+ // What came before a `/` or a `<`: nothing yet, a punctuator, a word, a
1097
+ // closing bracket, or a value. Only the last three make them operators.
1098
+ let prev = "start";
1099
+ let prevWord = "";
1100
+ // The closing bracket just read, if the last token was one: `)` and `}` are
1101
+ // where a `/` could honestly be either.
1102
+ let closer = "";
1103
+ // How many `.` tokens in a row came last (comments and spaces between do
1104
+ // not count): one makes the next word a property, three are a spread.
1105
+ let dots = 0;
1106
+ // A line terminator since the last token — in white space or inside a
1107
+ // comment — which is what ASI reads. `lineStartsAt` looks at the text of
1108
+ // the line and a comment before the token defeats it.
1109
+ let brokeLine = false;
1110
+ // Where the code of each `${…}` and each JSX `{…}` starts: the mask blanks
1111
+ // their openers, and what stands right after one is not after whatever
1112
+ // came before the blank.
1113
+ const exprStarts = new Map();
1114
+ // The last three code tokens — words as themselves, anything else as its
1115
+ // first character — so `type F = <T>(…)` can be told from the text, where
1116
+ // a comment saying `// type X =` would have told it wrong.
1117
+ const hist = [];
1118
+ const saw = (t) => {
1119
+ hist.push(t);
1120
+ if (hist.length > 3) hist.shift();
1121
+ };
1122
+ // A regex scan that ran into a line end without closing: no `/` before that
1123
+ // line end opens one, or a line of unclosed classes would be scanned once
1124
+ // per slash.
1125
+ let noRegexUntil = -1;
1126
+ // JSX: blanked from `hideFrom` until code resumes; one snapshot per
1127
+ // outermost element, restored when the element turns out not to be one.
1128
+ let jsxOn = jsx;
1129
+ let rollbacks = 0;
1130
+ let hideFrom = -1;
1131
+ let notJsxAt = -1;
1132
+ let snap = null;
1133
+
1134
+ let i = 0;
1135
+ const expressionStarts = () => prev === "start" || prev === "punct" || (prev === "word" && EXPRESSION_AFTER_WORD.has(prevWord));
1136
+ const valueBefore = () => prev === "value" || prev === "close" || (prev === "word" && !EXPRESSION_AFTER_WORD.has(prevWord));
1137
+ const inJsx = () => stack.some((f) => f.k === "jsx");
1138
+
1139
+ /** Where a regex starting at `/` at `at` would end on its line, or `-1` with `noRegexUntil` set. */
1140
+ const regexEnd = (at) => {
1141
+ let cls = false;
1142
+ for (let j = at + 1; j < n; j++) {
1143
+ const s = src[j];
1144
+ if (isLineEnd(s)) {
1145
+ noRegexUntil = j;
1146
+ return -1;
1147
+ }
1148
+ if (s === "\\") {
1149
+ j++;
1150
+ continue;
1151
+ }
1152
+ if (s === "[") cls = true;
1153
+ else if (s === "]") cls = false;
1154
+ else if (s === "/" && !cls) {
1155
+ j++;
1156
+ while (j < n && isIdentChar(src[j])) j++; // flags
1157
+ return j;
1158
+ }
1159
+ }
1160
+ noRegexUntil = n;
1161
+ return -1;
1162
+ };
1163
+
1164
+ /**
1165
+ * Scan template text from `j`: where it ends (just past the closing
1166
+ * backtick), where a `${` expression starts (just past the brace), or — for
1167
+ * a template that never closes — the end of the input.
1168
+ */
1169
+ const templateText = (j) => {
1170
+ for (; j < n; j++) {
1171
+ const c = src[j];
1172
+ if (c === "\\") {
1173
+ j++;
1174
+ continue;
1175
+ }
1176
+ if (c === "`") return { end: j + 1, to: "close" };
1177
+ if (c === "$" && src[j + 1] === "{") return { end: j + 2, to: "expr" };
1178
+ }
1179
+ return { end: n, to: "eof" };
1180
+ };
1181
+ /**
1182
+ * Blank template text from `from` (the opening backtick, or the `}` that
1183
+ * closed an expression) and step past it. The opening backtick stays — it is
1184
+ * what a scanner reads a plain template back by — the closing one stays so
1185
+ * the pair still reads as one token, and a template that never closes is
1186
+ * blanked to the end, backtick and all, so no lone backtick is left to pair
1187
+ * with anything.
1188
+ */
1189
+ const template = (from, keepFirst) => {
1190
+ const t = templateText(from + 1);
1191
+ const start = keepFirst ? from + 1 : from;
1192
+ if (t.to === "eof") hide(keepFirst ? from : start, n);
1193
+ else hide(start, t.to === "close" ? t.end - 1 : t.end);
1194
+ if (t.to === "expr") {
1195
+ stack.push({ k: "tpl" });
1196
+ exprStarts.set(t.end, "tpl");
1197
+ prev = "start";
1198
+ } else prev = "value";
1199
+ closer = "";
1200
+ brokeLine = false;
1201
+ i = t.end;
1202
+ };
1203
+
1204
+ /** An identifier-ish JSX name at `j` (`div`, `Foo.Bar`, `svg:rect`, `data-x`), or `""`. */
1205
+ const jsxName = (j) => {
1206
+ let q = j;
1207
+ while (q < n && (isIdentChar(src[q]) || src[q] === "." || src[q] === ":" || src[q] === "-")) q++;
1208
+ return src.slice(j, q);
1209
+ };
1210
+
1211
+ /**
1212
+ * An element's opening at `j` (just past its `<`): a fragment, or a name
1213
+ * with — in TypeScript — its type arguments. Returns where the tag's body
1214
+ * starts, or `-1` when this is not an element.
1215
+ */
1216
+ const openElement = (j) => {
1217
+ j = skipSpace(src, j);
1218
+ if (src[j] === ">") {
1219
+ stack.push({ k: "jsx", name: "", open: false });
1220
+ if (snap) snap.children = true;
1221
+ return j + 1;
1222
+ }
1223
+ const name = jsxName(j);
1224
+ if (!name || /^[\d.:-]/.test(name)) return -1;
1225
+ let k = j + name.length;
1226
+ if (typescript && src[skipSpace(src, k)] === "<") {
1227
+ k = skipAngles(src, skipSpace(src, k));
1228
+ if (k < 0) return -1;
1229
+ }
1230
+ stack.push({ k: "jsx", name, open: true });
1231
+ return k;
1232
+ };
1233
+
1234
+ /** Undo the outermost JSX attempt: the `<` that began it is an operator after all. */
1235
+ const rollback = (ranToEnd) => {
1236
+ const s = snap;
1237
+ // A rollback re-reads what it lexed as JSX — attribute strings that have
1238
+ // no escapes, text with an apostrophe — as code: from here on, what is
1239
+ // code is a guess.
1240
+ void ranToEnd;
1241
+ doubt(s.i);
1242
+ i = s.i;
1243
+ emitted = s.emitted;
1244
+ parts.length = s.parts;
1245
+ stack = s.stack.slice();
1246
+ prev = s.prev;
1247
+ prevWord = s.prevWord;
1248
+ closer = s.closer;
1249
+ dots = s.dots;
1250
+ noRegexUntil = s.noRegexUntil;
1251
+ hideFrom = -1;
1252
+ notJsxAt = s.i;
1253
+ snap = null;
1254
+ if (++rollbacks >= JSX_ROLLBACKS) {
1255
+ jsxOn = false;
1256
+ doubt(s.i);
1257
+ }
1258
+ };
1259
+
1260
+ /** An element closed: back to its parent (children, or a tag it is an attribute of), or to code. */
1261
+ const elementClosed = () => {
1262
+ stack.pop();
1263
+ if (inJsx() && stack[stack.length - 1].k === "jsx") return;
1264
+ if (hideFrom >= 0) hide(hideFrom, i);
1265
+ hideFrom = -1;
1266
+ prev = "value";
1267
+ closer = "";
1268
+ dots = 0;
1269
+ if (!inJsx()) snap = null;
1270
+ };
1271
+
1272
+ if (src.startsWith("#!", src.charCodeAt(0) === 0xfeff ? 1 : 0)) {
1273
+ let nl = 0;
1274
+ while (nl < n && !isLineEnd(src[nl])) nl++;
1275
+ i = nl;
1276
+ hide(0, i);
1277
+ }
1278
+
1279
+ for (;;) {
1280
+ while (i < n) {
1281
+ const top = stack[stack.length - 1];
1282
+
1283
+ // ── JSX: a tag and its attributes
1284
+ if (top && top.k === "jsx" && top.open) {
1285
+ const c = src[i];
1286
+ if (isSpace(c)) {
1287
+ i++;
1288
+ continue;
1289
+ }
1290
+ if (c === "/" && src[skipSpace(src, i + 1)] === ">") {
1291
+ i = skipSpace(src, i + 1) + 1;
1292
+ elementClosed();
1293
+ continue;
1294
+ }
1295
+ if (c === ">") {
1296
+ top.open = false;
1297
+ if (snap) snap.children = true;
1298
+ i++;
1299
+ continue;
1300
+ }
1301
+ if (c === "{") {
1302
+ hide(hideFrom, i + 1);
1303
+ hideFrom = -1;
1304
+ stack.push({ k: "jsxexpr" });
1305
+ exprStarts.set(i + 1, "jsx");
1306
+ prev = "start";
1307
+ closer = "";
1308
+ dots = 0;
1309
+ brokeLine = false;
1310
+ i++;
1311
+ continue;
1312
+ }
1313
+ if (c === "/" && (src[i + 1] === "*" || src[i + 1] === "/")) {
1314
+ if (src[i + 1] === "*") {
1315
+ const end = src.indexOf("*/", i + 2);
1316
+ i = end < 0 ? n : end + 2;
1317
+ } else {
1318
+ while (i < n && !isLineEnd(src[i])) i++;
1319
+ }
1320
+ continue;
1321
+ }
1322
+ if (isIdentChar(c)) {
1323
+ i += Math.max(1, jsxName(i).length);
1324
+ const eq = skipSpace(src, i);
1325
+ if (src[eq] !== "=") continue;
1326
+ const v = skipSpace(src, eq + 1);
1327
+ const q = src[v];
1328
+ if (q === '"' || q === "'") {
1329
+ const close = src.indexOf(q, v + 1);
1330
+ if (close < 0) {
1331
+ rollback(true);
1332
+ continue;
1333
+ }
1334
+ i = close + 1;
1335
+ continue;
1336
+ }
1337
+ if (q === "<") {
1338
+ // An element as the value: `icon=<Info />`.
1339
+ const k = openElement(v + 1);
1340
+ if (k < 0) {
1341
+ rollback(false);
1342
+ continue;
1343
+ }
1344
+ i = k;
1345
+ continue;
1346
+ }
1347
+ i = v; // `{…}` is read on the next turn; anything else fails there
1348
+ continue;
1349
+ }
1350
+ rollback(false);
1351
+ continue;
1352
+ }
1353
+
1354
+ // ── JSX: children
1355
+ if (top && top.k === "jsx") {
1356
+ const c = src[i];
1357
+ if (c === "{") {
1358
+ hide(hideFrom, i + 1);
1359
+ hideFrom = -1;
1360
+ stack.push({ k: "jsxexpr" });
1361
+ exprStarts.set(i + 1, "jsx");
1362
+ prev = "start";
1363
+ closer = "";
1364
+ dots = 0;
1365
+ brokeLine = false;
1366
+ i++;
1367
+ continue;
1368
+ }
1369
+ if (c === "<") {
1370
+ const j = skipSpace(src, i + 1);
1371
+ if (src[j] === "/") {
1372
+ const k = skipSpace(src, j + 1);
1373
+ const name = jsxName(k);
1374
+ const gt = skipSpace(src, k + name.length);
1375
+ if (src[gt] !== ">" || name !== top.name) {
1376
+ rollback(true);
1377
+ continue;
1378
+ }
1379
+ i = gt + 1;
1380
+ elementClosed();
1381
+ continue;
1382
+ }
1383
+ const k = openElement(i + 1);
1384
+ if (k >= 0) {
1385
+ i = k;
1386
+ continue;
1387
+ }
1388
+ }
1389
+ i++;
1390
+ continue;
1391
+ }
1392
+
1393
+ // ── code
1394
+ const c = src[i];
1395
+ const next = src[i + 1];
1396
+
1397
+ if (isSpace(c)) {
1398
+ if (isLineEnd(c)) brokeLine = true;
1399
+ i++;
1400
+ continue;
1401
+ }
1402
+
1403
+ if (c === "/" && next === "/" && !isEscaped(src, i)) {
1404
+ let j = i;
1405
+ while (j < n && !isLineEnd(src[j])) j++;
1406
+ hide(i, j);
1407
+ i = j;
1408
+ continue;
1409
+ }
1410
+ if (c === "/" && next === "*" && !isEscaped(src, i)) {
1411
+ const close = src.indexOf("*/", i + 2);
1412
+ const j = close < 0 ? n : close + 2;
1413
+ if (/[\n\r\u2028\u2029]/.test(src.slice(i, j))) brokeLine = true;
1414
+ hide(i, j);
1415
+ i = j;
1416
+ continue;
1417
+ }
1418
+ // HTML-like comments: a script's comment, a module's operators. Only a
1419
+ // JavaScript file that is not declared a module can be a script, and
1420
+ // which one it is we cannot tell from here — so it is a guess.
1421
+ if (script && ((c === "<" && src.startsWith("<!--", i)) || (c === "-" && src.startsWith("-->", i) && lineStartsAt(src, i)))) {
1422
+ doubt(i);
1423
+ let j = i;
1424
+ while (j < n && !isLineEnd(src[j])) j++;
1425
+ hide(i, j);
1426
+ i = j;
1427
+ continue;
1428
+ }
1429
+
1430
+ // Every token from here on is code: it either IS a dot or ends a run of
1431
+ // them, and it is a closing bracket or it ends what one said.
1432
+ const property = dots === 1;
1433
+ dots = c === "." ? dots + 1 : 0;
1434
+ const after = closer;
1435
+ closer = "";
1436
+ const newLine = brokeLine;
1437
+ brokeLine = false;
1438
+
1439
+ if (c === '"' || c === "'") {
1440
+ let j = i + 1;
1441
+ let terminated = false;
1442
+ for (; j < n; j++) {
1443
+ const s = src[j];
1444
+ if (s === "\\") {
1445
+ j += src[j + 1] === "\r" && src[j + 2] === "\n" ? 2 : 1;
1446
+ continue;
1447
+ }
1448
+ if (s === c) {
1449
+ terminated = true;
1450
+ break;
1451
+ }
1452
+ if (s === "\n" || s === "\r") break;
1453
+ }
1454
+ if (terminated) {
1455
+ if (j > i + 1) hide(i + 1, j);
1456
+ i = j + 1;
1457
+ } else {
1458
+ hide(i, j);
1459
+ i = j;
1460
+ }
1461
+ prev = "value";
1462
+ saw('"');
1463
+ continue;
1464
+ }
1465
+
1466
+ if (c === "`") {
1467
+ saw("`");
1468
+ template(i, true);
1469
+ continue;
1470
+ }
1471
+
1472
+ if (c === "}" && top && (top.k === "tpl" || top.k === "jsxexpr")) {
1473
+ stack.pop();
1474
+ if (top.k === "tpl") template(i, false);
1475
+ else {
1476
+ // Back into the JSX that holds this expression; the brace is JSX.
1477
+ hideFrom = i;
1478
+ i++;
1479
+ }
1480
+ continue;
1481
+ }
1482
+
1483
+ if (c === "/") {
1484
+ // After `)`, `}` or a word that may be a name, a `/` can honestly be
1485
+ // either. When the span a regex would take holds anything that pairs
1486
+ // or nests — a quote, a backtick, a bracket — or ends against a `*` or
1487
+ // a `/`, the two readings lex the rest differently, and whichever we
1488
+ // take is a guess.
1489
+ // A `/` at the start of a line after a value is the same guess: it
1490
+ // divides if the line above continues, and starts a regex if ASI ended
1491
+ // it (`let quote` then `/['"]/.test(src)`).
1492
+ //
1493
+ // When both readings are possible — the regex would close on this
1494
+ // line — the one we take is a guess, whatever the span holds: the
1495
+ // regex reading hides what the span holds, and the division reading
1496
+ // re-lexes the would-be regex's closing `/`, which can open a regex or
1497
+ // a `//` of its own. Two review rounds found a new way each time the
1498
+ // guess was excused by what the span contained.
1499
+ const afterLineEnd = valueBefore() && newLine;
1500
+ if ((after === ")" || after === "}" || afterLineEnd || (prev === "word" && AMBIGUOUS_WORDS.has(prevWord))) && i >= noRegexUntil) {
1501
+ if (probeRegex(src, i) > 0) doubt(i);
1502
+ }
1503
+ if (expressionStarts() && i >= noRegexUntil) {
1504
+ const end = regexEnd(i);
1505
+ if (end > 0) {
1506
+ hide(i, end);
1507
+ i = end;
1508
+ prev = "value";
1509
+ saw("/");
1510
+ continue;
1511
+ }
1512
+ }
1513
+ prev = "punct";
1514
+ i++;
1515
+ continue;
1516
+ }
1517
+
1518
+ // A merge conflict's markers: TypeScript reads one side as trivia, we
1519
+ // read both. Either side may be the one that runs.
1520
+ if ((c === "<" || c === "=" || c === ">" || c === "|") && /^(?:<{7}|={7}|>{7}|\|{7})/.test(src.slice(i, i + 7)) && lineStartsAt(src, i)) {
1521
+ doubt(i);
1522
+ }
1523
+
1524
+ // `<<`, `<=`, `<<=` are operators, whatever came before them.
1525
+ if (c === "<" && (next === "<" || next === "=")) {
1526
+ i += next === "<" && src[i + 2] === "=" ? 3 : 2;
1527
+ prev = "punct";
1528
+ continue;
1529
+ }
1530
+
1531
+ // At the start of a line after a value, a `<` continues a comparison — or
1532
+ // ASI ended the line and an element starts (`let el` then `<p>…`). We
1533
+ // read the comparison; when an element could start there, it is a guess.
1534
+ if (c === "<" && jsxOn && valueBefore() && newLine) {
1535
+ const j = skipSpace(src, i + 1);
1536
+ if (src[j] === ">" || jsxName(j) !== "") doubt(i);
1537
+ }
1538
+
1539
+ if (c === "<" && jsxOn && i !== notJsxAt && expressionStarts()) {
1540
+ const j = skipSpace(src, i + 1);
1541
+ const name = src[j] === ">" ? "" : jsxName(j);
1542
+ // `<T,>(…) =>` and `<T extends U>(…) =>` are a generic arrow's type
1543
+ // parameters — TypeScript itself tells them from JSX this way, so
1544
+ // that is knowledge. `type F = <T>(x: T) => T` and a signature's
1545
+ // `<T>(` are read as type parameters too, but by a guess.
1546
+ const aliasRight = hist.length === 3 && hist[0] === "type" && /^[A-Za-z_$][\w$]*$/.test(hist[1]) && hist[2] === "=";
1547
+ // Inside the body of an `interface` or a `type X = { … }` every `<` is
1548
+ // a type's.
1549
+ const inTypeBody = top !== undefined && top.k === "brace" && top.type === true;
1550
+ // TypeScript's own rule, read off the characters: `<T,` or `<T extends
1551
+ // U` — an `extends` followed by a type, not by `>`, `=` or `/`, which
1552
+ // TypeScript reads as JSX. That is knowledge. What the token history
1553
+ // says (a type alias's right side, an interface body) is only likely:
1554
+ // a template's `${type}`, a JSX `{type}`, ASI after a field named
1555
+ // `type` all fooled it.
1556
+ const certainlyGeneric =
1557
+ typescript && name !== "" && /^\s*(?:,|extends\s+[^\s>=/])/.test(src.slice(j + name.length, j + name.length + 16));
1558
+ const likelyGeneric =
1559
+ typescript &&
1560
+ name !== "" &&
1561
+ (aliasRight || inTypeBody || (/^[A-Z][A-Za-z0-9]?$/.test(name) && /^\s*>\s*\(/.test(src.slice(j + name.length, j + name.length + 8))));
1562
+ if (!certainlyGeneric && !likelyGeneric) {
1563
+ const depthBefore = stack.length;
1564
+ const k = openElement(i + 1);
1565
+ if (k >= 0) {
1566
+ if (!snap) {
1567
+ snap = { i, emitted, parts: parts.length, stack: stack.slice(0, depthBefore), prev, prevWord, closer: after, dots: 0, noRegexUntil, children: false };
1568
+ }
1569
+ const opened = stack[stack.length - 1];
1570
+ if (opened.k === "jsx" && !opened.open) snap.children = true;
1571
+ hideFrom = i;
1572
+ i = k;
1573
+ continue;
1574
+ }
1575
+ }
1576
+ // A `<` where an expression starts, in a file that can hold JSX, that
1577
+ // we do not read as JSX: unless TypeScript's own rule said so, that
1578
+ // is a guess, and the text after it is lexed as code on its strength.
1579
+ if (!certainlyGeneric) doubt(i);
1580
+ }
1581
+
1582
+ // A private name (`this.#new`) is a name, whatever it spells.
1583
+ if (c === "#" && isIdentChar(next)) {
1584
+ let j = i + 1;
1585
+ while (j < n && isIdentChar(src[j])) j++;
1586
+ prev = "word";
1587
+ prevWord = "";
1588
+ i = j;
1589
+ continue;
1590
+ }
1591
+
1592
+ // A number, with its dot: `1./2` is one, divided, and `1..x` is its property.
1593
+ if (c >= "0" && c <= "9") {
1594
+ NUMBER.lastIndex = i;
1595
+ const m = NUMBER.exec(src);
1596
+ let j = m ? i + m[0].length : i + 1;
1597
+ while (j < n && isIdentChar(src[j])) j++;
1598
+ prev = "value";
1599
+ saw("0");
1600
+ i = j;
1601
+ continue;
1602
+ }
1603
+
1604
+ if (isIdentChar(c)) {
1605
+ let j = i + 1;
1606
+ while (j < n && isIdentChar(src[j])) j++;
1607
+ if (property) {
1608
+ // `size.new / 2` divides: a keyword spelled as a property is a name.
1609
+ prev = "word";
1610
+ prevWord = "";
1611
+ } else {
1612
+ prev = "word";
1613
+ prevWord = src.slice(i, j);
1614
+ if (stack.length === 0 && (prevWord === "import" || prevWord === "export")) topLevel.add(i);
1615
+ }
1616
+ saw(property ? "." + src.slice(i, j) : src.slice(i, j));
1617
+ i = j;
1618
+ continue;
1619
+ }
1620
+
1621
+ // `i++ / 2` and `total! / n`: postfix operators end a value.
1622
+ if ((c === "+" || c === "-") && next === c && valueBefore() && !newLine) {
1623
+ i += 2;
1624
+ prev = "value";
1625
+ continue;
1626
+ }
1627
+ // A `!` after a value on its line is TypeScript's non-null (`x !` too) —
1628
+ // in JavaScript nothing else could stand there.
1629
+ if (c === "!" && next !== "=" && valueBefore() && !newLine) {
1630
+ i++;
1631
+ prev = "value";
1632
+ continue;
1633
+ }
1634
+
1635
+ let nextPrev = "punct";
1636
+ if (c === "{") {
1637
+ // The body of `interface I {`, of `type T = {`, or a type literal inside one.
1638
+ // A property is recorded as `.name`, so `net.interface` and `props.type`
1639
+ // cannot pass for the keywords; the name must be an identifier, so
1640
+ // `acc[type] = {` and `{ interface: {` cannot either.
1641
+ const ident = (t) => t !== undefined && /^[A-Za-z_$][\w$]*$/.test(t);
1642
+ const typeBody =
1643
+ typescript &&
1644
+ ((hist.length >= 2 && hist[hist.length - 2] === "interface" && ident(hist[hist.length - 1])) ||
1645
+ (hist.length === 3 && hist[0] === "type" && ident(hist[1]) && hist[2] === "=") ||
1646
+ (top !== undefined && top.k === "brace" && top.type === true));
1647
+ stack.push({ k: "brace", type: typeBody });
1648
+ }
1649
+ else if (c === "(") {
1650
+ const condition = prev === "word" && (CONDITION_WORDS.has(prevWord) || (prevWord === "await" && hist[hist.length - 2] === "for"));
1651
+ stack.push({ k: "paren", condition });
1652
+ }
1653
+ else if (c === "[") stack.push({ k: "bracket" });
1654
+ else if (c === "}" || c === ")" || c === "]") {
1655
+ const want = c === "}" ? "brace" : c === ")" ? "paren" : "bracket";
1656
+ const condition = top !== undefined && top.k === "paren" && top.condition;
1657
+ if (top && top.k === want) {
1658
+ stack.pop();
1659
+ // A closing paren or bracket ends a value — except the paren of a
1660
+ // condition, after which a statement (and a regex) can start.
1661
+ if (c !== "}") nextPrev = condition ? "punct" : "close";
1662
+ } else {
1663
+ // Closes nothing that is open: somewhere before this, a guess went wrong.
1664
+ doubt(i);
1665
+ if (c !== "}") nextPrev = "close";
1666
+ }
1667
+ // After a condition's `)` a `/` is known to open a regex; after any
1668
+ // other `)` or a `}` it is a guess.
1669
+ closer = condition ? "" : c;
1670
+ }
1671
+ saw(c);
1672
+ // A closing brace ends a block as often as an object, and a regex after a
1673
+ // block is the likelier reading.
1674
+ prev = nextPrev;
1675
+ i++;
1676
+ }
1677
+ // The end of the input inside a JSX element: it never closed, so it was
1678
+ // never JSX. Read the `<` as an operator and go on from there.
1679
+ if (snap) {
1680
+ rollback(true);
1681
+ continue;
1682
+ }
1683
+ if (hideFrom >= 0) hide(hideFrom, n);
1684
+ break;
1685
+ }
1686
+ if (emitted < n) parts.push(src.slice(emitted));
1687
+ return { code: parts.join(""), topLevel, doubtAt, exprStarts };
1688
+ }
1689
+ // END module-edges.ts
1690
+
1691
+ // ───────────────────────────── path classification ─────────────────────────────
1692
+
1693
+ const TEST_FILE_RX = [
1694
+ /\.test\.(?:ts|tsx|js|jsx|mjs|cjs)$/i,
1695
+ /\.spec\.(?:ts|tsx|js|jsx|mjs|cjs)$/i,
1696
+ /_test\.(?:py|go|rb|rs|dart)$/i,
1697
+ /(?:^|\/)test_[^/]+\.(?:py|go|rb|rs)$/i,
1698
+ /Tests?\.(?:swift|kt|kts|java|cs)$/,
1699
+ /Spec\.(?:kt|kts|scala|groovy)$/,
1700
+ ];
1701
+
1702
+ const TEST_DIR_RX = [/(?:^|\/)__tests__\//i, /(?:^|\/)tests?\//i, /(?:^|\/)specs?\//i];
1703
+
1704
+ export function isTestPath(path) {
1705
+ if (!path) return false;
1706
+ if (TEST_FILE_RX.some((rx) => rx.test(path))) return true;
1707
+ if (TEST_DIR_RX.some((rx) => rx.test(path))) return true;
1708
+ return false;
1709
+ }
1710
+
1711
+ const TEST_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "rb"];
1712
+
1713
+ /**
1714
+ * The test files the product's own pairing rule expects for a source path.
1715
+ *
1716
+ * Every branch here was added by a live report getting it wrong — a Ruby file
1717
+ * asked for `tally.test.rb`, a pytest module whose `tests/` peer was invisible,
1718
+ * a monorepo package whose integration tests mirror `src/` one directory over.
1719
+ * The list is not tidy and must not be tidied on this side alone.
1720
+ */
1721
+ export function expectedTestPaths(sourcePath) {
1722
+ if (!sourcePath || isTestPath(sourcePath)) return [];
1723
+ const dir = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/")) : "";
1724
+ const base = sourcePath.includes("/") ? sourcePath.slice(sourcePath.lastIndexOf("/") + 1) : sourcePath;
1725
+ const ext = base.includes(".") ? base.slice(base.lastIndexOf(".") + 1) : "";
1726
+ const stem = base.includes(".") ? base.slice(0, base.lastIndexOf(".")) : base;
1727
+
1728
+ if (!ext || !TEST_EXT.includes(ext)) return [];
1729
+
1730
+ const candidates = [];
1731
+ const join_ = (parts) => parts.filter(Boolean).join("/");
1732
+
1733
+ candidates.push(join_([dir, `${stem}.test.${ext}`]));
1734
+ candidates.push(join_([dir, `${stem}.spec.${ext}`]));
1735
+ candidates.push(join_([dir, "__tests__", `${stem}.test.${ext}`]));
1736
+ candidates.push(join_([dir, "__tests__", `${stem}.spec.${ext}`]));
1737
+ candidates.push(join_([dir, "__tests__", `${stem}.${ext}`]));
1738
+
1739
+ if (ext === "py") candidates.push(join_([dir, `test_${stem}.py`]));
1740
+ if (ext === "go" || ext === "rs") candidates.push(join_([dir, `${stem}_test.${ext}`]));
1741
+
1742
+ if (ext === "py") {
1743
+ candidates.push(join_([dir, "tests", `test_${stem}.py`]));
1744
+ candidates.push(join_([dir, "tests", `${stem}_test.py`]));
1745
+ candidates.push(join_([dir, "test", `test_${stem}.py`]));
1746
+ }
1747
+
1748
+ if (ext === "rb") {
1749
+ candidates.push(join_([dir, `${stem}_test.rb`]));
1750
+ candidates.push(join_([dir, `${stem}_spec.rb`]));
1751
+ candidates.push(join_([dir, "test", `${stem}_test.rb`]));
1752
+ candidates.push(join_([dir, "spec", `${stem}_spec.rb`]));
1753
+ const up = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
1754
+ candidates.push(join_([up, "test", `${stem}_test.rb`]));
1755
+ candidates.push(join_([up, "spec", `${stem}_spec.rb`]));
1756
+ }
1757
+
1758
+ if (sourcePath.includes("/src/")) {
1759
+ candidates.push(sourcePath.replace("/src/", "/tests/").replace(`.${ext}`, `.test.${ext}`));
1760
+ candidates.push(sourcePath.replace("/src/", "/__tests__/").replace(`.${ext}`, `.test.${ext}`));
1761
+ }
1762
+
1763
+ const srcAt = sourcePath.indexOf("/src/");
1764
+ if (srcAt >= 0 || sourcePath.startsWith("src/")) {
1765
+ const pkg = srcAt >= 0 ? sourcePath.slice(0, srcAt) : "";
1766
+ const afterSrc = srcAt >= 0 ? sourcePath.slice(srcAt + 5) : sourcePath.slice(4);
1767
+ const restDir = afterSrc.includes("/") ? afterSrc.slice(0, afterSrc.lastIndexOf("/")) : "";
1768
+ const under = join_([pkg, "integration-tests", "__tests__", restDir]);
1769
+ candidates.push(`${under}/${stem}/index.spec.${ext}`);
1770
+ candidates.push(`${under}/${stem}.spec.${ext}`);
1771
+ candidates.push(`${under}/${stem}.test.${ext}`);
1772
+ }
1773
+
1774
+ return Array.from(new Set(candidates));
1775
+ }
1776
+
1777
+ const SOURCE_EXT = /\.(?:[cm]?[jt]sx?)$/;
1778
+ const EXCLUDED_DIR = /(?:^|\/)(?:node_modules|dist|build|\.next|coverage|\.turbo|\.yarn)(?:\/|$)/;
1779
+
1780
+ export function isGraphSource(path) {
1781
+ return SOURCE_EXT.test(path) && !EXCLUDED_DIR.test(path);
1782
+ }
1783
+
1784
+ /**
1785
+ * WHICH paths a bundle carries. Every manifest and source file for the graph
1786
+ * and the package map; the changed files and the tests paired to them for
1787
+ * `contents`. A stem match across the whole repository would pull in every
1788
+ * `index.spec.ts` in it for one changed `index.ts`.
1789
+ */
1790
+ export function selectEvidence(treePaths, changedPaths) {
1791
+ const tree = [...treePaths].sort();
1792
+ const manifests = tree.filter((p) => /(?:^|\/)package\.json$/.test(p) && !EXCLUDED_DIR.test(p));
1793
+ const sources = tree.filter(isGraphSource);
1794
+ const changed = changedPaths.filter((p) => treePaths.has(p)).sort();
1795
+ const pairedTests = [
1796
+ ...new Set(changed.flatMap((p) => expectedTestPaths(p).filter((t) => treePaths.has(t) && !changed.includes(t)))),
1797
+ ].sort();
1798
+ return { manifests, sources, changed, pairedTests };
1799
+ }
1800
+
1801
+ // ───────────────────────────────── the bundle ─────────────────────────────────
1802
+
1803
+ /** `MANIFEST_FIELD_MAX_DEPTH` / `MANIFEST_FIELD_MAX_BYTES` in `@ao/types`, whose docblock says why. */
1804
+ export const MANIFEST_FIELD_MAX_DEPTH = 32;
1805
+ export const MANIFEST_FIELD_MAX_BYTES = 65_536;
1806
+
1807
+ /** The twin of `manifestFieldFits` in `@ao/types`: iterative, so a hostile depth cannot overflow the check itself. */
1808
+ export function manifestFieldFits(v) {
1809
+ const stack = [[v, 1]];
1810
+ while (stack.length > 0) {
1811
+ const [x, d] = stack.pop();
1812
+ if (x === null || typeof x !== "object") continue;
1813
+ if (d > MANIFEST_FIELD_MAX_DEPTH) return false;
1814
+ for (const child of Object.values(x)) stack.push([child, d + 1]);
1815
+ }
1816
+ return JSON.stringify(v ?? null).length <= MANIFEST_FIELD_MAX_BYTES;
1817
+ }
1818
+
1819
+ /** The twin of `manifestName` in git-source.ts. */
1820
+ export function manifestName(body) {
1821
+ if (!body) return null;
1822
+ try {
1823
+ const name = JSON.parse(body.charCodeAt(0) === 0xfeff ? body.slice(1) : body)?.name;
1824
+ return typeof name === "string" && name ? name : null;
1825
+ } catch {
1826
+ return null;
1827
+ }
1828
+ }
1829
+
1830
+ /**
1831
+ * One manifest as the bundle carries it — the twin of `readManifest` in
1832
+ * git-source.ts, which says why an unreadable one is still listed.
1833
+ */
1834
+ export function readManifest(path, body) {
1835
+ const dir = path.slice(0, -"package.json".length).replace(/\/$/, "");
1836
+ const none = { dir, readable: false, name: null, main: null, module: null, types: null, exports: null, imports: null };
1837
+ if (!body) return none;
1838
+ let json;
1839
+ try {
1840
+ json = JSON.parse(body.charCodeAt(0) === 0xfeff ? body.slice(1) : body);
1841
+ } catch {
1842
+ return none;
1843
+ }
1844
+ if (!json || typeof json !== "object" || Array.isArray(json)) return none;
1845
+ if (!manifestFieldFits(json.exports) || !manifestFieldFits(json.imports)) return none;
1846
+ const str = (v) => (typeof v === "string" && v ? v : null);
1847
+ return {
1848
+ dir,
1849
+ readable: true,
1850
+ name: str(json.name),
1851
+ main: str(json.main),
1852
+ module: str(json.module),
1853
+ types: str(json.types) ?? str(json.typings),
1854
+ exports: json.exports === undefined ? null : json.exports,
1855
+ imports: json.imports === undefined ? null : json.imports,
1856
+ };
1857
+ }
1858
+
1859
+ /**
1860
+ * The bundle from a tree and a way to read its files — the twin of the stand's
1861
+ * `buildEvidence`, down to the sort orders, because the sort orders ARE the
1862
+ * bytes that `bundleHash` hashes.
1863
+ *
1864
+ * `sent` is DERIVED here and a literal there, and that difference is the point
1865
+ * of this file: the stand reads a commit and never runs out of time, a hook
1866
+ * has a budget and a repository that may have no manifests at all.
1867
+ *
1868
+ * The caller says which sections to BUILD (`want`) and why the others are
1869
+ * missing (`absences`); it never says what was sent. That used to be an input,
1870
+ * and it made the one shape the server refuses representable — `sent.graph:
1871
+ * true` beside `graph: {edges: []}` after a budget ran out mid-read.
1872
+ * `sentAgreesWithBody` then 400s the WHOLE body at the door, losing the tree
1873
+ * with it, and the only trace is a line in a local log while the review has
1874
+ * already paid its wait. Derived, that bundle cannot be written down at all.
1875
+ */
1876
+ export function buildEvidence(input) {
1877
+ const sel = selectEvidence(input.tree.paths, input.changedPaths);
1878
+ const want = input.want ?? { tree: true, graph: true, packageMap: true, contents: true };
1879
+ const wanted = [
1880
+ ...new Set([
1881
+ ...(want.packageMap ? sel.manifests : []),
1882
+ ...(want.graph ? sel.sources : []),
1883
+ ...(want.contents ? [...sel.changed, ...sel.pairedTests] : []),
1884
+ ]),
1885
+ ].sort();
1886
+ const read = input.read(wanted);
1887
+
1888
+ // Everything actually produced. A section is in here or it is not sent —
1889
+ // there is no third state, and no way to say one and mean the other.
1890
+ const built = {};
1891
+ if (want.tree !== false) built.tree = { paths: [...input.tree.paths].sort(), truncated: input.tree.truncated };
1892
+
1893
+ const packageMap = {};
1894
+ const manifests = [];
1895
+ if (want.packageMap) {
1896
+ for (const m of sel.manifests) {
1897
+ const body = read.get(m);
1898
+ const manifest = readManifest(m, body);
1899
+ manifests.push(manifest);
1900
+ const name = manifest.name ?? manifestName(body);
1901
+ if (name) packageMap[name] = manifest.dir;
1902
+ }
1903
+ }
1904
+
1905
+ const edges = [];
1906
+ const doubted = [];
1907
+ if (want.graph) {
1908
+ const report = { doubtAt: -1 };
1909
+ for (const s of sel.sources) {
1910
+ const body = read.get(s);
1911
+ // A source we could not read is not a source with no edges: the reader
1912
+ // must not take it for a mock-free file. One that holds a NUL byte is still
1913
+ // text — agents write U+0000 into template literals — and is lexed; one
1914
+ // whose NUL stands in CODE, outside every string and comment (an MPEG-TS
1915
+ // video fixture named `.ts`), cannot parse: no runner can load it, so it
1916
+ // neither loads nor replaces.
1917
+ if (body == null) {
1918
+ doubted.push(s);
1919
+ continue;
1920
+ }
1921
+ if (body.includes("\0") && maskModuleSource(body, moduleEdgeOptions(s)).includes("\0")) continue;
1922
+ if (!body) continue;
1923
+ for (const ref of extractModuleEdges(body, moduleEdgeOptions(s), report)) edges.push([s, ref.specifier, ref.kind]);
1924
+ if (report.doubtAt >= 0) doubted.push(s);
1925
+ }
1926
+ edges.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : a[2] < b[2] ? -1 : a[2] > b[2] ? 1 : 0));
1927
+ }
1928
+
1929
+ if (want.graph) built.graph = { format: EVIDENCE_GRAPH_FORMAT, edges, doubted };
1930
+ if (want.packageMap) {
1931
+ built.packageMap = Object.fromEntries(Object.entries(packageMap).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
1932
+ built.manifests = manifests.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
1933
+ }
1934
+ if (want.contents) {
1935
+ const contents = {};
1936
+ for (const p of [...sel.changed, ...sel.pairedTests].sort()) {
1937
+ const body = read.get(p);
1938
+ contents[p] = body != null && !body.includes("\0") ? body : null;
1939
+ }
1940
+ built.contents = contents;
1941
+ }
1942
+
1943
+ // The selections, given rather than collected — the runners ran before this
1944
+ // function was called, because reaching one is the only part of the bundle
1945
+ // the two producers do differently. `normaliseListTests` is what they share:
1946
+ // the sort that makes the bytes repeatable, the refusal of a non-zero exit,
1947
+ // and `undefined` for "nothing survived" so the section is absent rather
1948
+ // than an empty promise.
1949
+ const listTests = normaliseListTests(input.listTests);
1950
+ if (want.listTests !== false && listTests) built.listTests = listTests;
1951
+
1952
+ // `sent` is DERIVED from what was built, never accepted from the caller.
1953
+ //
1954
+ // It used to be an input, and that made the one shape the server refuses
1955
+ // representable: a caller could promise three sections, the budget could run
1956
+ // out mid-read, and the rebuilt bundle would carry `sent.graph === true`
1957
+ // beside `graph: {edges: []}`. `sentAgreesWithBody` then 400s the whole
1958
+ // body at the door — losing the tree, which is the one section the no-App
1959
+ // path actually needs — and the only trace is a line in a local log while
1960
+ // the review has already paid its 30 s wait. Derived, the disagreement
1961
+ // cannot be written down.
1962
+ const absent = (k) => input.absences?.[k] ?? "not-collected";
1963
+ const sent = {
1964
+ tree: "tree" in built ? true : absent("tree"),
1965
+ graph: "graph" in built ? true : absent("graph"),
1966
+ packageMap: "packageMap" in built ? true : absent("packageMap"),
1967
+ contents: "contents" in built ? true : absent("contents"),
1968
+ listTests: "listTests" in built ? true : absent("listTests"),
1969
+ rerun: absent("rerun"),
1970
+ };
1971
+ return {
1972
+ v: EVIDENCE_V,
1973
+ producer: "client",
1974
+ ...(input.clientVersion ? { clientVersion: input.clientVersion } : {}),
1975
+ collectedAt: input.collectedAt,
1976
+ head: input.head,
1977
+ ...(input.rootAbs ? { rootAbs: input.rootAbs } : {}),
1978
+ defaultBranch: input.defaultBranch ?? null,
1979
+ porcelainClean: input.porcelainClean,
1980
+ sent,
1981
+ // A budget names itself only when it actually cost a section, because the
1982
+ // server checks that pair in both directions.
1983
+ partial: Object.values(sent).some((v) => v === "time-budget" || v === "size-cap")
1984
+ ? (input.partial ?? null)
1985
+ : null,
1986
+ ...built,
1987
+ };
1988
+ }
1989
+
1990
+ /**
1991
+ * The selections that are worth sending, in an order that is the same twice.
1992
+ * The twin of `git-source.ts`'s function of the same name, and the parity test
1993
+ * runs both over the same input.
1994
+ *
1995
+ * ORDER IS BYTES. `bundleHash` keys `(conversation_id, bundle_hash)`, which is
1996
+ * what a second Stop on an unchanged tree dedupes on, and `canonicalEvidence`
1997
+ * blanks only `at`. Neither jest nor vitest promises an order, so without the
1998
+ * sort the same repository would hash differently twice and the review would
1999
+ * pay for an analysis of evidence it already held.
2000
+ *
2001
+ * A NON-ZERO EXIT IS NOT A SELECTION. The runner said it could not answer; what
2002
+ * it printed before saying so is whatever it got through, and a completeness
2003
+ * claim built on that is the one thing this section must never license.
2004
+ */
2005
+ export function normaliseListTests(entries) {
2006
+ if (!entries?.length) return undefined;
2007
+ const kept = entries
2008
+ .filter((e) => e.exitCode === 0)
2009
+ .map((e) => ({ ...e, files: [...new Set(e.files)].sort() }))
2010
+ .sort((a, b) =>
2011
+ a.cwd < b.cwd ? -1 : a.cwd > b.cwd ? 1 : a.command < b.command ? -1 : a.command > b.command ? 1 : 0,
2012
+ );
2013
+ return kept.length ? kept : undefined;
2014
+ }
2015
+
2016
+ // ───────────────────────────────── collection ─────────────────────────────────
2017
+
2018
+ /** The budget the whole collection lives inside. Measured, not guessed — see `collectEvidence`. */
2019
+ export const COLLECT_BUDGET_MS = 20_000;
2020
+
2021
+ /**
2022
+ * The slice of the collection the runners may have between them, and what one
2023
+ * of them may have alone.
2024
+ *
2025
+ * MEASURED on `~/medusa` with its dependencies installed:
2026
+ *
2027
+ * jest --listTests, repository root 1 063 ms (460 files)
2028
+ * jest --listTests, one package 580 ms (3 files)
2029
+ * vitest list --filesOnly, root 568 ms (1 594 files)
2030
+ * vitest list --filesOnly, one package 668 ms (21 files)
2031
+ * vitest list, root, NO --filesOnly 74 260 ms exit 1
2032
+ *
2033
+ * The last line is why `--filesOnly` is not a detail. Without it `vitest list`
2034
+ * IMPORTS every file it finds in order to print the test names inside them —
2035
+ * which is both the wrong answer (names, not files) and a minute and a
2036
+ * quarter of somebody's laptop, ending in a non-zero exit because a monorepo
2037
+ * root sweeps in jest-only suites and half-configured doc sites. With it,
2038
+ * nothing is imported at all.
2039
+ *
2040
+ * The per-command ceiling is between five and ten times the slowest thing
2041
+ * measured, and the total is a third of the collection: a repository slower
2042
+ * than medusa may cost us a selection, and must not be able to cost us the
2043
+ * tree.
2044
+ */
2045
+ export const LIST_TESTS_BUDGET_MS = 7_000;
2046
+ const LIST_TESTS_PER_COMMAND_MS = 6_000;
2047
+
2048
+ /**
2049
+ * How many distinct runner invocations the session may ask for.
2050
+ *
2051
+ * A session runs the same command over and over — F3 ran one command seven
2052
+ * times, T1 fourteen — so identical invocations collapse to one question long
2053
+ * before this bites. What it bounds is a session that ran genuinely different
2054
+ * commands in genuinely different packages, where each one is a separate
2055
+ * process start.
2056
+ */
2057
+ const LIST_TESTS_MAX_COMMANDS = 4;
2058
+
2059
+ /** How many paths a tree may carry before we stop listing and say so. */
2060
+ export const TREE_CAP = 200_000;
2061
+
2062
+ /** The bundle body we refuse to build rather than send. Well under the server's 32 MiB. */
2063
+ export const BODY_CAP_BYTES = 24_000_000;
2064
+
2065
+ /**
2066
+ * `git`, resolved to an absolute path on Windows before it is spawned.
2067
+ *
2068
+ * `hook.mjs` already carries `resolveTool` for this and the reason is not
2069
+ * cosmetic: on Windows `execFileSync` with a bare name searches the CURRENT
2070
+ * DIRECTORY first, so a repository containing a `git.exe` — a fixture, a
2071
+ * vendored binary, a file an attacker put there — would be run instead of the
2072
+ * real one, inside a process holding the developer's API key. This file was
2073
+ * written with the bare name and re-opened that hole; it resolves the same way
2074
+ * the hook does, and falls back to the bare name only where resolution is not
2075
+ * a question (POSIX).
2076
+ */
2077
+ const GIT = resolveGit();
2078
+
2079
+ function resolveGit() {
2080
+ if (process.platform !== "win32") return "git";
2081
+ const exts = (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean);
2082
+ for (const dir of (process.env.PATH ?? "").split(";").filter(Boolean)) {
2083
+ for (const ext of exts) {
2084
+ const candidate = join(dir, `git${ext}`);
2085
+ try {
2086
+ accessSync(candidate, constants.X_OK);
2087
+ return candidate;
2088
+ } catch {
2089
+ /* keep looking */
2090
+ }
2091
+ }
2092
+ }
2093
+ return "git";
2094
+ }
2095
+
2096
+ function git(root, args, timeoutMs) {
2097
+ return execFileSync(GIT, ["-C", root, ...args], {
2098
+ encoding: "utf8",
2099
+ timeout: timeoutMs,
2100
+ maxBuffer: 1 << 28,
2101
+ stdio: ["ignore", "pipe", "ignore"],
2102
+ windowsHide: true,
2103
+ });
2104
+ }
2105
+
2106
+ /**
2107
+ * The repository's own idea of its default branch, read locally.
2108
+ *
2109
+ * `git symbolic-ref refs/remotes/origin/HEAD` is the only answer that is a
2110
+ * FACT rather than a guess: it is what the remote said when the clone was made
2111
+ * or `set-head` was last run. Measured at 9.5 ms on a 24 000-file repository —
2112
+ * the cheapest fact in this file, and the one the server otherwise pays a
2113
+ * GitHub API call for and, with no App installation, cannot get at all.
2114
+ *
2115
+ * `null` when the ref is missing, which is common enough to be ordinary: a
2116
+ * clone made with `--single-branch`, a worktree, a repository with no `origin`.
2117
+ * A null must travel as a null — the server's own fallback chain already ends
2118
+ * in a guess that says it is one, and a client inventing "main" here would
2119
+ * turn that honest guess into a false fact about somebody's repository.
2120
+ */
2121
+ export function defaultBranchOf(root, timeoutMs) {
2122
+ try {
2123
+ const ref = git(root, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], timeoutMs).trim();
2124
+ if (!ref) return null;
2125
+ const slash = ref.indexOf("/");
2126
+ return slash < 0 ? ref : ref.slice(slash + 1);
2127
+ } catch {
2128
+ return null;
2129
+ }
2130
+ }
2131
+
2132
+ /**
2133
+ * The paths this session changed, as the client can know them.
2134
+ *
2135
+ * The stand takes them from the pull request's `merge-base(base, head)..head`.
2136
+ * A hook at Stop has no pull request base to ask about, so it asks the same
2137
+ * question of the same merge-base against the repository's own default branch,
2138
+ * which is what the pull request will be opened against in all but the unusual
2139
+ * case. Where the two disagree the bundle's `contents` covers a different set
2140
+ * of files than the server's diff calls changed — nothing here can prevent
2141
+ * that, and it is why the bundle carries `head` for the server to check.
2142
+ *
2143
+ * Uncommitted work is deliberately NOT included. `porcelainClean` already says
2144
+ * the tree is not the commit, and a changed-path list that mixed committed and
2145
+ * uncommitted files would make `head` a lie about its own contents.
2146
+ */
2147
+ export function changedPathsOf(root, defaultBranch, timeoutMs) {
2148
+ if (!defaultBranch) return [];
2149
+ try {
2150
+ const base = git(root, ["merge-base", `origin/${defaultBranch}`, "HEAD"], timeoutMs).trim();
2151
+ if (!base) return [];
2152
+ const out = git(root, ["diff", "--name-only", "-z", `${base}..HEAD`], timeoutMs);
2153
+ return out.split("\0").filter(Boolean);
2154
+ } catch {
2155
+ return [];
2156
+ }
2157
+ }
2158
+
2159
+ // ──────────────────────────── the runner's own selection ────────────────────────────
2160
+ //
2161
+ // WHAT THIS DOES THAT NOTHING HERE DID BEFORE: it starts a program out of the
2162
+ // developer's repository. `jest --listTests` evaluates `jest.config.js` — 71 of
2163
+ // them in medusa — and `vitest list --filesOnly` evaluates a vite config. That
2164
+ // is repository code, and until now the collector only ever READ files.
2165
+ //
2166
+ // The reason it is nevertheless the right trade, stated rather than assumed:
2167
+ // every command asked here is a command THIS SESSION ALREADY RAN, minus the
2168
+ // execution. The developer typed it or their agent did, on this machine, in
2169
+ // this directory, and the tests themselves ran. Asking the same runner the
2170
+ // weaker question — which files would you select — adds no capability the
2171
+ // session did not already exercise.
2172
+ //
2173
+ // What it does NOT do, each a deliberate refusal:
2174
+ // · no shell, ever — `execFileSync` with an argv array, so a `;` or a `$(…)`
2175
+ // inside a session's command line is an argument and not a command;
2176
+ // · no `npx`, which may reach the network and install;
2177
+ // · no script alias (`npm test`, `yarn test:integration`) — the body of a
2178
+ // script is not ours to read, and `yarn foo --listTests` passes a flag to
2179
+ // something that may simply run the whole suite. T2 of the corpus is
2180
+ // exactly this shape, and it gets nothing;
2181
+ // · no bare command name — the binary is resolved to an absolute path under
2182
+ // the repository's own `node_modules`, for the reason `hook.mjs` gives at
2183
+ // `resolveTool`: on Windows a child's working directory is searched before
2184
+ // PATH, and this child's working directory is a repository.
2185
+
2186
+ /** Runner binaries we know how to ask, and how to ask them. */
2187
+ /**
2188
+ * A line that could be a path a runner printed: an optional drive or leading
2189
+ * slash, segments, and an extension a test file can have. Deliberately narrow
2190
+ * — the two runners we ask print nothing else, so anything wider would be
2191
+ * admitting text we have no reason to admit.
2192
+ */
2193
+ const LIST_LINE_RX = /^(?:[A-Za-z]:)?[\w./@$~+-]+\.(?:[cm]?[jt]sx?|py|rb|go|rs)$/;
2194
+
2195
+ const RUNNERS = {
2196
+ jest: { listedBy: "jest-listTests", argv: (args) => [...args, "--listTests"] },
2197
+ // `list` is a SUBCOMMAND and replaces `run`; `--filesOnly` is what keeps it
2198
+ // from importing every file it finds — measured at 74 s and a non-zero exit
2199
+ // without it, 0.57 s with it.
2200
+ vitest: { listedBy: "vitest-list", argv: (args) => ["list", "--filesOnly", ...args.filter((a) => a !== "run")] },
2201
+ };
2202
+
2203
+ /** Tokens of one command line, quotes kept whole, quotes then stripped. */
2204
+ function shellWords(segment) {
2205
+ return (segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((t) =>
2206
+ (t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")) ? t.slice(1, -1) : t,
2207
+ );
2208
+ }
2209
+
2210
+ /**
2211
+ * Which runner a token names, if any.
2212
+ *
2213
+ * A path is read by its LAST segment so `../../../node_modules/.bin/vitest`
2214
+ * and `node_modules/.bin/jest` are recognised — the two spellings the corpus
2215
+ * actually uses — while `jest.config.js` is not a runner and neither is a
2216
+ * directory called `jest`.
2217
+ */
2218
+ function runnerOfToken(token) {
2219
+ const base = token.slice(token.lastIndexOf("/") + 1);
2220
+ return base === "jest" || base === "vitest" ? base : null;
2221
+ }
2222
+
2223
+ // Shell plumbing, not runner arguments — and the two shapes are told apart,
2224
+ // which they were not.
2225
+ //
2226
+ // `2>&1`, `>&2` a redirect whose target is a FILE DESCRIPTOR, written in
2227
+ // the token itself. Nothing follows it.
2228
+ // `>`, `2>`, `>>`, `&>`, `<` a bare operator whose target is the NEXT
2229
+ // token, which is a filename and not an argument.
2230
+ // `>out.log`, `2>/dev/null` operator and target in one token.
2231
+ //
2232
+ // One pattern matched all three and skipped the next token for the first two
2233
+ // alike, so `jest 2>&1 --forceExit` lost `--forceExit` and the runner was
2234
+ // asked a different question from the one the session ran.
2235
+ const FD_REDIRECT_RX = /^&?\d*[<>]{1,2}&\d+$/;
2236
+ const BARE_REDIRECT_RX = /^&?\d*[<>]{1,2}$/;
2237
+ const REDIRECT_WITH_TARGET_RX = /^&?\d*[<>]{1,2}\S+$/;
2238
+
2239
+ function withoutRedirects(words) {
2240
+ const out = [];
2241
+ for (let i = 0; i < words.length; i++) {
2242
+ const w = words[i];
2243
+ if (FD_REDIRECT_RX.test(w)) continue;
2244
+ if (BARE_REDIRECT_RX.test(w)) {
2245
+ i += 1; // the filename is the next token
2246
+ continue;
2247
+ }
2248
+ if (REDIRECT_WITH_TARGET_RX.test(w)) continue;
2249
+ out.push(w);
2250
+ }
2251
+ return out;
2252
+ }
2253
+
2254
+ /**
2255
+ * The runner invocations on one command line, with the directory each was
2256
+ * asked from.
2257
+ *
2258
+ * `from` is where the shell STARTED — the transcript stamps a `cwd` on every
2259
+ * entry and it moves as the session moves — and `cd` walks from there.
2260
+ * Measured, and the size of the mistake is the reason both halves are needed:
2261
+ * `jest --listTests` answers 460 files at medusa's root and 5 one package
2262
+ * down, and of the corpus's runs, some carry a `cd` on the line while others
2263
+ * were simply started somewhere else. A selection taken in the wrong directory
2264
+ * is not a worse answer to the question — it is a confident answer to a
2265
+ * different one, which occasionally, by accident, has the right number of
2266
+ * files in it.
2267
+ *
2268
+ * Segments split on the operators that START a new command. A `|` ends the
2269
+ * runner's arguments, which is the point: `jest x | tail -20` is the corpus's
2270
+ * most common shape and the `tail` is not part of the question.
2271
+ *
2272
+ * A `cd` we cannot compute (`cd -`, `cd $VAR`, `cd ~`) or one that leaves the
2273
+ * repository abandons the whole LINE rather than guessing, because everything
2274
+ * after it is somewhere we cannot name.
2275
+ */
2276
+ export function runnerInvocations(commandLine, from = "", root = null) {
2277
+ if (typeof commandLine !== "string" || !commandLine) return [];
2278
+ // A HEREDOC BODY IS NOT A COMMAND. `cat > notes.md <<EOF` followed by a line
2279
+ // reading `npx jest --config /tmp/x.js` is one command writing a file, and
2280
+ // the text inside it was never run by anything. Split on newlines — which
2281
+ // every other shape here needs — and that text becomes an invocation we
2282
+ // would go and perform, with arguments nobody chose, breaking the one claim
2283
+ // this whole capability rests on: that we ask only what the session already
2284
+ // ran. The body cannot be told from the command around it without a shell
2285
+ // parser, so a line carrying a heredoc gives up all of its invocations.
2286
+ if (/<<-?\s*['"\\]?\w/.test(commandLine)) return [];
2287
+
2288
+ const out = [];
2289
+ let dir = from;
2290
+ for (const raw of commandLine.split(/\n|&&|\|\||;|\||\$\(|`/)) {
2291
+ // A SEGMENT WITH AN ODD QUOTE IS HALF A STRING. The split above is blind
2292
+ // to quoting, so `jest -t "a | b"` breaks in the middle of an argument and
2293
+ // leaves `-t "a` behind — a different question, asked confidently. The
2294
+ // whole LINE goes, because the other half of that string is in another
2295
+ // segment and no part of it can be trusted.
2296
+ if (((raw.match(/"/g) ?? []).length % 2) || ((raw.match(/'/g) ?? []).length % 2)) return [];
2297
+ const words = withoutRedirects(shellWords(raw.trim()));
2298
+ if (!words.length) continue;
2299
+
2300
+ // `(cd pkg && jest …)` — the group's paren rides on the first word, so
2301
+ // without this the `cd` is not a `cd` and the runner is asked from
2302
+ // wherever the shell was standing. `replayableCommandOf` strips the same
2303
+ // paren on the other side, for the same reason.
2304
+ if (words[0] === "(") words.shift();
2305
+ else if (words[0]?.startsWith("(")) words[0] = words[0].slice(1);
2306
+ if (!words.length) continue;
2307
+
2308
+ if (words[0] === "cd") {
2309
+ const target = words[1];
2310
+ if (!target || /^[-~$]/.test(target)) return [];
2311
+ // An ABSOLUTE `cd` is ordinary here and perfectly computable — `cd
2312
+ // /Users/x/medusa/packages/core/js-sdk && yarn jest …` is M2's own
2313
+ // command line, and M2 is the one session of the corpus the stand can
2314
+ // reproduce at all. It needs the repository root to become a repository
2315
+ // path; without one there is nothing to measure it against, and a
2316
+ // destination we cannot name abandons the line.
2317
+ if (target.startsWith("/") || /^[A-Za-z]:/.test(target)) {
2318
+ const rel = root ? relativeTo(root, target) : null;
2319
+ if (rel == null) return [];
2320
+ dir = rel;
2321
+ continue;
2322
+ }
2323
+ dir = normaliseDir(dir ? `${dir}/${target}` : target);
2324
+ if (dir == null) return []; // climbed out of the repository
2325
+ continue;
2326
+ }
2327
+
2328
+ // Leading env assignments (`TZ=UTC jest …`) and the wrappers that are not
2329
+ // the command. `npx` and `yarn` are dropped on purpose: they are ways to
2330
+ // REACH a runner, and we reach it ourselves, from `node_modules/.bin`. A
2331
+ // wrapper followed by something that is not a runner — `yarn test`,
2332
+ // `yarn test:integration:http` — is a SCRIPT, whose body we cannot read,
2333
+ // and it falls through to nothing rather than to a guess.
2334
+ let i = 0;
2335
+ let scoped = false;
2336
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i++;
2337
+ while (i < words.length && ["sudo", "command", "env", "time", "npx", "yarn", "pnpm", "bunx", "exec"].includes(words[i])) {
2338
+ i++;
2339
+ // A FLAG ON THE WRAPPER IS NOT NOISE. `pnpm --filter @scope/pkg jest`
2340
+ // and `yarn --cwd packages/app jest` decide WHICH package the runner
2341
+ // runs in, and we drop the flag and then ask the runner from the
2342
+ // directory the shell was standing in — a different question with a
2343
+ // different answer. We cannot reproduce it and must not pretend to.
2344
+ while (i < words.length && words[i].startsWith("-")) {
2345
+ scoped = true;
2346
+ i += words[i].includes("=") ? 1 : 2;
2347
+ }
2348
+ }
2349
+ if (i >= words.length || scoped) continue;
2350
+
2351
+ const runner = runnerOfToken(words[i]);
2352
+ if (!runner) continue;
2353
+ // …and the group's CLOSING paren rides on the last argument the same way
2354
+ // its opener rode on the first: `(cd pkg && jest a)` leaves `a)`.
2355
+ const args = words.slice(i + 1);
2356
+ const last = args.length - 1;
2357
+ if (last >= 0 && args[last].endsWith(")") && !args[last].includes("(")) args[last] = args[last].slice(0, -1);
2358
+ out.push({ runner, dir, args: args.filter(Boolean) });
2359
+ }
2360
+ return out;
2361
+ }
2362
+
2363
+ /** `a/./b`, `a/b/../c` and a trailing slash collapsed; a path that climbs out is refused. */
2364
+ function normaliseDir(dir) {
2365
+ const parts = [];
2366
+ for (const seg of dir.split("/")) {
2367
+ if (!seg || seg === ".") continue;
2368
+ if (seg === "..") {
2369
+ if (!parts.length) return null;
2370
+ parts.pop();
2371
+ continue;
2372
+ }
2373
+ parts.push(seg);
2374
+ }
2375
+ return parts.join("/");
2376
+ }
2377
+
2378
+ /**
2379
+ * Every Bash command line the session ran that could name a runner, newest
2380
+ * first, each with the directory the shell was standing in when it ran.
2381
+ *
2382
+ * The same walk `hook.mjs` already does for the pull-request receipt, over the
2383
+ * same `tool_use` blocks — plus the entry's own `cwd`, which Claude Code
2384
+ * stamps on every line and MOVES as the session moves. Measured on a corpus
2385
+ * transcript: one entry reads `/Users/admin/medusa`, the next
2386
+ * `/Users/admin/medusa/packages/core/js-sdk`. Without it, a command with no
2387
+ * `cd` of its own would be asked at the repository root, which is a different
2388
+ * question with a different answer.
2389
+ *
2390
+ * Newest first because a session's last runs are the ones a verdict is about,
2391
+ * and the cap cuts the tail rather than the head.
2392
+ */
2393
+ export function commandsFromTranscript(sessionData) {
2394
+ if (typeof sessionData !== "string" || !sessionData) return [];
2395
+ const out = [];
2396
+ // `tool_use` and its `tool_result` are different transcript ENTRIES, so a
2397
+ // command waits here until its own result says it ran.
2398
+ const pending = new Map();
2399
+ for (const line of sessionData.split("\n")) {
2400
+ // Cheap prefilter before JSON.parse over megabytes: only a line that names
2401
+ // a runner, or answers one, can matter. A result carries the id of the
2402
+ // call it answers and none of the words, so `tool_result` earns its own
2403
+ // clause rather than riding on the runner's name.
2404
+ if (!line || (!line.includes("jest") && !line.includes("vitest") && !line.includes("tool_result"))) continue;
2405
+ let entry;
2406
+ try {
2407
+ entry = JSON.parse(line);
2408
+ } catch {
2409
+ continue; // a tail-truncated transcript starts mid-object
2410
+ }
2411
+ const content = entry?.message?.content;
2412
+ if (!Array.isArray(content)) continue;
2413
+ const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
2414
+ for (const block of content) {
2415
+ if (block?.type === "tool_use") {
2416
+ const command = block.input?.command;
2417
+ if (typeof command === "string" && command) pending.set(block.id, { command, cwd });
2418
+ continue;
2419
+ }
2420
+ // A TOOL CALL IS NOT A COMMAND THE SESSION RAN UNTIL IT RAN.
2421
+ //
2422
+ // The whole licence for starting a runner out of somebody's repository
2423
+ // is that this session already ran this command; a call the developer
2424
+ // DENIED, or one the tool refused, was never run, and asking its runner
2425
+ // would be us running something on their machine that they did not.
2426
+ // Claude Code files the denial as an errored `tool_result`, which is
2427
+ // the same shape `prFromSessionReceipt` already reads for its own
2428
+ // reason.
2429
+ if (block?.type === "tool_result" && block.is_error !== true) {
2430
+ const seen = pending.get(block.tool_use_id);
2431
+ if (seen) out.push(seen);
2432
+ }
2433
+ }
2434
+ }
2435
+ return out.reverse();
2436
+ }
2437
+
2438
+ /**
2439
+ * The distinct runner questions this session raises, newest first.
2440
+ *
2441
+ * De-duplicated by runner, directory and argument list, because a session runs
2442
+ * the same command over and over — seven times in F3, fourteen in T1 — and
2443
+ * each repetition is the same question with the same answer.
2444
+ *
2445
+ * A command whose recorded directory is outside the repository is dropped
2446
+ * rather than asked at the root: the agent was somewhere else, and a selection
2447
+ * from here would describe a tree it never looked at.
2448
+ */
2449
+ export function listTestsPlan(entries, root) {
2450
+ const seen = new Set();
2451
+ const plan = [];
2452
+ for (const e of entries) {
2453
+ const line = typeof e === "string" ? e : e.command;
2454
+ const cwd = typeof e === "string" ? null : e.cwd;
2455
+ let from = "";
2456
+ if (cwd) {
2457
+ const rel = relativeTo(root, cwd);
2458
+ if (rel == null) continue;
2459
+ from = rel;
2460
+ }
2461
+ for (const inv of runnerInvocations(line, from, root)) {
2462
+ if (inv.dir == null) continue;
2463
+ const key = `${inv.runner}${inv.dir}${inv.args.join("")}`;
2464
+ if (seen.has(key)) continue;
2465
+ seen.add(key);
2466
+ plan.push({ ...inv, command: line.trim() });
2467
+ }
2468
+ }
2469
+ return plan;
2470
+ }
2471
+
2472
+ /**
2473
+ * The runner binary for a directory, resolved to an absolute path inside the
2474
+ * repository and nowhere else.
2475
+ *
2476
+ * Walks up from the directory the command ran in, exactly as Node's own
2477
+ * resolution does, and stops at the repository root: a runner found above the
2478
+ * root is a global install, and a bundle must not be able to describe a
2479
+ * repository through a tool that is not part of it.
2480
+ */
2481
+ export function runnerBinary(root, dir, runner) {
2482
+ const exts = process.platform === "win32" ? [".cmd", ".CMD", ".exe", ".EXE", ""] : [""];
2483
+ let at = dir;
2484
+ for (;;) {
2485
+ for (const ext of exts) {
2486
+ const candidate = join(root, at, "node_modules", ".bin", `${runner}${ext}`);
2487
+ try {
2488
+ accessSync(candidate, constants.X_OK);
2489
+ return candidate;
2490
+ } catch {
2491
+ /* keep walking up */
2492
+ }
2493
+ }
2494
+ if (!at) return null;
2495
+ const slash = at.lastIndexOf("/");
2496
+ at = slash < 0 ? "" : at.slice(0, slash);
2497
+ }
2498
+ }
2499
+
2500
+ /**
2501
+ * One runner, one question, one answer — or nothing.
2502
+ *
2503
+ * `files` come back in two shapes and both are resolved HERE, where the
2504
+ * directory they were printed in is known: jest prints absolute paths, vitest
2505
+ * prints paths relative to its own root, which is the directory it was started
2506
+ * in. Repo-relative is the only spelling that travels, and a path that does not
2507
+ * land inside the repository is dropped — a selection naming somebody's disk is
2508
+ * not evidence about this repository.
2509
+ *
2510
+ * A path that is dropped takes the WHOLE selection with it. A short list is
2511
+ * indistinguishable from a complete one, and completeness is the only thing
2512
+ * this section is for.
2513
+ */
2514
+ export function askRunner(input) {
2515
+ const { root, plan, timeoutMs, now } = input;
2516
+ const bin = runnerBinary(root, plan.dir, plan.runner);
2517
+ if (!bin) return { ok: false, reason: "tool-missing" };
2518
+ const spec = RUNNERS[plan.runner];
2519
+ const cwd = plan.dir ? join(root, plan.dir) : root;
2520
+ const at = new Date(now()).toISOString();
2521
+
2522
+ let stdout;
2523
+ let exitCode = 0;
2524
+ try {
2525
+ stdout = execFileSync(bin, spec.argv(plan.args), {
2526
+ cwd,
2527
+ encoding: "utf8",
2528
+ timeout: timeoutMs,
2529
+ maxBuffer: 1 << 26,
2530
+ // stdin closed so a runner that decides to prompt cannot hang a detached
2531
+ // process nobody is watching; stderr discarded because it is the
2532
+ // repository's own noise and we only publish the answer.
2533
+ stdio: ["ignore", "pipe", "ignore"],
2534
+ // SIGTERM is a REQUEST. A runner that installs a handler and ignores it
2535
+ // keeps a detached process nobody is watching alive past every budget
2536
+ // here; `execFileSync`'s timeout only promises to ask. This is a listing
2537
+ // that must not outlive its own ceiling, so it is not asked.
2538
+ killSignal: "SIGKILL",
2539
+ windowsHide: true,
2540
+ // The runner must not decide it is interactive, and must not colour the
2541
+ // paths we are about to parse.
2542
+ env: { ...process.env, CI: "1", NO_COLOR: "1", FORCE_COLOR: "0" },
2543
+ });
2544
+ } catch (e) {
2545
+ // A non-zero exit, a timeout or a signal. `status` is null for a signal,
2546
+ // which is not a zero and must not become one.
2547
+ exitCode = typeof e?.status === "number" ? e.status : 1;
2548
+ if (exitCode === 0) exitCode = 1;
2549
+ stdout = typeof e?.stdout === "string" ? e.stdout : "";
2550
+ }
2551
+
2552
+ // EVERY LINE MUST BE A PATH, or this is not a file list.
2553
+ //
2554
+ // Nothing here knows what the runner printed. A deprecation notice, a
2555
+ // progress line, an experimental-warning banner — any of them, read as a
2556
+ // path, becomes a "file" in the bundle: unbounded text the redactor does not
2557
+ // touch (it masks `command`, never `files`), and a count that the gate's
2558
+ // agreement with `declaredTotal` is then measured against. Both jest's
2559
+ // `--listTests` and vitest's `list --filesOnly` print paths and nothing else
2560
+ // — measured — so a line that is not one means the output is not the output
2561
+ // we think we are reading, and the whole list goes rather than the line.
2562
+ const files = [];
2563
+ for (const raw of stdout.split("\n")) {
2564
+ const line = raw.trim();
2565
+ if (!line) continue;
2566
+ if (!LIST_LINE_RX.test(line)) return { ok: false, reason: "unreadable-output" };
2567
+ const abs = line.startsWith("/") || /^[A-Za-z]:[\\/]/.test(line);
2568
+ const joined = abs ? line : plan.dir ? `${plan.dir}/${line}` : line;
2569
+ const rel = abs ? relativeTo(root, line) : normaliseDir(joined);
2570
+ // Not inside the repository, or a spelling we could not normalise. Either
2571
+ // way the list we are holding is not the list the runner printed.
2572
+ if (!rel) return { ok: false, reason: "off-repo" };
2573
+ files.push(rel);
2574
+ }
2575
+
2576
+ return {
2577
+ ok: true,
2578
+ entry: {
2579
+ // THE RUNNER SEGMENT, not the line it sat on.
2580
+ //
2581
+ // The line is what the reader matches a selection to a RUN by, and one
2582
+ // line can hold two runs: `jest a && jest b` is two invocations, and the
2583
+ // corpus has exactly that shape (M1 chains two jest calls in one Bash
2584
+ // call). Sending the line would give both entries an identical
2585
+ // (directory, command) key, and the gate would then pick between them by
2586
+ // timestamp — stamping one run with the other one's file list, which is
2587
+ // a false witness built out of nothing but a shell operator.
2588
+ command: `${plan.runner} ${plan.args.join(" ")}`.trim(),
2589
+ runner: plan.runner,
2590
+ listedBy: spec.listedBy,
2591
+ cwd: plan.dir,
2592
+ cwdAbs: cwd,
2593
+ head: "",
2594
+ exitCode,
2595
+ files,
2596
+ at,
2597
+ },
2598
+ };
2599
+ }
2600
+
2601
+ /** `path` under `root`, repo-relative — or null when it is not under it at all. */
2602
+ function relativeTo(root, path) {
2603
+ const r = root.replace(/\/+$/, "");
2604
+ const p = path.replace(/\\/g, "/");
2605
+ const base = r.replace(/\\/g, "/");
2606
+ if (p === base) return "";
2607
+ if (!p.startsWith(`${base}/`)) return null;
2608
+ return p.slice(base.length + 1);
2609
+ }
2610
+
2611
+ /**
2612
+ * Ask every runner the session used, inside a budget, and hand back what they
2613
+ * said.
2614
+ *
2615
+ * `head` is re-read after each answer and stamped on it, rather than copied
2616
+ * from the bundle. Collection is not instantaneous and a developer's hands do
2617
+ * not stop during it; a commit landing between `git rev-parse HEAD` at the top
2618
+ * of the collection and a runner answering at the bottom would leave the bundle
2619
+ * claiming a selection of a tree it is not about, with nothing to show for it.
2620
+ */
2621
+ export function collectListTests(input) {
2622
+ const { root, plan, budgetMs, now, headOf } = input;
2623
+ const started = now();
2624
+ const entries = [];
2625
+ let tooLate = false;
2626
+ let onlyToolMissing = true;
2627
+ for (const p of plan.slice(0, LIST_TESTS_MAX_COMMANDS)) {
2628
+ const left = budgetMs - (now() - started);
2629
+ if (left <= 0) {
2630
+ tooLate = true;
2631
+ break;
2632
+ }
2633
+ const r = askRunner({ root, plan: p, timeoutMs: Math.min(LIST_TESTS_PER_COMMAND_MS, left), now });
2634
+ if (!r.ok) {
2635
+ if (r.reason !== "tool-missing") onlyToolMissing = false;
2636
+ continue;
2637
+ }
2638
+ // A RUNNER THAT COULD NOT ANSWER IS NOT AN ENTRY. It was filtered at the
2639
+ // door and at the build, and filtering it here as well is what keeps
2640
+ // `sent.listTests` and the collector's own log saying the same thing: an
2641
+ // entry that survives to `buildEvidence` and is dropped there leaves the
2642
+ // section absent for the reason `collectListTests` had already decided was
2643
+ // `null`.
2644
+ if (r.entry.exitCode !== 0) {
2645
+ onlyToolMissing = false;
2646
+ continue;
2647
+ }
2648
+ // …and a head we could not read drops the ENTRY, never the bundle. The
2649
+ // door validates `head` as a sha, so an empty one 400s the WHOLE body —
2650
+ // tree included, the one section the no-App path cannot work without —
2651
+ // over a `git rev-parse` that blinked. That is the exact trade E1 spent a
2652
+ // rebuild to avoid, and it must not come back through a new section.
2653
+ // Charged against the SAME sub-budget: `headOf` is a `git rev-parse` with
2654
+ // its own timeout, once per entry, and four of them outside the ceiling
2655
+ // would put this block past the collection budget it is supposed to live
2656
+ // inside. A budget gone by now is the CLOCK's doing and says so — an
2657
+ // answer we threw away for time must not be filed as one we never asked
2658
+ // for.
2659
+ if (budgetMs - (now() - started) <= 0) {
2660
+ tooLate = true;
2661
+ break;
2662
+ }
2663
+ const head = headOf();
2664
+ if (!head) {
2665
+ onlyToolMissing = false;
2666
+ continue;
2667
+ }
2668
+ entries.push({ ...r.entry, head });
2669
+ }
2670
+ if (entries.length) return { entries, absence: null };
2671
+ // WHY there is nothing, in the vocabulary the registry counts. Each member is
2672
+ // a different debt and the ledger cannot tell a limit from a gap if two of
2673
+ // them are spelled the same way:
2674
+ //
2675
+ // `not-applicable` the repository asks no such question — the session ran
2676
+ // no runner we can reach at all;
2677
+ // `time-budget` we ran out before we could ask, and `partial` says so;
2678
+ // `tool-missing` the runner is genuinely not on this machine;
2679
+ // `not-collected` we asked and could not use the answer. It is the
2680
+ // weakest thing we can say and the only honest one: the
2681
+ // tool WAS there, so `tool-missing` would be false, and
2682
+ // the question DID arise, so `not-applicable` would be
2683
+ // too. There is no member for "it answered and the answer
2684
+ // named another repository", and inventing one on the
2685
+ // wire to say it would be a vocabulary nobody reads yet.
2686
+ if (!plan.length) return { entries: [], absence: "not-applicable" };
2687
+ if (tooLate) return { entries: [], absence: "time-budget" };
2688
+ return { entries: [], absence: onlyToolMissing ? "tool-missing" : "not-collected" };
2689
+ }
2690
+
2691
+ /**
2692
+ * Collect the bundle from a working tree, inside a budget, saying honestly
2693
+ * which sections the budget cost.
2694
+ *
2695
+ * MEASURED on `~/medusa` (24 043 paths, 11 923 source files, 47 MB of text),
2696
+ * warm cache, so the budget is a number rather than a hope:
2697
+ *
2698
+ * rev-parse ×2 19 ms
2699
+ * status --porcelain 330 ms
2700
+ * ls-files 15 ms
2701
+ * symbolic-ref origin/HEAD 9 ms
2702
+ * merge-base + diff 18 ms
2703
+ * read every source file 213 ms warm / 769 ms cold
2704
+ * extractModuleEdges over them 652 ms warm / 837 ms first (38 079 edges)
2705
+ * whole bundle 1 240-1 530 ms · 6.33 MB, 0.39 MB gzipped
2706
+ *
2707
+ * (Measured 27.09 on the graph format 2 lexer — JSX, the doubt rule, the
2708
+ * mocks, `uncertain`. The graph before `EVIDENCE_GRAPH_FORMAT`, relative specifiers only,
2709
+ * was 18 657 edges and a 4.18 MB bundle in 940 ms: every package specifier,
2710
+ * the kinds that do not load, and a lexer that knows JSX cost 2.1 MB and
2711
+ * about 0.2 s.)
2712
+ *
2713
+ * The budget is twenty times that, because the repository on the next machine
2714
+ * is not this one and a spinning disk is not this disk. It is not the ceiling
2715
+ * that matters anyway: the Stop hook itself is killed by Claude Code at 600 s
2716
+ * (measured — see `hook.mjs`), and this runs in a DETACHED process that
2717
+ * outlives the hook entirely, so the only thing the budget protects is the
2718
+ * developer's laptop, not the capture.
2719
+ *
2720
+ * The order is deliberate. The tree is first because it is nearly free and
2721
+ * because it alone unlocks `ranFiles` on a session with no App. The graph is
2722
+ * last of the expensive three because it is the one V7 needs and the one that
2723
+ * can be re-asked on the next Stop; losing it costs a capability, not a fact.
2724
+ */
2725
+ export function collectEvidence(opts) {
2726
+ const { clientVersion } = opts;
2727
+ const budgetMs = opts.budgetMs ?? COLLECT_BUDGET_MS;
2728
+ const startedAt = opts.now ? opts.now() : Date.now();
2729
+ const spent = () => (opts.now ? opts.now() : Date.now()) - startedAt;
2730
+ const left = () => budgetMs - spent();
2731
+ // Every git call is bounded by what remains, never by the whole budget: a
2732
+ // single blocked call must not be able to spend the time the rest needs.
2733
+ // Each git call gets what remains, but never less than a floor — and the
2734
+ // floor is generous on purpose. A previous call that ran long would
2735
+ // otherwise leave the next one a second, and `git status --porcelain` alone
2736
+ // was measured at 330 ms on a 24 000-file repository; on a cold cache or a
2737
+ // network filesystem a one-second ceiling turns an ordinary read into a
2738
+ // timeout, which discards the whole collection rather than a section.
2739
+ const gitTimeout = () => Math.max(5_000, left());
2740
+
2741
+ // THE ROOT IS RESOLVED, NOT ASSUMED, and it is the first thing done.
2742
+ //
2743
+ // Claude Code is routinely launched inside a package of a monorepo, and
2744
+ // `git ls-files` from there lists that subtree with paths relative to IT.
2745
+ // Every path in the bundle would then be in a different namespace from every
2746
+ // path the server knows, and nothing downstream could tell: the tree would
2747
+ // simply never contain the files the report asks about, and `ranFiles` would
2748
+ // read `unreadable` forever for a reason no log would name. The stand hit
2749
+ // exactly this, in the test written to catch it.
2750
+ let root;
2751
+ try {
2752
+ root = git(opts.root, ["rev-parse", "--show-toplevel"], gitTimeout()).trim() || opts.root;
2753
+ } catch {
2754
+ root = opts.root;
2755
+ }
2756
+
2757
+ let head;
2758
+ let porcelainClean;
2759
+ let treePaths;
2760
+ try {
2761
+ head = git(root, ["rev-parse", "HEAD"], gitTimeout()).trim();
2762
+ porcelainClean = diskIsTheCommit(root, gitTimeout());
2763
+ treePaths = git(root, ["ls-files", "-z"], gitTimeout()).split("\0").filter(Boolean);
2764
+ } catch (e) {
2765
+ // No head, no tree, no bundle. A repository we cannot read at all is not a
2766
+ // partial bundle — it is no bundle, and the caller says nothing rather than
2767
+ // sending an empty one that would read as "the repository is empty".
2768
+ return { bundle: null, reason: `git: ${e?.message ?? e}`, spentMs: spent() };
2769
+ }
2770
+
2771
+ const truncated = treePaths.length > TREE_CAP;
2772
+ if (truncated) treePaths = treePaths.slice(0, TREE_CAP);
2773
+ const tree = { paths: new Set(treePaths), truncated };
2774
+
2775
+ const defaultBranch = opts.defaultBranch ?? defaultBranchOf(root, gitTimeout());
2776
+ const changedPaths = opts.changedPaths ?? changedPathsOf(root, defaultBranch, gitTimeout());
2777
+ // A clone with no `refs/remotes/origin/HEAD` — `--single-branch`, a
2778
+ // worktree, no origin — gives no merge-base and therefore no changed paths.
2779
+ // `contents` would then be `{}` MARKED SENT, and the registry would count a
2780
+ // `contents`-closed gap as ours to answer on a section that describes
2781
+ // nothing. The question does not arise; say so.
2782
+ const changedKnown = changedPaths.length > 0 || (defaultBranch != null && opts.changedPaths !== undefined);
2783
+
2784
+ const base = {
2785
+ head,
2786
+ // Where the tree sits on this disk — what a reader places a run's
2787
+ // directory against (never the directory the session was launched in).
2788
+ rootAbs: root,
2789
+ defaultBranch,
2790
+ porcelainClean,
2791
+ collectedAt: new Date(startedAt).toISOString(),
2792
+ clientVersion,
2793
+ tree,
2794
+ changedPaths,
2795
+ };
2796
+
2797
+ // Every file is read once: a rebuild (the size cap below) takes what the
2798
+ // first build read rather than reading the repository again past a budget
2799
+ // that is already spent.
2800
+ const readCache = new Map();
2801
+ let readCut = false;
2802
+ const read = (paths) => {
2803
+ const out = new Map();
2804
+ for (const p of paths) {
2805
+ if (readCache.has(p)) {
2806
+ out.set(p, readCache.get(p));
2807
+ continue;
2808
+ }
2809
+ // A read that runs past the budget stops reading, and SAYS so. The
2810
+ // caller then rebuilds WITHOUT the sections this read fed, so nothing is
2811
+ // ever built out of half a repository — a section whose silence is our
2812
+ // clock rather than the repository's is worse than no section at all. A
2813
+ // graph missing the file that holds a mock loses a veto; a manifest
2814
+ // never read would travel as `readable: false`, a fact about a file we
2815
+ // did not look at.
2816
+ if (left() <= 0) {
2817
+ readCut = true;
2818
+ break;
2819
+ }
2820
+ let v;
2821
+ try {
2822
+ // A NUL byte is kept: buildEvidence decides what it means (lexed for
2823
+ // the graph, not carried as contents) — one rule for both producers.
2824
+ v = readFileSync(join(root, p)).toString("utf8");
2825
+ } catch {
2826
+ v = null;
2827
+ }
2828
+ readCache.set(p, v);
2829
+ out.set(p, v);
2830
+ }
2831
+ return out;
2832
+ };
2833
+
2834
+ // `rerun` is E3's. It is `not-collected` because we did not attempt it — a
2835
+ // different fact from running out of time, and it must not be spelled the
2836
+ // same way.
2837
+ const absences = { listTests: "not-collected", rerun: "not-collected" };
2838
+
2839
+ // ── the runners' own selections, BEFORE the file read.
2840
+ //
2841
+ // Ordered ahead of the expensive three for one reason: a read of eleven
2842
+ // thousand files that overruns would otherwise starve a question worth under
2843
+ // a second, and the selection is the only section here that cannot be asked
2844
+ // again later — it is about a tree that is already moving. The graph can be
2845
+ // re-asked on the next Stop; a selection of a commit the developer has since
2846
+ // left cannot.
2847
+ //
2848
+ // It has its own sub-budget so the ordering cannot be turned round: a
2849
+ // repository whose runner hangs costs a selection, never the tree.
2850
+ let listTests;
2851
+ let listTestsRanOut = false;
2852
+ if (opts.testCommands !== undefined) {
2853
+ const nowFn = opts.now ?? Date.now;
2854
+ const r = collectListTests({
2855
+ root,
2856
+ plan: listTestsPlan(opts.testCommands, root),
2857
+ budgetMs: Math.max(0, Math.min(LIST_TESTS_BUDGET_MS, left())),
2858
+ now: nowFn,
2859
+ // Re-read, not copied: see `EvidenceListTests.head`. A failure to read it
2860
+ // leaves the field empty, and an entry with no head is one the reader
2861
+ // refuses — which is the right way round for a fact about which commit
2862
+ // an answer is about.
2863
+ headOf: () => {
2864
+ try {
2865
+ return git(root, ["rev-parse", "HEAD"], 5_000).trim();
2866
+ } catch {
2867
+ return "";
2868
+ }
2869
+ },
2870
+ });
2871
+ if (r.entries.length) listTests = r.entries;
2872
+ else {
2873
+ absences.listTests = r.absence ?? "not-collected";
2874
+ // A SECTION THE CLOCK COST MUST HAVE A CLOCK TO POINT AT. The door
2875
+ // checks the pair in both directions: a section marked `time-budget`
2876
+ // beside a `partial` of `null` is refused, and the refusal is of the
2877
+ // WHOLE body — the tree with it. E1 spent a rebuild removing exactly
2878
+ // that trade and this section must not re-open it through its own
2879
+ // sub-budget, which can run out while the collection's has not.
2880
+ listTestsRanOut = r.absence === "time-budget";
2881
+ }
2882
+ }
2883
+
2884
+ // Attempt everything the budget has not already taken off the table. The
2885
+ // tree is free by now (it is in hand) and is the section the no-App path
2886
+ // cannot do without, so it is never given up.
2887
+ const enough = left() > 0;
2888
+ const want = { tree: true, graph: enough, packageMap: enough, contents: enough && changedKnown };
2889
+ if (!enough) for (const k of ["graph", "packageMap", "contents"]) absences[k] = "time-budget";
2890
+ else if (!changedKnown) absences.contents = "not-applicable";
2891
+
2892
+ // `listTests` rides in `base` rather than in `want`, and the difference is
2893
+ // the rebuild below. Every rebuild writes a FRESH `want` literal, so a
2894
+ // section gated on `want` is dropped by any literal that forgets to name it
2895
+ // — silently, because `sent` is derived and would simply stop saying `true`.
2896
+ // A selection already in hand costs nothing to carry and has no relationship
2897
+ // to the clock that cut the file read, so it survives every rebuild by
2898
+ // construction instead of by remembering.
2899
+ const build = (w, a, partial) =>
2900
+ buildEvidence({
2901
+ ...base,
2902
+ listTests,
2903
+ read: w.graph || w.packageMap || w.contents ? read : () => new Map(),
2904
+ want: w,
2905
+ absences: a,
2906
+ partial,
2907
+ });
2908
+
2909
+ let partial =
2910
+ enough && !listTestsRanOut ? null : { reason: "time-budget", budgetMs, spentMs: spent() };
2911
+ let bundle;
2912
+ try {
2913
+ bundle = build(want, absences, partial);
2914
+ } catch (e) {
2915
+ return { bundle: null, reason: `build: ${e?.message ?? e}`, spentMs: spent() };
2916
+ }
2917
+
2918
+ // The budget may have run out DURING the read. Rebuild without the three it
2919
+ // fed rather than ship sections that describe a repository we stopped
2920
+ // reading — and note that `sent` is derived, so the rebuilt bundle says
2921
+ // `time-budget` for exactly those three and stays coherent at the door.
2922
+ //
2923
+ // Whatever set `partial` first. It used to be asked only when `partial` was
2924
+ // still null, so a runner that had already spent the selection's sub-budget
2925
+ // let a half-read graph and half-read manifests ship as `sent: true`
2926
+ // (review of ENG-87, reproduced with an injected clock).
2927
+ if (readCut) {
2928
+ for (const k of ["graph", "packageMap", "contents"]) absences[k] = "time-budget";
2929
+ partial = { reason: "time-budget", budgetMs, spentMs: spent() };
2930
+ want.graph = want.packageMap = want.contents = false;
2931
+ bundle = build({ tree: true, graph: false, packageMap: false, contents: false }, absences, partial);
2932
+ }
2933
+
2934
+ // Over the cap, the sections go in order of size — the graph is the whole
2935
+ // repository, the manifests can carry verbatim `exports`, the contents are a
2936
+ // handful of files — and the reason names the one that was cut. A budget that
2937
+ // already cut something keeps its own reason in `partial`; the door accepts
2938
+ // two budgets cutting two sections.
2939
+ const serialise = () => {
2940
+ try {
2941
+ return JSON.stringify(bundle);
2942
+ } catch {
2943
+ return null;
2944
+ }
2945
+ };
2946
+ let body = serialise();
2947
+ // Largest first: one changed file of 30 MB must cost `contents`, not the
2948
+ // graph and the package map before it.
2949
+ const weight = (k) => {
2950
+ try {
2951
+ return JSON.stringify(k === "packageMap" ? [bundle.packageMap, bundle.manifests] : bundle[k] ?? null).length;
2952
+ } catch {
2953
+ return Infinity;
2954
+ }
2955
+ };
2956
+ for (const k of ["graph", "packageMap", "contents"].sort((a, b) => weight(b) - weight(a))) {
2957
+ if (body != null && Buffer.byteLength(body) <= BODY_CAP_BYTES) break;
2958
+ if (!want[k]) continue;
2959
+ want[k] = false;
2960
+ absences[k] = "size-cap";
2961
+ partial = partial ?? { reason: "size-cap", budgetMs, spentMs: spent() };
2962
+ bundle = build({ ...want }, absences, partial);
2963
+ body = serialise();
2964
+ }
2965
+ if (body == null) return { bundle: null, reason: "serialise: the bundle could not be written as JSON", spentMs: spent() };
2966
+ if (Buffer.byteLength(body) > BODY_CAP_BYTES) {
2967
+ return { bundle: null, reason: "size-cap: tree alone is over the cap", spentMs: spent() };
2968
+ }
2969
+
2970
+ // THE DISK MAY HAVE MOVED WHILE WE READ IT. `head` and `porcelainClean` were
2971
+ // asked before the runners and the file read — seconds on a large
2972
+ // repository, while the developer's next turn may already be editing — and
2973
+ // the graph, the contents and the selections were read from the disk, not
2974
+ // from the commit. Asked again after the last read: a bundle whose disk
2975
+ // stopped being its commit on the way says so, and the server then holds
2976
+ // none of it against the commit (a mock deleted from a test file mid-read
2977
+ // would otherwise ship as a mock-free test of a commit that has one).
2978
+ if (bundle.porcelainClean) {
2979
+ let still = false;
2980
+ try {
2981
+ still = git(root, ["rev-parse", "HEAD"], gitTimeout()).trim() === head && diskIsTheCommit(root, gitTimeout());
2982
+ } catch {
2983
+ still = false;
2984
+ }
2985
+ if (!still) bundle = { ...bundle, porcelainClean: false };
2986
+ }
2987
+
2988
+ return { bundle, reason: null, spentMs: spent(), defaultBranch, bytes: Buffer.byteLength(body) };
2989
+ }
2990
+
2991
+ /**
2992
+ * CLEAN MEANS THE DISK IS THE COMMIT, whatever the developer's git config
2993
+ * says to show. Plain `git status --porcelain` obeys
2994
+ * `status.showUntrackedFiles=no` (an untracked `cart.js` beside `cart.ts`
2995
+ * vanishes) and never reports a file flagged `assume-unchanged` or
2996
+ * `skip-worktree`, however much it differs from HEAD. A witness stands on this
2997
+ * answer being true (V7 review, 27.09), so both are asked explicitly. Throws
2998
+ * when git does; the caller decides what an unreadable repository means.
2999
+ */
3000
+ function diskIsTheCommit(root, timeoutMs) {
3001
+ if (git(root, ["status", "--porcelain", "--untracked-files=normal", "--ignore-submodules=none"], timeoutMs).trim() !== "") return false;
3002
+ return !git(root, ["ls-files", "-v"], timeoutMs)
3003
+ .split("\n")
3004
+ .some((l) => /^[a-zS] /.test(l));
3005
+ }