diffninja 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/dist/calltree.d.ts +47 -0
  4. package/dist/calltree.js +296 -0
  5. package/dist/cli.d.ts +57 -0
  6. package/dist/cli.js +340 -0
  7. package/dist/diff.d.ts +7 -0
  8. package/dist/diff.js +114 -0
  9. package/dist/extract.d.ts +26 -0
  10. package/dist/extract.js +152 -0
  11. package/dist/git.d.ts +40 -0
  12. package/dist/git.js +288 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.js +8 -0
  15. package/dist/infer.d.ts +21 -0
  16. package/dist/infer.js +189 -0
  17. package/dist/languages/bash.d.ts +2 -0
  18. package/dist/languages/bash.js +208 -0
  19. package/dist/languages/c.d.ts +2 -0
  20. package/dist/languages/c.js +218 -0
  21. package/dist/languages/call-syntax.d.ts +125 -0
  22. package/dist/languages/call-syntax.js +997 -0
  23. package/dist/languages/cpp.d.ts +2 -0
  24. package/dist/languages/cpp.js +321 -0
  25. package/dist/languages/csharp.d.ts +2 -0
  26. package/dist/languages/csharp.js +324 -0
  27. package/dist/languages/elixir.d.ts +2 -0
  28. package/dist/languages/elixir.js +331 -0
  29. package/dist/languages/go.d.ts +2 -0
  30. package/dist/languages/go.js +299 -0
  31. package/dist/languages/grammars.d.ts +50 -0
  32. package/dist/languages/grammars.js +351 -0
  33. package/dist/languages/haskell.d.ts +2 -0
  34. package/dist/languages/haskell.js +250 -0
  35. package/dist/languages/java.d.ts +2 -0
  36. package/dist/languages/java.js +351 -0
  37. package/dist/languages/javascript.d.ts +4 -0
  38. package/dist/languages/javascript.js +648 -0
  39. package/dist/languages/kotlin.d.ts +2 -0
  40. package/dist/languages/kotlin.js +368 -0
  41. package/dist/languages/lua.d.ts +2 -0
  42. package/dist/languages/lua.js +212 -0
  43. package/dist/languages/ocaml.d.ts +2 -0
  44. package/dist/languages/ocaml.js +291 -0
  45. package/dist/languages/perl.d.ts +2 -0
  46. package/dist/languages/perl.js +418 -0
  47. package/dist/languages/php.d.ts +2 -0
  48. package/dist/languages/php.js +397 -0
  49. package/dist/languages/python.d.ts +2 -0
  50. package/dist/languages/python.js +376 -0
  51. package/dist/languages/registry.d.ts +7 -0
  52. package/dist/languages/registry.js +69 -0
  53. package/dist/languages/ruby.d.ts +2 -0
  54. package/dist/languages/ruby.js +391 -0
  55. package/dist/languages/rust.d.ts +2 -0
  56. package/dist/languages/rust.js +261 -0
  57. package/dist/languages/scala.d.ts +2 -0
  58. package/dist/languages/scala.js +307 -0
  59. package/dist/languages/solidity.d.ts +2 -0
  60. package/dist/languages/solidity.js +240 -0
  61. package/dist/languages/swift.d.ts +2 -0
  62. package/dist/languages/swift.js +268 -0
  63. package/dist/languages/types.d.ts +36 -0
  64. package/dist/languages/types.js +74 -0
  65. package/dist/languages/typescript-contracts.d.ts +57 -0
  66. package/dist/languages/typescript-contracts.js +528 -0
  67. package/dist/languages/typescript-dispatch.d.ts +68 -0
  68. package/dist/languages/typescript-dispatch.js +710 -0
  69. package/dist/languages/typescript.d.ts +4 -0
  70. package/dist/languages/typescript.js +722 -0
  71. package/dist/languages/zig.d.ts +2 -0
  72. package/dist/languages/zig.js +243 -0
  73. package/dist/loc.d.ts +17 -0
  74. package/dist/loc.js +34 -0
  75. package/dist/reach.d.ts +17 -0
  76. package/dist/reach.js +65 -0
  77. package/dist/render.d.ts +18 -0
  78. package/dist/render.js +83 -0
  79. package/dist/review/brand.d.ts +8 -0
  80. package/dist/review/brand.js +25 -0
  81. package/dist/review/call-context.d.ts +27 -0
  82. package/dist/review/call-context.js +446 -0
  83. package/dist/review/call-flow-html.d.ts +32 -0
  84. package/dist/review/call-flow-html.js +1870 -0
  85. package/dist/review/call-flow-nav.d.ts +151 -0
  86. package/dist/review/call-flow-nav.js +317 -0
  87. package/dist/review/call-flow.d.ts +47 -0
  88. package/dist/review/call-flow.js +229 -0
  89. package/dist/review/change-facts.d.ts +69 -0
  90. package/dist/review/change-facts.js +729 -0
  91. package/dist/review/cli.d.ts +2 -0
  92. package/dist/review/cli.js +50 -0
  93. package/dist/review/connected-analysis.d.ts +100 -0
  94. package/dist/review/connected-analysis.js +163 -0
  95. package/dist/review/connected-html.d.ts +17 -0
  96. package/dist/review/connected-html.js +2853 -0
  97. package/dist/review/connected.d.ts +23 -0
  98. package/dist/review/connected.js +141 -0
  99. package/dist/review/escape-html.d.ts +2 -0
  100. package/dist/review/escape-html.js +9 -0
  101. package/dist/review/evidence-html.d.ts +21 -0
  102. package/dist/review/evidence-html.js +521 -0
  103. package/dist/review/evidence-syntax.d.ts +132 -0
  104. package/dist/review/evidence-syntax.js +478 -0
  105. package/dist/review/evidence-types.d.ts +62 -0
  106. package/dist/review/evidence-types.js +1 -0
  107. package/dist/review/evidence.d.ts +31 -0
  108. package/dist/review/evidence.js +1603 -0
  109. package/dist/review/file-role.d.ts +9 -0
  110. package/dist/review/file-role.js +29 -0
  111. package/dist/review/github.d.ts +204 -0
  112. package/dist/review/github.js +1245 -0
  113. package/dist/review/history.d.ts +101 -0
  114. package/dist/review/history.js +412 -0
  115. package/dist/review/html.d.ts +34 -0
  116. package/dist/review/html.js +1104 -0
  117. package/dist/review/input.d.ts +10 -0
  118. package/dist/review/input.js +113 -0
  119. package/dist/review/intent.d.ts +4 -0
  120. package/dist/review/intent.js +75 -0
  121. package/dist/review/mcp-cli.d.ts +2 -0
  122. package/dist/review/mcp-cli.js +25 -0
  123. package/dist/review/mcp.d.ts +12 -0
  124. package/dist/review/mcp.js +414 -0
  125. package/dist/review/module-resolution.d.ts +2 -0
  126. package/dist/review/module-resolution.js +86 -0
  127. package/dist/review/palette.d.ts +7 -0
  128. package/dist/review/palette.js +104 -0
  129. package/dist/review/pipeline.d.ts +77 -0
  130. package/dist/review/pipeline.js +227 -0
  131. package/dist/review/pr-input.d.ts +19 -0
  132. package/dist/review/pr-input.js +130 -0
  133. package/dist/review/questions.d.ts +201 -0
  134. package/dist/review/questions.js +174 -0
  135. package/dist/review/reference-check.d.ts +7 -0
  136. package/dist/review/reference-check.js +733 -0
  137. package/dist/review/report-pages.d.ts +109 -0
  138. package/dist/review/report-pages.js +328 -0
  139. package/dist/review/service.d.ts +23 -0
  140. package/dist/review/service.js +198 -0
  141. package/dist/review/setup.d.ts +112 -0
  142. package/dist/review/setup.js +549 -0
  143. package/dist/review/source.d.ts +26 -0
  144. package/dist/review/source.js +276 -0
  145. package/dist/review/toml.d.ts +38 -0
  146. package/dist/review/toml.js +565 -0
  147. package/dist/review/types.d.ts +179 -0
  148. package/dist/review/types.js +1 -0
  149. package/dist/run.d.ts +49 -0
  150. package/dist/run.js +311 -0
  151. package/dist/types.d.ts +366 -0
  152. package/dist/types.js +83 -0
  153. package/package.json +88 -0
  154. package/scripts/ensure-native-grammar.mjs +188 -0
@@ -0,0 +1,733 @@
1
+ /**
2
+ * Deterministic broken-reference check.
3
+ *
4
+ * Runs the TypeScript compiler that the *opted-in* project installed itself —
5
+ * `ReviewOptions.referenceProject`, a repository-relative `tsconfig.json` — over
6
+ * both revisions of the repository, each materialized whole into a temporary
7
+ * directory, and reports the errors the head revision has and the base revision
8
+ * does not. Errors are matched by content, not by line, so a pre-existing error
9
+ * that merely shifted is not re-reported as introduced.
10
+ *
11
+ * Each revision's *own* `tsconfig.json` is parsed from its snapshot, and
12
+ * `createProgram` receives TypeScript's own resolved root file list with the
13
+ * project's own options and normal module resolution: no hand-rolled specifier
14
+ * resolution, no partial materialization, no diagnostic suppression.
15
+ *
16
+ * Safety model:
17
+ *
18
+ * - The repository is read with git plumbing only (`rev-parse`, `ls-tree`,
19
+ * `cat-file`): no checkout, no index or working-tree write, no hook, no npm
20
+ * script, and nothing the pull request defines is executed.
21
+ * - Files are written only into a fresh directory under the OS temp directory,
22
+ * only from repository-relative paths with no `..`/absolute/backslash/NUL and
23
+ * no `node_modules` segment, and only for regular-file blob modes. A symbolic
24
+ * link or submodule anywhere in the revision, an oversized file, or an
25
+ * exceeded budget reports not-checked: emulating a resolution the revision
26
+ * does not have would manufacture an error.
27
+ * - Dependencies are the repository's *existing* installed `node_modules`,
28
+ * mirrored read-only into each snapshot per directory so Node's own resolution
29
+ * finds what the real checkout finds. Nothing is installed, no real
30
+ * `node_modules` entry is ever written through, no emit or build runs, and
31
+ * `createProgram` never loads a config `plugins` entry.
32
+ * - Configuration that reaches outside the snapshot (an absolute or escaping
33
+ * `extends`, a `paths` target, an input file, or a package config whose
34
+ * resolution leaves the snapshot) is refused rather than followed, and a
35
+ * program that read any file outside the snapshot, the installed
36
+ * dependencies, and the compiler's own libraries is discarded before its
37
+ * diagnostics are published.
38
+ * - A missing compiler, a missing or invalid configuration, unsupported project
39
+ * references, uninstalled dependencies, and any revision whose error count
40
+ * exceeds the comparison bound all report not-checked with the reason. The
41
+ * check never reports passed, and an empty finding list is never a claim that
42
+ * the project compiles.
43
+ */
44
+ import { execFileSync } from "node:child_process";
45
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
46
+ import { createRequire } from "node:module";
47
+ import { tmpdir } from "node:os";
48
+ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
49
+ import { z } from "zod";
50
+ /**
51
+ * Diagnostics that state a reference does not resolve. The set is closed: type
52
+ * mismatches, unused locals, argument counts, and syntax errors are out of
53
+ * scope, so a negative result is a statement about these codes only.
54
+ */
55
+ const UNRESOLVED_CODES = new Set([
56
+ 2304, // Cannot find name
57
+ 2305, // Module has no exported member
58
+ 2307, // Cannot find module
59
+ 2339, // Property does not exist on type
60
+ 2503, // Cannot find namespace
61
+ 2551, // Property does not exist on type; did you mean ...
62
+ 2552, // Cannot find name; did you mean ...
63
+ 7016, // Could not find a declaration file for module
64
+ ]);
65
+ const MAX_FILE_BYTES = 8 * 1024 * 1024;
66
+ const MAX_FILES = 50_000;
67
+ const MAX_TOTAL_BYTES = 512 * 1024 * 1024;
68
+ /** Per-revision unresolved-reference errors above which the comparison is refused. */
69
+ const MAX_DIAGNOSTICS = 500;
70
+ /** Tracked manifests whose declared dependencies are checked before comparing. */
71
+ const MAX_MANIFESTS = 200;
72
+ /** Declared dependency names beyond which the install layout is judged irreproducible. */
73
+ const MAX_DECLARED = 2_000;
74
+ const MAX_FINDINGS = 50;
75
+ const MAX_MESSAGE_CHARS = 240;
76
+ const GIT_MAX_BYTES = 64 * 1024 * 1024;
77
+ const LIMITATION = "Diagnostic comparison against the repository's currently installed dependencies: no emit, build, or test runs, so this bounds only these error codes in these two revisions and is not evidence that the project builds or that the change is safe.";
78
+ /** `extends` written as a single string or an array; the boundary normalizes both. */
79
+ const EXTENDS = z.union([z.string(), z.array(z.string())])
80
+ .transform(value => (Array.isArray(value) ? value : [value]))
81
+ .optional();
82
+ const extendsSchema = z.object({ extends: EXTENDS }).catchall(z.unknown());
83
+ /** A condition under which the check must report not-checked, never passed. */
84
+ class Unavailable extends Error {
85
+ }
86
+ export async function checkReferences(repo, base, head, units, project) {
87
+ const repoRoot = resolve(repo);
88
+ const projectSpec = project.trim();
89
+ try {
90
+ if (projectSpec === "")
91
+ fail("Reference checking was requested without a reference project.");
92
+ if (isAbsolute(projectSpec) || projectSpec.includes("\0")) {
93
+ fail(`referenceProject must be a repository-relative tsconfig path; ${JSON.stringify(projectSpec)} is not, so no trusted project configuration was selected.`);
94
+ }
95
+ const configPath = resolve(repoRoot, projectSpec);
96
+ const projectRel = repoRelative(repoRoot, configPath);
97
+ if (!within(repoRoot, configPath) || !isRegularFile(configPath)) {
98
+ fail(`The reference project ${JSON.stringify(projectSpec)} is not a file inside the repository, so its installed checker and configuration cannot be used.`);
99
+ }
100
+ const baseSha = resolveCommit(repoRoot, base);
101
+ const headSha = resolveCommit(repoRoot, head);
102
+ const compiler = loadCompiler(dirname(configPath), repoRoot);
103
+ const plan = {
104
+ ts: compiler.ts,
105
+ compilerEntry: compiler.entry,
106
+ repoRoot,
107
+ projectRel,
108
+ headSha,
109
+ trustedRoots: [realpathSync(dirname(compiler.entry))],
110
+ unitsByFile: unitsByFile(units),
111
+ notes: new Set(),
112
+ truncated: false,
113
+ };
114
+ // `realpath` keeps the isolated root on the physical path: the OS temp
115
+ // directory is itself a symlink on macOS, and a mismatched root would make
116
+ // every snapshot path look external and silently drop every diagnostic.
117
+ const isolated = realpathSync(mkdtempSync(join(tmpdir(), "diffninja-reference-")));
118
+ const links = [];
119
+ try {
120
+ if (within(repoRoot, isolated)) {
121
+ fail("The operating system temporary directory is inside the reviewed repository, so an isolated snapshot cannot be guaranteed.");
122
+ }
123
+ const baseTree = readTree(repoRoot, baseSha);
124
+ const headTree = readTree(repoRoot, headSha);
125
+ if (!baseTree.has(projectRel) || !headTree.has(projectRel)) {
126
+ fail(`The reference project ${projectRel} is not present in both compared revisions, so there is no pair of configurations to compare.`);
127
+ }
128
+ const baseSnapshot = materialize(plan, baseTree, baseSha, join(isolated, "base"));
129
+ const headSnapshot = materialize(plan, headTree, headSha, join(isolated, "head"));
130
+ const snapshots = [baseSnapshot, headSnapshot];
131
+ for (const snapshot of snapshots) {
132
+ const unresolved = mirrorInstalls(plan, snapshot, links);
133
+ if (unresolved.length === 0)
134
+ continue;
135
+ fail(`Revision ${short(snapshot.rev)} has ${unresolved.length} declared dependenc${unresolved.length === 1 ? "y" : "ies"} that Node cannot resolve from its own directory — ${unresolved.slice(0, 5).join(", ")}${unresolved.length > 5 ? ", …" : ""}. The install layout cannot be reproduced faithfully, so an unresolved import could not be distinguished from a missing install; no findings are reported.`);
136
+ }
137
+ const baseDiagnostics = revisionDiagnostics(plan, baseSnapshot);
138
+ const headDiagnostics = revisionDiagnostics(plan, headSnapshot);
139
+ const introduced = introducedOnly(baseDiagnostics, headDiagnostics);
140
+ const reported = introduced.slice(0, MAX_FINDINGS);
141
+ if (introduced.length > reported.length) {
142
+ plan.truncated = true;
143
+ plan.notes.add(`${introduced.length - reported.length} further introduced reference(s) beyond the ${MAX_FINDINGS} reported`);
144
+ }
145
+ const findings = reported.map((item, index) => findingFor(plan, item, index + 1));
146
+ if (findings.some(finding => finding.unitIds.length === 0)) {
147
+ plan.notes.add("finding(s) in files this diff does not change, so they carry no changed unit");
148
+ }
149
+ return {
150
+ findings,
151
+ check: {
152
+ kind: "broken-reference",
153
+ status: plan.truncated ? "partial" : "checked",
154
+ detail: coverageDetail(plan, baseSha, headSha, baseDiagnostics, headDiagnostics, introduced.length, findings.length),
155
+ },
156
+ };
157
+ }
158
+ finally {
159
+ // Unlink every mirror explicitly before removing the temp root, so no
160
+ // walk can follow a link into the repository's real node_modules.
161
+ for (const link of links)
162
+ unlinkSync(link);
163
+ rmSync(isolated, { recursive: true, force: true });
164
+ }
165
+ }
166
+ catch (error) {
167
+ // Narrowing in place: an unknown thrown value is never passed on as prose.
168
+ const reason = error instanceof Unavailable
169
+ ? error.message
170
+ : `The reference check failed before it could report: ${error instanceof Error ? error.message : String(error)}`;
171
+ return notChecked(reason);
172
+ }
173
+ }
174
+ function fail(reason) {
175
+ throw new Unavailable(reason);
176
+ }
177
+ function notChecked(reason) {
178
+ return {
179
+ findings: [],
180
+ check: { kind: "broken-reference", status: "not-checked", detail: reason },
181
+ };
182
+ }
183
+ /**
184
+ * The opted-in project's own installed compiler. Loading it is the trust
185
+ * boundary the user opened by naming the project: it is the only package that
186
+ * is required, and resolution starts at the project directory.
187
+ */
188
+ function loadCompiler(projectDir, repoRoot) {
189
+ for (const dir of projectDir === repoRoot ? [projectDir] : [projectDir, repoRoot]) {
190
+ const require = createRequire(join(dir, "package.json"));
191
+ let entry;
192
+ try {
193
+ entry = require.resolve("typescript");
194
+ }
195
+ catch {
196
+ continue;
197
+ }
198
+ try {
199
+ // SAFETY: `typescript` resolved from the opted-in project is the trusted
200
+ // compiler by contract; a package that cannot serve as one throws here
201
+ // and the check reports not-checked instead of substituting a checker.
202
+ const ts = require(entry);
203
+ if (ts.version !== undefined && ts.sys !== undefined)
204
+ return { ts, entry };
205
+ }
206
+ catch {
207
+ continue;
208
+ }
209
+ }
210
+ fail(`No installed TypeScript compiler was found for the reference project (looked from ${projectDir} and ${repoRoot}). Install it there or omit referenceProject; the check never falls back to an unverified compiler.`);
211
+ }
212
+ /** Materialize one revision whole, or report not-checked and materialize nothing. */
213
+ function materialize(plan, tree, rev, root) {
214
+ mkdirSync(root, { recursive: true });
215
+ if (tree.size > MAX_FILES) {
216
+ fail(`The revision ${short(rev)} tracks ${tree.size} files, exceeding the bounded whole-snapshot materialization budget of ${MAX_FILES}.`);
217
+ }
218
+ let bytes = 0;
219
+ for (const [path, entry] of tree) {
220
+ if (entry.mode !== "100644" && entry.mode !== "100755") {
221
+ fail(`The revision ${short(rev)} tracks a symbolic link or submodule (${path}), which this check does not materialize. Its resolution cannot be reproduced faithfully, so no reference result is reported.`);
222
+ }
223
+ const safe = safeRepoPath(path);
224
+ const target = safe === null ? null : join(root, safe);
225
+ if (target === null || !within(root, target)) {
226
+ fail(`The revision ${short(rev)} tracks a path unsafe to materialize (${JSON.stringify(path)}), so the snapshot cannot be trusted.`);
227
+ }
228
+ if (entry.size > MAX_FILE_BYTES || bytes + entry.size > MAX_TOTAL_BYTES) {
229
+ fail(`The revision ${short(rev)} exceeds the bounded whole-snapshot materialization budget (${MAX_FILE_BYTES} bytes/file, ${MAX_TOTAL_BYTES} bytes total).`);
230
+ }
231
+ const blob = git(plan.repoRoot, ["cat-file", "blob", `${rev}:${path}`]);
232
+ mkdirSync(dirname(target), { recursive: true });
233
+ writeFileSync(target, blob);
234
+ bytes += blob.length;
235
+ }
236
+ // Resolution follows the *importing file*, so a nested install anywhere the
237
+ // revision tracks a file can serve an import the project directory cannot.
238
+ const dirs = new Set(["."]);
239
+ for (const path of tree.keys()) {
240
+ for (let dir = posix.dirname(path); dir !== "." && dir !== "/"; dir = posix.dirname(dir))
241
+ dirs.add(dir);
242
+ }
243
+ return { rev, root, tree, dirs };
244
+ }
245
+ /**
246
+ * Mirror the repository's existing installed dependencies into the snapshot,
247
+ * one directory per tracked directory, so Node's own resolution from any
248
+ * tracked file finds exactly what the real checkout finds. Nothing is installed,
249
+ * nothing is copied, and no real `node_modules` is ever written through.
250
+ *
251
+ * Returns declared dependency names that Node cannot resolve by climbing from
252
+ * their own manifest directory. Node would not resolve them from there in the
253
+ * real checkout either — the install is missing or lives in a layout (such as a
254
+ * sibling workspace) that a per-directory mirror cannot reproduce — so the
255
+ * revision is refused instead of reporting an import this check cannot judge.
256
+ */
257
+ function mirrorInstalls(plan, snapshot, links) {
258
+ const unresolved = new Set();
259
+ let manifests = 0;
260
+ let declared = 0;
261
+ for (const dir of [...snapshot.dirs].sort()) {
262
+ const realDir = join(plan.repoRoot, dir);
263
+ const names = declaredNames(snapshot, dir);
264
+ if (names !== null) {
265
+ manifests += 1;
266
+ declared += names.length;
267
+ if (manifests > MAX_MANIFESTS || declared > MAX_DECLARED) {
268
+ fail(`The revision ${short(snapshot.rev)} has more than ${MAX_MANIFESTS} manifests or ${MAX_DECLARED} declared dependencies, beyond the install layout this check reproduces faithfully.`);
269
+ }
270
+ for (const name of names) {
271
+ if (installedFrom(plan.repoRoot, realDir, name))
272
+ continue;
273
+ const elsewhere = findInstalled(plan, snapshot, name);
274
+ unresolved.add(elsewhere === null
275
+ ? `${name} (not installed)`
276
+ : `${name} (installed only at ${repoRelative(plan.repoRoot, dirname(dirname(elsewhere)))})`);
277
+ }
278
+ }
279
+ const realModules = join(realDir, "node_modules");
280
+ if (!existsSync(realModules))
281
+ continue;
282
+ const target = join(snapshot.root, dir, "node_modules");
283
+ if (existsSync(target))
284
+ continue;
285
+ linkInto(plan, links, realModules, target);
286
+ }
287
+ return [...unresolved].sort();
288
+ }
289
+ /**
290
+ * One read-only symlink, recorded for cleanup. The linked directory itself is
291
+ * the only path this makes readable: trusting its parent would expose the whole
292
+ * live checkout (for a repository-root install, its parent is the repository).
293
+ */
294
+ function linkInto(plan, links, source, target) {
295
+ const real = realpathSync(source);
296
+ symlinkSync(real, target, "dir");
297
+ links.push(target);
298
+ if (!plan.trustedRoots.includes(real))
299
+ plan.trustedRoots.push(real);
300
+ }
301
+ /** Node's own upward resolution: `node_modules` in each directory up to the repository root. */
302
+ function installedFrom(repoRoot, dir, name) {
303
+ for (let current = dir;;) {
304
+ if (existsSync(join(current, "node_modules", name, "package.json")))
305
+ return true;
306
+ const parent = dirname(current);
307
+ if (current === repoRoot || parent === current || !within(repoRoot, parent))
308
+ return false;
309
+ current = parent;
310
+ }
311
+ }
312
+ /** An installed copy anywhere in the repository, for a dependency this directory cannot reach. */
313
+ function findInstalled(plan, snapshot, name) {
314
+ for (const dir of [...snapshot.dirs].sort()) {
315
+ const candidate = join(plan.repoRoot, dir, "node_modules", name);
316
+ if (existsSync(join(candidate, "package.json")))
317
+ return candidate;
318
+ }
319
+ return null;
320
+ }
321
+ /** Dependency names a tracked manifest at `dir` declares, or null when it has none. */
322
+ function declaredNames(snapshot, dir) {
323
+ const path = join(snapshot.root, dir, "package.json");
324
+ if (!isRegularFile(path))
325
+ return null;
326
+ let manifest;
327
+ try {
328
+ // SAFETY: package.json is untrusted repository data; only dependency names
329
+ // are read from it, and an unreadable shape yields no declared names.
330
+ manifest = JSON.parse(readFileSync(path, "utf8"));
331
+ }
332
+ catch {
333
+ return null;
334
+ }
335
+ return [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.devDependencies ?? {})];
336
+ }
337
+ /** One `createProgram` for the revision's own configuration, no emit, no plugins. */
338
+ function revisionDiagnostics(plan, snapshot) {
339
+ const { ts } = plan;
340
+ const revision = short(snapshot.rev);
341
+ const configPath = join(snapshot.root, plan.projectRel);
342
+ const host = boundedHost(plan, snapshot);
343
+ const read = ts.readConfigFile(configPath, host.readFile);
344
+ if (read.error !== undefined) {
345
+ fail(`The reference project ${plan.projectRel} could not be read at ${revision}: ${ts.flattenDiagnosticMessageText(read.error.messageText, " ")}`);
346
+ }
347
+ // The `extends` chain is validated *before* parsing, because parsing is what
348
+ // reads an inherited configuration: a chain pointing at the live machine must
349
+ // be refused, never followed.
350
+ const inherited = extendsEscape(plan, snapshot, host, configPath, 0);
351
+ if (inherited !== null) {
352
+ fail(`The reference project ${plan.projectRel} at ${revision} resolves inputs outside the isolated snapshot (${inherited}), so it was not read and no diagnostics were reported.`);
353
+ }
354
+ const parsed = ts.parseJsonConfigFileContent(read.config, host, dirname(configPath), undefined, configPath);
355
+ const error = parsed.errors[0];
356
+ if (error !== undefined) {
357
+ fail(`The reference project ${plan.projectRel} has a configuration error at ${revision}, so its options are not trustworthy: ${ts.flattenDiagnosticMessageText(error.messageText, " ")}`);
358
+ }
359
+ if ((parsed.projectReferences?.length ?? 0) > 0) {
360
+ fail(`The reference project ${plan.projectRel} uses project references at ${revision}, which this single-configuration check does not build. Point referenceProject at the referenced project's own tsconfig.`);
361
+ }
362
+ const escape = outsideSnapshot(configPath, parsed, snapshot.root);
363
+ if (escape !== null) {
364
+ fail(`The reference project ${plan.projectRel} at ${revision} resolves inputs outside the isolated snapshot (${escape}), so its configuration was not trusted and no diagnostics were reported.`);
365
+ }
366
+ const program = ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options });
367
+ const breach = confinementBreach(plan, snapshot, program);
368
+ if (breach !== null) {
369
+ fail(`The program for ${revision} read ${breach}, which is neither snapshot content, an installed dependency, nor a compiler library. A source file outside the revision cannot be judged against it, so no diagnostics were reported.`);
370
+ }
371
+ return collectDiagnostics(plan, snapshot, program);
372
+ }
373
+ /**
374
+ * A host that answers only for snapshot content, the installed dependencies,
375
+ * and the compiler's own libraries. Passing it to config parsing and module
376
+ * resolution is the backstop for `extends` chains, including package-provided
377
+ * configs: a read that would leave that boundary fails as missing rather than
378
+ * importing live machine state into the revision.
379
+ */
380
+ function boundedHost(plan, snapshot) {
381
+ const allowed = (path) => confined(plan, snapshot.root, path);
382
+ return {
383
+ useCaseSensitiveFileNames: plan.ts.sys.useCaseSensitiveFileNames,
384
+ readFile: path => (allowed(path) ? plan.ts.sys.readFile(path) : undefined),
385
+ fileExists: path => allowed(path) && plan.ts.sys.fileExists(path),
386
+ readDirectory: (...args) => plan.ts.sys.readDirectory(...args),
387
+ directoryExists: path => plan.ts.sys.directoryExists(path),
388
+ getDirectories: path => plan.ts.sys.getDirectories(path),
389
+ };
390
+ }
391
+ /**
392
+ * Configuration that reaches outside the snapshot: an input file or a
393
+ * resolution root. Any of these would make the program read live machine state
394
+ * instead of the revision, so the revision is refused rather than checked
395
+ * against files it does not contain.
396
+ */
397
+ function outsideSnapshot(configPath, parsed, root) {
398
+ const configDir = dirname(configPath);
399
+ const escapedFile = parsed.fileNames.find(name => !within(root, name));
400
+ if (escapedFile !== undefined)
401
+ return `file ${escapedFile}`;
402
+ // SAFETY: `pathsBasePath` is set by parseJsonConfigFileContent but is absent
403
+ // from the public CompilerOptions type; it decides where `paths` resolve.
404
+ const internal = parsed.options;
405
+ const roots = [parsed.options.baseUrl, internal.pathsBasePath, ...(parsed.options.typeRoots ?? []), ...(parsed.options.rootDirs ?? [])];
406
+ for (const value of roots) {
407
+ if (value !== undefined && !within(root, resolve(configDir, value)))
408
+ return value;
409
+ }
410
+ const bases = [parsed.options.baseUrl, internal.pathsBasePath]
411
+ .filter((value) => value !== undefined)
412
+ .map(value => resolve(configDir, value));
413
+ if (bases.length === 0)
414
+ bases.push(configDir);
415
+ for (const [pattern, targets] of Object.entries(parsed.options.paths ?? {})) {
416
+ for (const mapping of targets) {
417
+ for (const base of bases) {
418
+ if (!within(root, resolve(base, mapping.replace(/\*/g, "x")))) {
419
+ return `paths ${pattern} → ${mapping}`;
420
+ }
421
+ }
422
+ }
423
+ }
424
+ return null;
425
+ }
426
+ /**
427
+ * The `extends` chain. An absolute value is always refused, a relative one must
428
+ * stay inside the snapshot, and a package-provided config is resolved through
429
+ * the bounded host so it can only come from the installed dependencies.
430
+ */
431
+ function extendsEscape(plan, snapshot, host, configPath, depth) {
432
+ if (depth > 8)
433
+ return `extends chain deeper than 8 levels from ${repoRelative(snapshot.root, configPath)}`;
434
+ const read = plan.ts.readConfigFile(configPath, host.readFile);
435
+ if (read.error !== undefined)
436
+ return null;
437
+ // Boundary parse: the raw config is untrusted text, so only a schema-checked
438
+ // `extends` is ever used, and any other shape contributes no inherited path.
439
+ const boundary = extendsSchema.safeParse(read.config);
440
+ if (!boundary.success)
441
+ return null;
442
+ for (const value of boundary.data.extends ?? []) {
443
+ if (isAbsolute(value))
444
+ return `extends ${value}`;
445
+ let target;
446
+ if (value.startsWith(".")) {
447
+ target = resolve(dirname(configPath), value);
448
+ if (!within(snapshot.root, target))
449
+ return `extends ${value}`;
450
+ }
451
+ else {
452
+ const resolved = plan.ts.resolveModuleName(value, configPath, { moduleResolution: plan.ts.ModuleResolutionKind.NodeNext }, host).resolvedModule?.resolvedFileName;
453
+ if (resolved === undefined)
454
+ continue;
455
+ if (!confined(plan, snapshot.root, resolved))
456
+ return `extends ${value}`;
457
+ target = resolved;
458
+ }
459
+ const nested = extendsEscape(plan, snapshot, host, isRegularFile(target) ? target : `${target}.json`, depth + 1);
460
+ if (nested !== null)
461
+ return nested;
462
+ }
463
+ return null;
464
+ }
465
+ /**
466
+ * Every file the program actually read must come from the snapshot, an
467
+ * installed dependency, or the compiler's own libraries. TypeScript follows
468
+ * absolute specifiers and `../..` paths wherever they lead, so a program that
469
+ * reached any other file is discarded before its diagnostics are published.
470
+ */
471
+ function confinementBreach(plan, snapshot, program) {
472
+ for (const source of program.getSourceFiles()) {
473
+ if (confined(plan, snapshot.root, source.fileName))
474
+ continue;
475
+ return source.fileName;
476
+ }
477
+ return null;
478
+ }
479
+ /**
480
+ * A read is confined when its *resolved* location is snapshot content or a
481
+ * trusted installed-dependency/compiler directory. The lexical path is never
482
+ * enough: with `preserveSymlinks` TypeScript keeps `snapshot/node_modules/...`
483
+ * for a file that physically lives in the live checkout, and a lexical match
484
+ * would then admit live state as if it were revision content.
485
+ */
486
+ function confined(plan, root, path) {
487
+ const real = realpathOr(path);
488
+ if (within(root, real))
489
+ return true;
490
+ return plan.trustedRoots.some(allowed => within(allowed, real));
491
+ }
492
+ /**
493
+ * Every unresolved reference the program reports in a snapshot file. Files
494
+ * outside the snapshot — installed dependencies and compiler libraries — are
495
+ * excluded: their errors are not this revision's, and the repository's live
496
+ * dependency tree is deliberately not part of the comparison.
497
+ *
498
+ * A revision with more errors than the comparison bound is refused, never
499
+ * truncated: capping one revision's list before subtracting it would move its
500
+ * boundary, so a pre-existing error past the cap could enter as introduced.
501
+ */
502
+ function collectDiagnostics(plan, snapshot, program) {
503
+ const lines = new Map();
504
+ const located = [];
505
+ for (const diagnostic of plan.ts.getPreEmitDiagnostics(program)) {
506
+ const file = diagnostic.file;
507
+ if (file === undefined || diagnostic.start === undefined)
508
+ continue;
509
+ if (!UNRESOLVED_CODES.has(diagnostic.code))
510
+ continue;
511
+ if (!within(snapshot.root, file.fileName))
512
+ continue;
513
+ // A finding must come from a file this revision tracks. Anything else the
514
+ // program read — an installed package's own sources, or a symlinked path
515
+ // that only looks like snapshot content under `preserveSymlinks` — is not
516
+ // this revision's source and would otherwise be reported with head-revision
517
+ // evidence it does not have.
518
+ const relative = repoRelative(snapshot.root, file.fileName);
519
+ if (!snapshot.tree.has(relative))
520
+ continue;
521
+ if (located.length >= MAX_DIAGNOSTICS) {
522
+ fail(`Revision ${short(snapshot.rev)} has more than ${MAX_DIAGNOSTICS} unresolved-reference errors, beyond what this comparison bounds. Truncating a revision's errors would move its boundary and could report a pre-existing error as introduced, so no findings are reported.`);
523
+ }
524
+ const position = file.getLineAndCharacterOfPosition(diagnostic.start);
525
+ const cached = lines.get(file.fileName);
526
+ const text = cached ?? file.getFullText().split(/\r\n|\r|\n/);
527
+ lines.set(file.fileName, text);
528
+ located.push({
529
+ file: relative,
530
+ line: position.line + 1,
531
+ code: diagnostic.code,
532
+ // TypeScript embeds resolved file paths in some messages, and the two
533
+ // snapshots have different roots, so every representation of the root is
534
+ // replaced by a stable token: otherwise the same pre-existing error would
535
+ // read as a different message in each revision and be reported as new.
536
+ message: normalizeMessage(plan.ts.flattenDiagnosticMessageText(diagnostic.messageText, " "), snapshot.root).slice(0, MAX_MESSAGE_CHARS),
537
+ lineText: (text[position.line] ?? "").trim(),
538
+ });
539
+ }
540
+ located.sort(compareLocated);
541
+ return located;
542
+ }
543
+ /**
544
+ * Head diagnostics that no base diagnostic explains. An exact match on (file,
545
+ * code, message, trimmed source line) cancels first, so a pre-existing error
546
+ * that only moved lines stays pre-existing; a second pass matches on (file,
547
+ * code, message), so a reflowed pre-existing error does not become an
548
+ * introduction either.
549
+ */
550
+ function introducedOnly(base, head) {
551
+ const exact = new Map();
552
+ const triples = new Map();
553
+ for (const item of base) {
554
+ exact.set(exactKey(item), (exact.get(exactKey(item)) ?? 0) + 1);
555
+ triples.set(tripleKey(item), (triples.get(tripleKey(item)) ?? 0) + 1);
556
+ }
557
+ const unmatched = [];
558
+ for (const item of head) {
559
+ const remaining = exact.get(exactKey(item)) ?? 0;
560
+ if (remaining > 0) {
561
+ exact.set(exactKey(item), remaining - 1);
562
+ triples.set(tripleKey(item), (triples.get(tripleKey(item)) ?? 0) - 1);
563
+ continue;
564
+ }
565
+ unmatched.push(item);
566
+ }
567
+ const introduced = [];
568
+ for (const item of unmatched) {
569
+ const remaining = triples.get(tripleKey(item)) ?? 0;
570
+ if (remaining > 0)
571
+ triples.set(tripleKey(item), remaining - 1);
572
+ else
573
+ introduced.push(item);
574
+ }
575
+ return introduced;
576
+ }
577
+ function exactKey(item) {
578
+ return `${tripleKey(item)}\u0000${item.lineText}`;
579
+ }
580
+ function tripleKey(item) {
581
+ return `${item.file}\u0000${item.code}\u0000${item.message}`;
582
+ }
583
+ function compareLocated(left, right) {
584
+ if (left.file !== right.file)
585
+ return left.file < right.file ? -1 : 1;
586
+ if (left.line !== right.line)
587
+ return left.line - right.line;
588
+ if (left.code !== right.code)
589
+ return left.code - right.code;
590
+ if (left.message === right.message)
591
+ return 0;
592
+ return left.message < right.message ? -1 : 1;
593
+ }
594
+ function findingFor(plan, item, index) {
595
+ const excerpt = {
596
+ id: `reference-${index}-head`,
597
+ label: `TS${item.code}`,
598
+ file: item.file,
599
+ line: item.line,
600
+ ref: short(plan.headSha),
601
+ text: item.lineText,
602
+ role: "change",
603
+ };
604
+ return {
605
+ id: `reference-${index}`,
606
+ kind: "broken-reference",
607
+ title: `TS${item.code}: ${item.message}`,
608
+ scope: `${item.file}:${item.line}`,
609
+ limitation: LIMITATION,
610
+ unitIds: plan.unitsByFile.get(item.file) ?? [],
611
+ evidence: [excerpt],
612
+ };
613
+ }
614
+ function coverageDetail(plan, baseSha, headSha, base, head, introduced, reported) {
615
+ const parts = [
616
+ `TypeScript ${plan.ts.version} at ${plan.compilerEntry}, run once per revision over whole isolated snapshots (base ${short(baseSha)} → head ${short(headSha)}) written outside the repository, each with its own ${plan.projectRel} and the repository's installed node_modules mirrored read-only.`,
617
+ `Unresolved-reference errors: ${base.length} at base, ${head.length} at head; ${head.length - introduced} matched a pre-existing error and ${reported} are reported as introduced. Codes considered: ${[...UNRESOLVED_CODES].sort((left, right) => left - right).join(", ")}.`,
618
+ ];
619
+ if (plan.notes.size > 0)
620
+ parts.push(`Not verified: ${[...plan.notes].join(", ")}.`);
621
+ parts.push(LIMITATION);
622
+ return parts.join(" ");
623
+ }
624
+ /** Changed unit ids per repository-relative path, for finding attribution. */
625
+ function unitsByFile(units) {
626
+ const byFile = new Map();
627
+ for (const unit of units) {
628
+ const path = safeRepoPath(unit.file);
629
+ if (path === null)
630
+ continue;
631
+ const ids = byFile.get(path);
632
+ if (ids === undefined)
633
+ byFile.set(path, [unit.id]);
634
+ else
635
+ ids.push(unit.id);
636
+ }
637
+ return byFile;
638
+ }
639
+ function readTree(repoRoot, rev) {
640
+ let listing;
641
+ try {
642
+ listing = git(repoRoot, ["ls-tree", "-r", "-z", "-l", rev]).toString("utf8");
643
+ }
644
+ catch {
645
+ fail(`The file list of revision ${short(rev)} is unavailable, so nothing was materialized or compared.`);
646
+ }
647
+ const tree = new Map();
648
+ for (const record of listing.split("\0")) {
649
+ if (record === "")
650
+ continue;
651
+ const tab = record.indexOf("\t");
652
+ if (tab === -1)
653
+ fail(`The file list of revision ${short(rev)} could not be parsed, so nothing was compared.`);
654
+ const fields = record.slice(0, tab).split(/\s+/);
655
+ const size = Number(fields[3]);
656
+ tree.set(record.slice(tab + 1), { mode: fields[0] ?? "", size: Number.isFinite(size) ? size : 0 });
657
+ }
658
+ return tree;
659
+ }
660
+ function resolveCommit(repoRoot, ref) {
661
+ try {
662
+ return git(repoRoot, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]).toString("utf8").trim();
663
+ }
664
+ catch {
665
+ fail(`The revision ${JSON.stringify(ref)} cannot be resolved in ${repoRoot}, so no immutable snapshot was available to check.`);
666
+ }
667
+ }
668
+ function git(repoRoot, args) {
669
+ return execFileSync("git", ["--no-replace-objects", "--no-pager", ...args], {
670
+ cwd: repoRoot,
671
+ maxBuffer: GIT_MAX_BYTES,
672
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
673
+ });
674
+ }
675
+ /**
676
+ * A repository-relative path that is safe to join onto the snapshot root: no
677
+ * absolute form, no NUL, no backslash, no `..` segment, and no `node_modules`
678
+ * segment, so snapshot content can never enter a dependency tree.
679
+ */
680
+ function safeRepoPath(path) {
681
+ if (path === "" || path.includes("\0") || path.includes("\\") || isAbsolute(path))
682
+ return null;
683
+ const normalized = posix.normalize(path);
684
+ if (normalized === "" || normalized === "." || normalized === "..")
685
+ return null;
686
+ if (normalized.startsWith("../") || normalized.startsWith("/"))
687
+ return null;
688
+ if (normalized.split("/").includes("node_modules"))
689
+ return null;
690
+ return normalized;
691
+ }
692
+ function repoRelative(root, path) {
693
+ return relative(root, path).split(sep).join("/");
694
+ }
695
+ function within(root, path) {
696
+ return path === root || path.startsWith(root + sep);
697
+ }
698
+ function realpathOr(path) {
699
+ try {
700
+ return realpathSync(path);
701
+ }
702
+ catch {
703
+ return path;
704
+ }
705
+ }
706
+ function isRegularFile(path) {
707
+ try {
708
+ return statSync(path).isFile();
709
+ }
710
+ catch {
711
+ return false;
712
+ }
713
+ }
714
+ function short(sha) {
715
+ return sha.slice(0, 8);
716
+ }
717
+ /**
718
+ * Replace every representation of a snapshot root in a diagnostic message with
719
+ * one stable token: the path as given, its forward-slash form, its
720
+ * backslash form, and — on macOS, where the temp directory is reachable as both
721
+ * `/var/...` and `/private/var/...` — the `/private`-stripped form. Without
722
+ * this, the base and head snapshots would render the same error differently and
723
+ * a pre-existing error would be subtracted as an introduced one.
724
+ */
725
+ function normalizeMessage(message, root) {
726
+ const roots = new Set([root, root.split(sep).join("/"), root.split("/").join("\\")]);
727
+ if (root.startsWith("/private/"))
728
+ roots.add(root.slice("/private".length));
729
+ let normalized = message;
730
+ for (const variant of roots)
731
+ normalized = normalized.split(variant).join("<snapshot>");
732
+ return normalized;
733
+ }