@videlic/connect 0.1.1 → 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/README.md +4 -0
- package/evidence.d.mts +172 -0
- package/evidence.mjs +3005 -0
- package/hook.mjs +1468 -42
- package/install.mjs +38 -4
- package/package.json +9 -1
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
|
+
}
|