@diffci.com/diffci 0.1.0-alpha.3

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.
@@ -0,0 +1,958 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { createTestFileMatcher, DEFAULT_TEST_FILE_MATCHER, testFileMatcherForProfile } from "./test-discovery.js";
3
+ import { isBuiltin } from "node:module";
4
+ import { dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
5
+ import ts from "typescript";
6
+ import { analyzeRepository } from "./analyzer.js";
7
+ const SOURCE_EXTENSIONS = new Set([
8
+ ".ts",
9
+ ".tsx",
10
+ ".js",
11
+ ".jsx",
12
+ ".mjs",
13
+ ".cjs",
14
+ ".mts",
15
+ ".cts",
16
+ ]);
17
+ const ASSET_EXTENSIONS = new Set([
18
+ ".css",
19
+ ".scss",
20
+ ".sass",
21
+ ".less",
22
+ ".json",
23
+ ".jsonc",
24
+ ".svg",
25
+ ".png",
26
+ ".jpg",
27
+ ".jpeg",
28
+ ".gif",
29
+ ".webp",
30
+ ".ico",
31
+ ".bmp",
32
+ ".woff",
33
+ ".woff2",
34
+ ".ttf",
35
+ ".otf",
36
+ ".eot",
37
+ ".wasm",
38
+ ".md",
39
+ ".txt",
40
+ ]);
41
+ function toPosix(p) {
42
+ return p.split(sep).join("/");
43
+ }
44
+ function isSourceFileName(fileName) {
45
+ return SOURCE_EXTENSIONS.has(extname(fileName).toLowerCase());
46
+ }
47
+ function isAssetFileName(fileName) {
48
+ return ASSET_EXTENSIONS.has(extname(fileName).toLowerCase());
49
+ }
50
+ function isNodeBuiltin(specifier) {
51
+ return isBuiltin(specifier);
52
+ }
53
+ function toRelativeInternal(repoPath, absolutePath) {
54
+ const rel = toPosix(normalize(relative(repoPath, absolutePath)));
55
+ if (rel.startsWith(".."))
56
+ return undefined;
57
+ if (rel === "node_modules" || rel.startsWith("node_modules/"))
58
+ return undefined;
59
+ return rel;
60
+ }
61
+ function isExcludedPath(rel, excludeDirs) {
62
+ return excludeDirs.some((dir) => rel === dir || rel.startsWith(`${dir}/`));
63
+ }
64
+ function extractImportRefs(sourceFile) {
65
+ const refs = [];
66
+ function visit(node) {
67
+ if (ts.isImportDeclaration(node)) {
68
+ const specifier = node.moduleSpecifier;
69
+ if (ts.isStringLiteral(specifier)) {
70
+ const isTypeOnly = node.importClause?.isTypeOnly ?? false;
71
+ const kind = isTypeOnly ? "type-import" : "import";
72
+ refs.push({ specifier: specifier.text, kind, dynamic: false });
73
+ }
74
+ }
75
+ else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
76
+ const specifier = node.moduleSpecifier;
77
+ if (ts.isStringLiteral(specifier)) {
78
+ const kind = node.isTypeOnly ? "type-import" : "re-export";
79
+ refs.push({ specifier: specifier.text, kind, dynamic: false });
80
+ }
81
+ }
82
+ else if (ts.isCallExpression(node)) {
83
+ const firstArg = node.arguments[0];
84
+ if (node.expression.kind === ts.SyntaxKind.ImportKeyword && firstArg) {
85
+ if (ts.isStringLiteral(firstArg)) {
86
+ refs.push({
87
+ specifier: firstArg.text,
88
+ kind: "dynamic-import",
89
+ dynamic: true,
90
+ });
91
+ }
92
+ else {
93
+ refs.push({
94
+ specifier: firstArg.getText(sourceFile).slice(0, 200),
95
+ kind: "dynamic-import",
96
+ dynamic: true,
97
+ });
98
+ }
99
+ }
100
+ else if (ts.isIdentifier(node.expression) &&
101
+ node.expression.text === "require" &&
102
+ firstArg &&
103
+ ts.isStringLiteral(firstArg)) {
104
+ refs.push({
105
+ specifier: firstArg.text,
106
+ kind: "require",
107
+ dynamic: false,
108
+ });
109
+ }
110
+ }
111
+ ts.forEachChild(node, visit);
112
+ }
113
+ visit(sourceFile);
114
+ return refs;
115
+ }
116
+ class DependencyGraphImpl {
117
+ repoPath;
118
+ nodes;
119
+ edges;
120
+ forward = {};
121
+ reverse = {};
122
+ constructor(sourcePaths, assetPaths, edges, repoPath, isTestFile = DEFAULT_TEST_FILE_MATCHER) {
123
+ this.repoPath = repoPath;
124
+ this.edges = [...edges].sort(DependencyGraphImpl.compareEdges);
125
+ this.nodes = [
126
+ ...Array.from(sourcePaths).map((p) => ({
127
+ path: p,
128
+ isSource: true,
129
+ isAsset: false,
130
+ isTest: isTestFile(p),
131
+ isEntryPoint: false,
132
+ })),
133
+ ...Array.from(assetPaths).map((p) => ({
134
+ path: p,
135
+ isSource: false,
136
+ isAsset: true,
137
+ assetType: extname(p).toLowerCase(),
138
+ isTest: false,
139
+ isEntryPoint: false,
140
+ })),
141
+ ].sort((a, b) => a.path.localeCompare(b.path));
142
+ for (const node of this.nodes) {
143
+ this.forward[node.path] = [];
144
+ this.reverse[node.path] = [];
145
+ }
146
+ for (const edge of this.edges) {
147
+ if (this.forward[edge.from] !== undefined) {
148
+ this.forward[edge.from].push(edge.to);
149
+ }
150
+ if (this.reverse[edge.to] !== undefined) {
151
+ this.reverse[edge.to].push(edge.from);
152
+ }
153
+ }
154
+ for (const key of Object.keys(this.forward)) {
155
+ this.forward[key] = [...new Set(this.forward[key])].sort();
156
+ }
157
+ for (const key of Object.keys(this.reverse)) {
158
+ this.reverse[key] = [...new Set(this.reverse[key])].sort();
159
+ }
160
+ }
161
+ static compareEdges(a, b) {
162
+ return (a.from.localeCompare(b.from) ||
163
+ a.to.localeCompare(b.to) ||
164
+ a.kind.localeCompare(b.kind));
165
+ }
166
+ dependenciesOf(filePath) {
167
+ const rel = this.normalizeQuery(filePath);
168
+ return this.forward[rel] ?? [];
169
+ }
170
+ dependentsOf(filePath) {
171
+ const rel = this.normalizeQuery(filePath);
172
+ return this.reverse[rel] ?? [];
173
+ }
174
+ transitiveDependenciesOf(filePath) {
175
+ return this.transitiveTraversal(filePath, "forward");
176
+ }
177
+ transitiveDependentsOf(filePath) {
178
+ return this.transitiveTraversal(filePath, "reverse");
179
+ }
180
+ transitiveTraversal(filePath, direction) {
181
+ const rel = this.normalizeQuery(filePath);
182
+ const visited = new Set();
183
+ const queue = [rel];
184
+ const result = [];
185
+ const adjacency = direction === "forward" ? this.forward : this.reverse;
186
+ while (queue.length > 0) {
187
+ const current = queue.shift();
188
+ if (visited.has(current))
189
+ continue;
190
+ visited.add(current);
191
+ if (current !== rel)
192
+ result.push(current);
193
+ for (const next of adjacency[current] ?? []) {
194
+ if (!visited.has(next))
195
+ queue.push(next);
196
+ }
197
+ }
198
+ return result.sort();
199
+ }
200
+ normalizeQuery(filePath) {
201
+ const rel = toPosix(normalize(relative(this.repoPath, resolve(this.repoPath, filePath))));
202
+ return rel;
203
+ }
204
+ }
205
+ /** Restores a cached graph. `testPatterns` (from the cached profile) keeps isTest flags consistent with
206
+ * the test universe the graph was built under; without it the pre-2026-08-23 defaults apply. */
207
+ export function hydrateDependencyGraph(graph, repoPath, testPatterns) {
208
+ const sourcePaths = new Set(graph.nodes.filter((n) => n.isSource).map((n) => n.path));
209
+ const assetPaths = new Set(graph.nodes.filter((n) => n.isAsset).map((n) => n.path));
210
+ return new DependencyGraphImpl(sourcePaths, assetPaths, graph.edges, repoPath, testPatterns ? createTestFileMatcher(testPatterns) : DEFAULT_TEST_FILE_MATCHER);
211
+ }
212
+ /**
213
+ * TypeScript "solution style" tsconfigs (`"files": [], "references": [...]`) parse to zero
214
+ * root file names via ts.parseJsonConfigFileContent — it does not expand `references` into
215
+ * `fileNames` (that requires solution-build APIs this module doesn't otherwise use). Left
216
+ * unhandled, that produces an empty ts.Program and therefore an empty dependency graph,
217
+ * which computeConfidence() would otherwise happily report as "COMPLETE" (nothing to
218
+ * flag as unresolved when there's nothing to resolve). Recursively resolve each
219
+ * referenced project's own file list and compiler options so the program actually
220
+ * contains the real source files.
221
+ */
222
+ function resolveProjectReferenceInputs(configPath, visited) {
223
+ const collected = { fileNames: [], optionsList: [] };
224
+ const normalized = ts.sys.resolvePath ? ts.sys.resolvePath(configPath) : configPath;
225
+ if (visited.has(normalized))
226
+ return collected; // guard against reference cycles
227
+ visited.add(normalized);
228
+ const { config, error } = ts.readConfigFile(configPath, ts.sys.readFile);
229
+ if (error || !config)
230
+ return collected;
231
+ const parsed = ts.parseJsonConfigFileContent(config, ts.sys, dirname(configPath), undefined, configPath);
232
+ collected.fileNames.push(...parsed.fileNames);
233
+ if (parsed.fileNames.length > 0)
234
+ collected.optionsList.push(parsed.options);
235
+ for (const ref of parsed.projectReferences ?? []) {
236
+ const refConfigPath = ts.resolveProjectReferencePath(ref);
237
+ if (!ts.sys.fileExists(refConfigPath))
238
+ continue;
239
+ const nested = resolveProjectReferenceInputs(refConfigPath, visited);
240
+ collected.fileNames.push(...nested.fileNames);
241
+ collected.optionsList.push(...nested.optionsList);
242
+ }
243
+ return collected;
244
+ }
245
+ const FALLBACK_SCAN_IGNORED_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "coverage", "tmp", "temp"]);
246
+ /** Bound on how many files this walk will ever collect, purely as a defensive cap against pathological
247
+ * repos - real source roots are never anywhere close to this in practice. */
248
+ const FALLBACK_SCAN_MAX_FILES = 20_000;
249
+ /** Stage 1B fix (2026-08-21, docs/research/2026-08-21-stage1b-*.md): recursively collects real
250
+ * TS/JS source file paths under the given source-root directories. Used only as a fallback when the
251
+ * repository's own tsconfig scopes the compiler Program to something that excludes real source
252
+ * entirely (see the doc comment on the caller below) - independent of, and not a replacement for, the
253
+ * repo's own tsconfig-driven file discovery. */
254
+ function discoverFallbackSourceFiles(repoPath, sourceRoots) {
255
+ const found = [];
256
+ function walk(dirAbs) {
257
+ if (found.length >= FALLBACK_SCAN_MAX_FILES)
258
+ return;
259
+ let entries;
260
+ try {
261
+ entries = readdirSync(dirAbs, { withFileTypes: true });
262
+ }
263
+ catch {
264
+ return; // root doesn't exist / unreadable - not this function's concern to report, just skip it
265
+ }
266
+ for (const entry of entries) {
267
+ if (found.length >= FALLBACK_SCAN_MAX_FILES)
268
+ return;
269
+ if (FALLBACK_SCAN_IGNORED_DIRS.has(entry.name))
270
+ continue;
271
+ const full = join(dirAbs, entry.name);
272
+ if (entry.isDirectory()) {
273
+ walk(full);
274
+ }
275
+ else if (entry.isFile() && isSourceFileName(entry.name) && !entry.name.endsWith(".d.ts")) {
276
+ found.push(full);
277
+ }
278
+ }
279
+ }
280
+ for (const root of sourceRoots)
281
+ walk(join(repoPath, root.path));
282
+ return found;
283
+ }
284
+ /** Monorepo layouts without a root `tsconfig.json` (a `tsconfig.json` per package under per-package
285
+ * subdirectories such as `packages/`, `apps/`, or `crates/` - e.g. biomejs/biome, calcom/cal.diy) are
286
+ * never seen by `ts.findConfigFile()`, which starts at the repository root and walks UP, never DOWN
287
+ * into subdirectories. This recursively discovers those per-package `tsconfig.json` files so the graph
288
+ * can still be built from the repository's real TypeScript surface instead of crashing. Mirrors the
289
+ * ignore set used by the source-file fallback scan so `node_modules` / build output / VCS internals are
290
+ * never descended into. */
291
+ const NESTED_TSCONFIG_IGNORED_DIRS = FALLBACK_SCAN_IGNORED_DIRS;
292
+ /** Defensive cap on how many tsconfig files the nested walk will ever collect, mirroring the
293
+ * source-file fallback's own bound - real monorepos have one per package, nowhere near this. */
294
+ const NESTED_TSCONFIG_MAX_FILES = 1_000;
295
+ export function discoverNestedTsconfigPaths(repoPath) {
296
+ const found = [];
297
+ function walk(dirAbs) {
298
+ if (found.length >= NESTED_TSCONFIG_MAX_FILES)
299
+ return;
300
+ let entries;
301
+ try {
302
+ entries = readdirSync(dirAbs, { withFileTypes: true });
303
+ }
304
+ catch {
305
+ return; // unreadable directory - skip it, not this function's concern to report
306
+ }
307
+ for (const entry of entries) {
308
+ if (found.length >= NESTED_TSCONFIG_MAX_FILES)
309
+ return;
310
+ if (NESTED_TSCONFIG_IGNORED_DIRS.has(entry.name))
311
+ continue;
312
+ const full = join(dirAbs, entry.name);
313
+ if (entry.isDirectory()) {
314
+ walk(full);
315
+ }
316
+ else if (entry.isFile() && entry.name === "tsconfig.json") {
317
+ found.push(full);
318
+ }
319
+ }
320
+ }
321
+ walk(repoPath);
322
+ // Deterministic order: the option-merge below is "last wins", so a stable source order makes the
323
+ // merged compiler options reproducible across filesystems (readdir order is OS-dependent).
324
+ return found.sort();
325
+ }
326
+ function createProgram(repoPath, fallbackSourceRoots = []) {
327
+ // Phase 01 F5 (2026-08-26). This was `ts.findConfigFile(repoPath, ...)`, which starts at repoPath
328
+ // and walks UP - so a repository cloned beneath any directory containing a tsconfig.json was
329
+ // silently analysed against that ANCESTOR's project instead of its own. Flagged as a known latent
330
+ // bug in src/research/repository/collector.ts and left unfixed since. It never bit the container
331
+ // pipeline (clones land at /repos/<name>, with nothing above them) but it corrupts every local run,
332
+ // which is precisely how this phase's own verification is done. Clamped to the repository root:
333
+ // identical behaviour when a root tsconfig exists, and no escape when it does not.
334
+ const rootConfigCandidate = join(repoPath, "tsconfig.json");
335
+ const configPath = ts.sys.fileExists(rootConfigCandidate) ? rootConfigCandidate : undefined;
336
+ let fileNames = [];
337
+ let options = {};
338
+ let resolvedViaProjectReferences = false;
339
+ let configFileParsingDiagnostics = [];
340
+ if (configPath) {
341
+ const { config, error } = ts.readConfigFile(configPath, ts.sys.readFile);
342
+ if (error) {
343
+ throw new Error(ts.flattenDiagnosticMessageText(error.messageText, "\n"));
344
+ }
345
+ const parsed = ts.parseJsonConfigFileContent(config, ts.sys, dirname(configPath), undefined, configPath);
346
+ fileNames = parsed.fileNames;
347
+ options = parsed.options;
348
+ configFileParsingDiagnostics = parsed.errors;
349
+ // Expand project references whenever the root DECLARES them - not only when the root itself
350
+ // yielded no files.
351
+ //
352
+ // The condition was `fileNames.length === 0`, which assumed a solution-style root contributes
353
+ // nothing of its own. Real ones do. Measured 2026-08-30:
354
+ //
355
+ // typescript-eslint root has "files": [] yet parses to 3 file names and 19 references. The gate
356
+ // was false, references were never expanded, and the graph came back with 310
357
+ // nodes of which 308 were tests and TWO were non-test files - the repository's
358
+ // entire source absent, while ast-spec/tsconfig.build.json alone holds 253
359
+ // files two levels down.
360
+ // babel the same gate, masked: its root `include` pulls in ~500 files directly, so
361
+ // the graph looked plausible at 511 nodes while holding 421 of 697
362
+ // packages/*/src files - 60% coverage, silently.
363
+ //
364
+ // THE INVARIANT IS A UNION, not a replacement: the program contains the root project's own inputs
365
+ // AND the recursively resolved reference inputs. Replacing would delete Babel's 421 root-included
366
+ // files the moment expansion began working for it.
367
+ if (parsed.projectReferences && parsed.projectReferences.length > 0) {
368
+ const visited = new Set([ts.sys.resolvePath ? ts.sys.resolvePath(configPath) : configPath]);
369
+ const collected = { fileNames: [], optionsList: [] };
370
+ for (const ref of parsed.projectReferences) {
371
+ const refConfigPath = ts.resolveProjectReferencePath(ref);
372
+ if (!ts.sys.fileExists(refConfigPath))
373
+ continue;
374
+ const nested = resolveProjectReferenceInputs(refConfigPath, visited);
375
+ collected.fileNames.push(...nested.fileNames);
376
+ collected.optionsList.push(...nested.optionsList);
377
+ }
378
+ if (collected.fileNames.length > 0) {
379
+ fileNames = Array.from(new Set([...fileNames, ...collected.fileNames]));
380
+ // Best-effort merge, as before: later-referenced projects' options win over earlier ones. The
381
+ // root's own options are applied LAST so the config the caller actually pointed at still wins -
382
+ // which is also what keeps a mixed root's existing behaviour unchanged. This is an
383
+ // approximation (referenced projects can legitimately differ) but it is used only for import
384
+ // resolution and AST parsing here, not for type checking.
385
+ const mergedReferences = collected.optionsList.reduce((merged, opts) => ({ ...merged, ...opts }), {});
386
+ options = { ...mergedReferences, ...options };
387
+ resolvedViaProjectReferences = true;
388
+ }
389
+ }
390
+ }
391
+ else {
392
+ // No root tsconfig.json. Instead of throwing (which previously crashed the whole analysis for
393
+ // every monorepo with only per-package tsconfigs - biomejs/biome, calcom/cal.diy, and the 2026-08-24
394
+ // blind baseline's "tsconfig crash" finding), discover the repository's nested per-package tsconfigs
395
+ // and merge their inputs exactly like project references. The same "multiple sub-projects' compiler
396
+ // options merged into one best-effort approximation" caveat applies, so resolvedViaProjectReferences
397
+ // is set to cap confidence at PARTIAL. If there is genuinely no tsconfig anywhere (pure-Rust repo,
398
+ // plain-JS repo), fileNames stays empty and computeConfidence() reports UNSAFE - a conservative
399
+ // FALLBACK rather than an unhandled exception.
400
+ const nestedConfigPaths = discoverNestedTsconfigPaths(repoPath);
401
+ if (nestedConfigPaths.length > 0) {
402
+ const visited = new Set();
403
+ const collected = { fileNames: [], optionsList: [] };
404
+ for (const nestedPath of nestedConfigPaths) {
405
+ const nested = resolveProjectReferenceInputs(nestedPath, visited);
406
+ collected.fileNames.push(...nested.fileNames);
407
+ collected.optionsList.push(...nested.optionsList);
408
+ }
409
+ if (collected.fileNames.length > 0) {
410
+ fileNames = Array.from(new Set(collected.fileNames));
411
+ options = collected.optionsList.reduce((merged, opts) => ({ ...merged, ...opts }), {});
412
+ resolvedViaProjectReferences = true;
413
+ }
414
+ }
415
+ }
416
+ // Stage 1B fix (2026-08-21, docs/research/2026-08-21-stage1b-*.md), root-caused in Stage 1A
417
+ // (sindresorhus/execa): a real repository's tsconfig can exist and be entirely valid (so the repo is
418
+ // correctly not excluded by the "no tsconfig" rule) yet be scoped ONLY to declaration-file validation
419
+ // via an explicit "files" list with no "include" glob (a genuine, non-niche pattern for ESM-first
420
+ // packages that hand-author both .js source and a separate .d.ts for `tsc`-only type-checking) - the
421
+ // resulting Program never loads any real .js/.ts implementation source at all, producing a graph with
422
+ // (effectively) zero source nodes despite real, testable code existing. Trigger is narrow and
423
+ // specific: the tsconfig's own file list resolved to at least one file, but EVERY one of them is a
424
+ // declaration file - not "the file list is merely small" (a genuinely small, correctly-scoped
425
+ // tsconfig should not be second-guessed). When triggered, independently-discovered source files
426
+ // (analyzer.ts's own glob-based source-root scan, entirely separate from and unaffected by the
427
+ // tsconfig's own file-list scoping) are ADDED to the Program's root files - never replacing what the
428
+ // tsconfig specified, only supplementing it.
429
+ if (fileNames.length > 0 && fileNames.every((f) => f.endsWith(".d.ts")) && fallbackSourceRoots.length > 0) {
430
+ const discovered = discoverFallbackSourceFiles(repoPath, fallbackSourceRoots);
431
+ if (discovered.length > 0) {
432
+ fileNames = Array.from(new Set([...fileNames, ...discovered]));
433
+ // Real-world validation finding (2026-08-21, docs/research/2026-08-21-stage1b-*.md): merely
434
+ // adding discovered .js files to rootNames is not sufficient - a tsconfig scoped to declaration-
435
+ // only validation (this fallback's whole trigger condition) was never designed to compile .js at
436
+ // all, so it correctly never sets allowJs. Without it, TypeScript does not treat the
437
+ // fallback-added .js files as valid program members for AST/import extraction purposes. Only
438
+ // forced on when the fallback itself already fired - never changes behavior for a repo whose
439
+ // tsconfig was never mis-scoped in the first place.
440
+ options = { ...options, allowJs: true };
441
+ }
442
+ }
443
+ const program = ts.createProgram({
444
+ rootNames: fileNames,
445
+ options,
446
+ configFileParsingDiagnostics,
447
+ });
448
+ return { program, options, fileNames, resolvedViaProjectReferences };
449
+ }
450
+ function classifySpecifier(specifier) {
451
+ if (specifier.startsWith("`./") || specifier.startsWith("`../"))
452
+ return "relative";
453
+ if (specifier.startsWith("./") || specifier.startsWith("../"))
454
+ return "relative";
455
+ if (specifier.startsWith("`/"))
456
+ return "absolute";
457
+ if (specifier.startsWith("/"))
458
+ return "absolute";
459
+ if (specifier.startsWith("`@/"))
460
+ return "alias";
461
+ if (specifier.startsWith("@/"))
462
+ return "alias";
463
+ if (specifier.startsWith("`~/"))
464
+ return "alias";
465
+ if (specifier.startsWith("~/"))
466
+ return "alias";
467
+ if (specifier.startsWith("`#"))
468
+ return "alias";
469
+ if (specifier.startsWith("#"))
470
+ return "alias";
471
+ return "package";
472
+ }
473
+ function expandAliasCandidates(specifier, aliases, repoPath) {
474
+ for (const { pattern, substitutions } of aliases) {
475
+ if (pattern.endsWith("/*")) {
476
+ const prefix = pattern.slice(0, -1);
477
+ if (specifier.startsWith(prefix)) {
478
+ const rest = specifier.slice(prefix.length);
479
+ return substitutions.map((sub) => {
480
+ const base = sub.endsWith("/*") ? sub.slice(0, -1) : sub;
481
+ return resolve(repoPath, base, rest);
482
+ });
483
+ }
484
+ }
485
+ else if (specifier === pattern || specifier.startsWith(`${pattern}/`)) {
486
+ return substitutions.map((sub) => resolve(repoPath, sub));
487
+ }
488
+ }
489
+ return [];
490
+ }
491
+ /**
492
+ * Bundler import-query suffixes (2026-08-23, deepseek-harness Phase 5): Vite/webpack allow
493
+ * `import css from "../styles/base.css?inline"` / `?raw` / `?url` / `?worker` (no `#` handling). The
494
+ * query changes HOW the bundler loads the file, never WHICH file - so for resolution purposes the
495
+ * specifier is the path before the first `?`/`#`. Without this every such import was "unresolved
496
+ * internal module", which made the whole ui-theme package UNSAFE and blocked 5 of 30 deepseek merges
497
+ * on nothing else. Applied only to relative/absolute/alias specifiers (bare package names cannot carry
498
+ * a query); the original specifier is still what gets recorded in references.
499
+ */
500
+ function stripImportQuery(specifier) {
501
+ // Only `?` - a leading `#` is a Node subpath import (`#internal/x`), and `#fragment` is not a bundler convention.
502
+ const cut = specifier.indexOf("?");
503
+ return cut <= 0 ? specifier : specifier.slice(0, cut);
504
+ }
505
+ function findAssetCandidate(importerAbs, specifier, aliases, repoPath) {
506
+ const candidates = [];
507
+ if (specifier.startsWith(".")) {
508
+ candidates.push(resolve(dirname(importerAbs), specifier));
509
+ }
510
+ else if (specifier.startsWith("/")) {
511
+ candidates.push(resolve(repoPath, specifier.slice(1)));
512
+ }
513
+ else {
514
+ candidates.push(...expandAliasCandidates(specifier, aliases, repoPath));
515
+ }
516
+ for (const candidate of candidates) {
517
+ if (existsSync(candidate))
518
+ return candidate;
519
+ }
520
+ return undefined;
521
+ }
522
+ export async function buildDependencyGraph(options = {}) {
523
+ const start = process.hrtime.bigint();
524
+ const repoPath = options.repoPath ? resolve(options.repoPath) : process.cwd();
525
+ const profile = analyzeRepository(options);
526
+ const entryPointPaths = new Set(profile.entryPoints.map((e) => e.path));
527
+ let { program, options: compilerOptions, resolvedViaProjectReferences } = createProgram(repoPath, profile.sourceRoots);
528
+ let moduleResolutionCache = ts.createModuleResolutionCache(repoPath, (x) => x, compilerOptions);
529
+ const sourceFiles = program
530
+ .getSourceFiles()
531
+ .filter((sf) => sf.fileName && !sf.fileName.endsWith(".d.ts"));
532
+ const internalSourcePaths = new Set();
533
+ for (const sf of sourceFiles) {
534
+ const rel = toRelativeInternal(repoPath, sf.fileName);
535
+ if (rel && isSourceFileName(sf.fileName) && !isExcludedPath(rel, options.excludeDirs ?? [])) {
536
+ internalSourcePaths.add(rel);
537
+ }
538
+ }
539
+ const edges = [];
540
+ const unresolved = [];
541
+ const references = [];
542
+ const assetPaths = new Set();
543
+ const edgeKeys = new Set();
544
+ function addEdge(from, to, kind) {
545
+ const key = `${from}|${to}|${kind}`;
546
+ if (edgeKeys.has(key))
547
+ return;
548
+ edgeKeys.add(key);
549
+ edges.push({ from, to, kind });
550
+ }
551
+ function recordUnresolved(importerRel, ref, reason) {
552
+ unresolved.push({
553
+ importer: importerRel,
554
+ specifier: ref.specifier,
555
+ reason,
556
+ dynamic: ref.dynamic,
557
+ typeOnly: ref.kind === "type-import",
558
+ });
559
+ }
560
+ function recordReference(resolution, importer, ref) {
561
+ references.push({
562
+ resolution,
563
+ specifier: ref.specifier,
564
+ importer,
565
+ kind: ref.kind,
566
+ dynamic: ref.dynamic,
567
+ });
568
+ }
569
+ function recordAssetEdge(importerRel, assetRel) {
570
+ assetPaths.add(assetRel);
571
+ addEdge(importerRel, assetRel, "asset");
572
+ }
573
+ const filesParsed = sourceFiles.length;
574
+ for (const sf of sourceFiles) {
575
+ const importerRel = toRelativeInternal(repoPath, sf.fileName);
576
+ if (!importerRel || !isSourceFileName(sf.fileName))
577
+ continue;
578
+ if (isExcludedPath(importerRel, options.excludeDirs ?? []))
579
+ continue;
580
+ const refs = extractImportRefs(sf);
581
+ for (const ref of refs) {
582
+ if (!ref.specifier) {
583
+ recordUnresolved(importerRel, ref, "empty specifier");
584
+ recordReference("unresolved", importerRel, ref);
585
+ continue;
586
+ }
587
+ if (isNodeBuiltin(ref.specifier)) {
588
+ recordReference("platform-builtin", importerRel, ref);
589
+ continue;
590
+ }
591
+ const specifierCategory = classifySpecifier(ref.specifier);
592
+ const resolvableSpecifier = specifierCategory === "relative" || specifierCategory === "absolute" || specifierCategory === "alias" ? stripImportQuery(ref.specifier) : ref.specifier;
593
+ const resolution = ts.resolveModuleName(resolvableSpecifier, sf.fileName, compilerOptions, ts.sys, moduleResolutionCache);
594
+ if (!resolution.resolvedModule || !resolution.resolvedModule.resolvedFileName) {
595
+ const category = classifySpecifier(ref.specifier);
596
+ if (category === "relative" || category === "absolute" || category === "alias") {
597
+ const assetAbs = findAssetCandidate(sf.fileName, resolvableSpecifier, profile.pathAliases, repoPath);
598
+ if (assetAbs) {
599
+ const assetRel = toRelativeInternal(repoPath, assetAbs);
600
+ if (assetRel) {
601
+ recordAssetEdge(importerRel, assetRel);
602
+ recordReference("internal-asset", importerRel, ref);
603
+ continue;
604
+ }
605
+ }
606
+ recordUnresolved(importerRel, ref, "unresolved internal module");
607
+ recordReference("unresolved", importerRel, ref);
608
+ }
609
+ else {
610
+ recordReference("external-package", importerRel, ref);
611
+ }
612
+ continue;
613
+ }
614
+ const resolved = resolution.resolvedModule.resolvedFileName;
615
+ const targetRel = toRelativeInternal(repoPath, resolved);
616
+ if (targetRel && isSourceFileName(resolved)) {
617
+ internalSourcePaths.add(targetRel);
618
+ addEdge(importerRel, targetRel, ref.kind);
619
+ recordReference("internal-source", importerRel, ref);
620
+ continue;
621
+ }
622
+ if (targetRel && isAssetFileName(resolved)) {
623
+ recordAssetEdge(importerRel, targetRel);
624
+ recordReference("internal-asset", importerRel, ref);
625
+ continue;
626
+ }
627
+ const category = classifySpecifier(ref.specifier);
628
+ if (category === "relative" || category === "absolute" || category === "alias") {
629
+ const reason = targetRel
630
+ ? "resolved to non-source internal file"
631
+ : "resolved outside repository";
632
+ recordUnresolved(importerRel, ref, reason);
633
+ recordReference("unresolved", importerRel, ref);
634
+ }
635
+ else {
636
+ recordReference("external-package", importerRel, ref);
637
+ }
638
+ }
639
+ }
640
+ // Nested-package test visibility (2026-08-24, biomejs/biome finding): `internalSourcePaths` above is
641
+ // strictly the TS PROGRAM's own file list (createProgram()'s `include`/nested-tsconfig-merged
642
+ // fileNames) - so a package whose own tsconfig deliberately excludes its test directory (a real,
643
+ // common pattern; confirmed verbatim on biome: `packages/@biomejs/js-api/tsconfig.json` has
644
+ // `"exclude": ["./tests", "./dist"], "include": ["./src"]`) NEVER contributes those files to the
645
+ // program, so they never became graph nodes and `totalTestsInGraph` stayed 0 even though
646
+ // `profile.testFilePaths` (the separate, tsconfig-agnostic glob walk in analyzer.ts's discoverTests())
647
+ // already found them correctly. Source-ROOT discovery itself was already correct (the 2026-08-21
648
+ // zod/trpc fallback already lists `packages`/`crates` as roots for exactly this monorepo shape) - the
649
+ // gap was narrower: the graph never incorporated what that walk found. Fix: union in any test file
650
+ // discoverTests() found that the TS program's own file list missed, as an ADDITIONAL leaf node
651
+ // (isTest true; no import edges - we have no real resolution info for a file the type-checker was
652
+ // never asked to see, so dependency-graph traversal through it is honestly absent, not guessed at).
653
+ // This does NOT add Rust visibility of any kind - testFilePaths only ever contains files already
654
+ // matched by the JS/TS test-file patterns; a `.rs` test is never in it and stays "unknown" as before.
655
+ for (const testPath of profile.testFilePaths) {
656
+ if (!internalSourcePaths.has(testPath) && !assetPaths.has(testPath))
657
+ internalSourcePaths.add(testPath);
658
+ }
659
+ const graph = new DependencyGraphImpl(internalSourcePaths, assetPaths, edges, repoPath, testFileMatcherForProfile(profile));
660
+ for (const node of graph.nodes) {
661
+ node.isEntryPoint = entryPointPaths.has(node.path);
662
+ }
663
+ const heapDuringBuildMb = process.memoryUsage
664
+ ? Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 100) / 100
665
+ : undefined;
666
+ // Release the heavy TypeScript AST / program after graph extraction.
667
+ program = undefined;
668
+ moduleResolutionCache = undefined;
669
+ if (typeof globalThis.gc === "function") {
670
+ try {
671
+ globalThis.gc();
672
+ }
673
+ catch { /* ignore */ }
674
+ }
675
+ const heapAfterExtractionMb = process.memoryUsage
676
+ ? Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 100) / 100
677
+ : undefined;
678
+ profile.stats.sourceFiles = graph.nodes.filter((n) => n.isSource).length;
679
+ profile.stats.testFiles = graph.nodes.filter((n) => n.isTest).length;
680
+ const integrity = validateDependencyGraph(graph);
681
+ const dynamicUnresolvedCount = unresolved.filter((u) => u.dynamic).length;
682
+ const confidence = computeConfidence(unresolved.length, dynamicUnresolvedCount, integrity.criticalCount, profile.stats.sourceFiles, resolvedViaProjectReferences);
683
+ const internalAssetEdges = edges.filter((e) => e.kind === "asset").length;
684
+ const externalReferences = references.filter((r) => r.resolution === "external-package").length;
685
+ const platformBuiltinReferences = references.filter((r) => r.resolution === "platform-builtin").length;
686
+ const counts = {
687
+ internalSource: references.filter((r) => r.resolution === "internal-source").length,
688
+ internalAsset: internalAssetEdges,
689
+ externalPackage: externalReferences,
690
+ platformBuiltin: platformBuiltinReferences,
691
+ unresolved: unresolved.length,
692
+ };
693
+ const durationMs = Number(process.hrtime.bigint() - start) / 1_000_000;
694
+ const performance = {
695
+ durationMs,
696
+ heapUsedMb: heapDuringBuildMb,
697
+ heapAfterExtractionMb,
698
+ filesDiscovered: filesParsed,
699
+ filesParsed: filesParsed,
700
+ };
701
+ return {
702
+ graph,
703
+ profile,
704
+ unresolved,
705
+ references,
706
+ counts,
707
+ externalReferences,
708
+ platformBuiltinReferences,
709
+ internalAssetEdges,
710
+ performance,
711
+ confidence,
712
+ integrity,
713
+ resolvedViaProjectReferences,
714
+ };
715
+ }
716
+ /**
717
+ * Can DiffCI build a dependency graph for this repository? (Phase 01 F3, 2026-08-26.)
718
+ *
719
+ * This exists so the eligibility gate cannot drift from what the graph builder can actually do -
720
+ * which is exactly what happened. `src/repo/graph.ts` gained nested per-package tsconfig support on
721
+ * 2026-08-24 (for biome and cal.diy); `collectMetadata()` kept refusing anything without a ROOT
722
+ * tsconfig, with the message "DiffCI cannot analyze this repository". On the Phase 01 baseline both
723
+ * `vitest-dev/vitest` and `facebook/docusaurus` were refused by that gate and then built real graphs
724
+ * of 2118 and 1131 nodes. The message was not a limitation, it was out of date - and it cost roughly
725
+ * 144 container launches a day before the ramp caught it.
726
+ *
727
+ * A nested-only layout is capable but not equivalent: createProgram() merges several sub-projects'
728
+ * compiler options into one approximation, which caps graph confidence at PARTIAL. Callers should
729
+ * report the kind rather than flatten it to a boolean.
730
+ */
731
+ export function classifyTypeScriptProject(repoPath) {
732
+ if (existsSync(join(repoPath, "tsconfig.json"))) {
733
+ return { capable: true, kind: "root", nestedCount: 0, reason: "tsconfig.json at the repository root" };
734
+ }
735
+ const nested = discoverNestedTsconfigPaths(repoPath);
736
+ if (nested.length > 0) {
737
+ return {
738
+ capable: true,
739
+ kind: "nested",
740
+ nestedCount: nested.length,
741
+ reason: `no root tsconfig.json, but ${nested.length} per-package tsconfig.json file(s) the graph builder merges`,
742
+ };
743
+ }
744
+ return {
745
+ capable: false,
746
+ kind: "none",
747
+ nestedCount: 0,
748
+ reason: "no tsconfig.json anywhere in the repository - there is no TypeScript project to build a graph from",
749
+ };
750
+ }
751
+ export function validateDependencyGraph(graph) {
752
+ const findings = [];
753
+ const nodePaths = new Set(graph.nodes.map((n) => n.path));
754
+ for (const edge of graph.edges) {
755
+ if (!nodePaths.has(edge.from)) {
756
+ findings.push({
757
+ level: "critical",
758
+ message: `Edge from nonexistent node: ${edge.from} -> ${edge.to}`,
759
+ });
760
+ }
761
+ if (!nodePaths.has(edge.to)) {
762
+ findings.push({
763
+ level: "critical",
764
+ message: `Edge to nonexistent node: ${edge.from} -> ${edge.to}`,
765
+ });
766
+ }
767
+ if (edge.from.startsWith("../") || edge.from.includes("/../")) {
768
+ findings.push({
769
+ level: "critical",
770
+ message: `Edge path escapes repository: ${edge.from} -> ${edge.to}`,
771
+ });
772
+ }
773
+ if (edge.to.startsWith("../") || edge.to.includes("/../")) {
774
+ findings.push({
775
+ level: "critical",
776
+ message: `Edge path escapes repository: ${edge.from} -> ${edge.to}`,
777
+ });
778
+ }
779
+ if (edge.from.includes("\\") || edge.to.includes("\\")) {
780
+ findings.push({
781
+ level: "critical",
782
+ message: `Edge path contains backslash: ${edge.from} -> ${edge.to}`,
783
+ });
784
+ }
785
+ }
786
+ const expectedForward = {};
787
+ const expectedReverse = {};
788
+ for (const path of nodePaths) {
789
+ expectedForward[path] = [];
790
+ expectedReverse[path] = [];
791
+ }
792
+ for (const edge of graph.edges) {
793
+ if (expectedForward[edge.from] !== undefined) {
794
+ expectedForward[edge.from].push(edge.to);
795
+ }
796
+ if (expectedReverse[edge.to] !== undefined) {
797
+ expectedReverse[edge.to].push(edge.from);
798
+ }
799
+ }
800
+ for (const path of nodePaths) {
801
+ const expectedF = [...new Set(expectedForward[path])].sort();
802
+ const actualF = graph.forward[path]?.slice().sort() ?? [];
803
+ if (JSON.stringify(expectedF) !== JSON.stringify(actualF)) {
804
+ findings.push({
805
+ level: "critical",
806
+ message: `Forward adjacency mismatch for ${path}`,
807
+ });
808
+ }
809
+ const expectedR = [...new Set(expectedReverse[path])].sort();
810
+ const actualR = graph.reverse[path]?.slice().sort() ?? [];
811
+ if (JSON.stringify(expectedR) !== JSON.stringify(actualR)) {
812
+ findings.push({
813
+ level: "critical",
814
+ message: `Reverse adjacency mismatch for ${path}`,
815
+ });
816
+ }
817
+ }
818
+ const connected = new Set();
819
+ for (const edge of graph.edges) {
820
+ connected.add(edge.from);
821
+ connected.add(edge.to);
822
+ }
823
+ for (const node of graph.nodes) {
824
+ if (!connected.has(node.path) && !node.isEntryPoint) {
825
+ findings.push({
826
+ level: "warning",
827
+ message: `Isolated node: ${node.path}`,
828
+ });
829
+ }
830
+ if (!node.path) {
831
+ findings.push({ level: "critical", message: "Node with empty path" });
832
+ }
833
+ else if (node.path.includes("\\")) {
834
+ findings.push({
835
+ level: "critical",
836
+ message: `Malformed node path with backslash: ${node.path}`,
837
+ });
838
+ }
839
+ }
840
+ return {
841
+ findings,
842
+ criticalCount: findings.filter((f) => f.level === "critical").length,
843
+ warningCount: findings.filter((f) => f.level === "warning").length,
844
+ stats: {
845
+ nodeCount: graph.nodes.length,
846
+ sourceNodeCount: graph.nodes.filter((n) => n.isSource).length,
847
+ assetNodeCount: graph.nodes.filter((n) => n.isAsset).length,
848
+ edgeCount: graph.edges.length,
849
+ assetEdgeCount: graph.edges.filter((e) => e.kind === "asset").length,
850
+ },
851
+ };
852
+ }
853
+ function computeConfidence(unresolvedCount, dynamicUnresolvedCount, integrityCriticalCount, sourceFileCount, resolvedViaProjectReferences) {
854
+ // An empty graph has nothing to flag as unresolved and nothing to violate integrity
855
+ // checks, so without this it would fall through to "COMPLETE" — the worst possible
856
+ // failure mode (confidently wrong rather than honestly unsure). Zero source files means
857
+ // we could not build a trustworthy view of the repository at all.
858
+ if (sourceFileCount === 0)
859
+ return "UNSAFE";
860
+ if (integrityCriticalCount > 0)
861
+ return "UNSAFE";
862
+ if (dynamicUnresolvedCount > 0)
863
+ return "UNSAFE";
864
+ if (unresolvedCount > 0)
865
+ return "UNSAFE";
866
+ // Project-reference resolution merges multiple sub-projects' compiler options into one
867
+ // best-effort approximation (see resolveProjectReferenceInputs) rather than the exact
868
+ // settings TypeScript itself would use per sub-project, so cap confidence at PARTIAL
869
+ // even when nothing else flagged a problem.
870
+ if (resolvedViaProjectReferences)
871
+ return "PARTIAL";
872
+ return "COMPLETE";
873
+ }
874
+ /** Stage 1B fix (2026-08-21, docs/research/2026-08-21-stage1b-*.md): per-delta refinement of a graph's
875
+ * raw, delta-independent confidence(). Stage 1A's forensic investigation of all 7 fully-UNSAFE
876
+ * repositories found that in 5 of 7, the unresolved-import count was under 0.1% of total graph edges
877
+ * and confined to files structurally unrelated to the actual library/test surface (a build-tooling
878
+ * script, a non-committed generated fixture, an optional peer-dependency shim) - yet computeConfidence()
879
+ * unconditionally treats ANY unresolved import anywhere in the repository as disqualifying the ENTIRE
880
+ * graph for EVERY delta, for as long as that one file's import stays unresolved, regardless of whether
881
+ * the delta being analyzed has anything to do with it.
882
+ *
883
+ * This narrows that: an unresolved import only makes THIS delta's confidence untrustworthy if the
884
+ * unresolved import's importer file is actually reachable from the delta's changed files (in either
885
+ * direction - the changed file might depend on the incomplete file, or something reachable from the
886
+ * changed file might). If none of the graph's unresolved imports are anywhere near this delta, the
887
+ * graph's real incompleteness elsewhere cannot plausibly affect what can safely be determined about
888
+ * THIS specific change.
889
+ *
890
+ * Deliberately NOT narrowed - these remain hard, global blockers regardless of the delta:
891
+ * - `sourceFileCount === 0` (an empty graph gives no reachability information to reason about at all).
892
+ * - `integrity.criticalCount > 0` (the graph's own internal structure is broken - a construction bug,
893
+ * not a property of any one file, so no delta-specific narrowing is meaningful).
894
+ * These match computeConfidence()'s own priority order - both are checked before the unresolved-import
895
+ * conditions this function narrows, and are returned as-is, unchanged, before reachability is examined.
896
+ *
897
+ * This function accepts the SAME confidence-relevant fields as computeConfidence() (rather than the
898
+ * bare enum) precisely so a `confidence === "UNSAFE"` return value can be disambiguated: reachability
899
+ * narrowing only applies when unresolved/dynamic-unresolved imports are the actual reason, never when
900
+ * sourceFileCount or integrity triggered it. */
901
+ export function refineConfidenceForDelta(result, changedFiles) {
902
+ // Anything that was never UNSAFE in the first place passes through untouched - there is nothing to
903
+ // narrow, and re-deriving sourceFileCount/integrity from raw fields here (rather than trusting the
904
+ // already-computed confidence) would be both redundant and a real correctness risk: a caller's
905
+ // profile object could be stale/unrelated to the graph actually being evaluated for reasons that have
906
+ // nothing to do with this delta, and computeConfidence() already made the authoritative call once.
907
+ if (result.confidence !== "UNSAFE")
908
+ return result.confidence;
909
+ // sourceFileCount===0 can never co-occur with unresolved.length>0 in a real graph (buildDependencyGraph
910
+ // only ever records an unresolved entry for a file that isSourceFileName(), and any such file is
911
+ // unconditionally added to internalSourcePaths before unresolved-detection ever runs - so
912
+ // unresolved.length>0 guarantees sourceFileCount>=1 by construction). No need to re-check it
913
+ // independently; result.confidence already reflects it correctly.
914
+ //
915
+ // integrity.criticalCount>0 is different - it's a genuinely independent condition (graph edge/adjacency
916
+ // consistency, orthogonal to import resolution) that COULD co-occur with real unresolved imports, and
917
+ // is never narrowable by reachability (an internally-broken graph gives no trustworthy reachability
918
+ // information to reason about at all) - checked explicitly here, only within this already-UNSAFE
919
+ // branch, not as an unconditional gate that could override a confidence that was never UNSAFE.
920
+ if (result.integrity.criticalCount > 0)
921
+ return "UNSAFE";
922
+ if (result.unresolved.length === 0)
923
+ return result.confidence;
924
+ const reachableFromChanged = new Set();
925
+ for (const file of changedFiles) {
926
+ for (const dep of result.graph.transitiveDependenciesOf(file))
927
+ reachableFromChanged.add(dep);
928
+ for (const dep of result.graph.transitiveDependentsOf(file))
929
+ reachableFromChanged.add(dep);
930
+ }
931
+ const changedSet = new Set(changedFiles);
932
+ const relevantUnresolved = result.unresolved.some((u) => changedSet.has(u.importer) || reachableFromChanged.has(u.importer));
933
+ if (relevantUnresolved)
934
+ return "UNSAFE";
935
+ // None of the unresolved imports are reachable from this delta's changed files - narrow to what
936
+ // confidence would have been without the unresolved-import trigger (still respecting the
937
+ // project-references cap, exactly as computeConfidence()'s own final two branches do).
938
+ return result.resolvedViaProjectReferences ? "PARTIAL" : "COMPLETE";
939
+ }
940
+ export function graphToJson(result) {
941
+ const { graph, profile, unresolved, references, counts, externalReferences, platformBuiltinReferences, internalAssetEdges, performance, confidence, integrity } = result;
942
+ return JSON.stringify({
943
+ profile,
944
+ graph: {
945
+ nodes: graph.nodes,
946
+ edges: graph.edges,
947
+ },
948
+ unresolved,
949
+ references,
950
+ counts,
951
+ externalReferences,
952
+ platformBuiltinReferences,
953
+ internalAssetEdges,
954
+ performance,
955
+ confidence,
956
+ integrity,
957
+ }, (_key, value) => (typeof value === "bigint" ? value.toString() : value), 2);
958
+ }