@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.
- package/README.md +98 -0
- package/action.yml +155 -0
- package/dist-client/src/client/cli.js +281 -0
- package/dist-client/src/client/context.js +173 -0
- package/dist-client/src/client/observe.js +230 -0
- package/dist-client/src/client/report.js +63 -0
- package/dist-client/src/client/submit.js +89 -0
- package/dist-client/src/client/workflow-guard.js +300 -0
- package/dist-client/src/git/git-diff.js +379 -0
- package/dist-client/src/git/types.js +1 -0
- package/dist-client/src/planner/path-baseline.js +131 -0
- package/dist-client/src/planner/test-command.js +124 -0
- package/dist-client/src/planner/types.js +1 -0
- package/dist-client/src/repo/analyzer.js +454 -0
- package/dist-client/src/repo/graph.js +958 -0
- package/dist-client/src/repo/impact-types.js +1 -0
- package/dist-client/src/repo/impact.js +625 -0
- package/dist-client/src/repo/layout.js +75 -0
- package/dist-client/src/repo/repo-config.js +63 -0
- package/dist-client/src/repo/runner-universe.js +253 -0
- package/dist-client/src/repo/test-discovery.js +383 -0
- package/dist-client/src/repo/test-fixture-ownership.js +76 -0
- package/dist-client/src/repo/test-framework.js +117 -0
- package/dist-client/src/repo/types.js +1 -0
- package/docs/distribution.md +81 -0
- package/package.json +118 -0
|
@@ -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
|
+
}
|