@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,454 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { basename, extname, join, relative, resolve, sep } from "node:path";
3
+ import { createTestFileMatcher, discoverTestRunnerConfigs, matchesGlob } from "./test-discovery.js";
4
+ import { compileIgnoreRegexes } from "./runner-universe.js";
5
+ import { defaultExcludesFor, defaultIncludesFor, detectDeclaredFrameworks } from "./test-framework.js";
6
+ import { readRepositoryConfig } from "./repo-config.js";
7
+ const IGNORED_DIRS = new Set([
8
+ "node_modules",
9
+ ".git",
10
+ ".next",
11
+ "dist",
12
+ "build",
13
+ "coverage",
14
+ "tmp",
15
+ "temp",
16
+ ]);
17
+ function toPosix(p) {
18
+ return p.split(sep).join("/");
19
+ }
20
+ function repoRelative(repoPath, absolutePath) {
21
+ return toPosix(relative(repoPath, absolutePath));
22
+ }
23
+ function readJson(path) {
24
+ if (!existsSync(path))
25
+ return undefined;
26
+ try {
27
+ return JSON.parse(readFileSync(path, "utf8"));
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ function detectPackageManager(repoPath) {
34
+ if (existsSync(join(repoPath, "bun.lockb")) || existsSync(join(repoPath, "bun.lock"))) {
35
+ return "bun";
36
+ }
37
+ if (existsSync(join(repoPath, "pnpm-lock.yaml"))) {
38
+ return "pnpm";
39
+ }
40
+ if (existsSync(join(repoPath, "yarn.lock"))) {
41
+ return "yarn";
42
+ }
43
+ if (existsSync(join(repoPath, "package-lock.json"))) {
44
+ return "npm";
45
+ }
46
+ return "unknown";
47
+ }
48
+ function findConfig(repoPath, name) {
49
+ const candidates = readdirSync(repoPath, { withFileTypes: true })
50
+ .filter((entry) => entry.isFile() && entry.name.startsWith(name))
51
+ .map((entry) => entry.name)
52
+ .sort();
53
+ return candidates.length > 0 ? candidates[0] : undefined;
54
+ }
55
+ function discoverWorkflows(repoPath) {
56
+ const workflowDir = join(repoPath, ".github", "workflows");
57
+ if (!existsSync(workflowDir))
58
+ return [];
59
+ const workflows = [];
60
+ for (const entry of readdirSync(workflowDir, { withFileTypes: true })) {
61
+ if (!entry.isFile())
62
+ continue;
63
+ if (!/\.(ya?ml)$/.test(entry.name))
64
+ continue;
65
+ const path = repoRelative(repoPath, join(workflowDir, entry.name));
66
+ workflows.push({ path });
67
+ }
68
+ return workflows;
69
+ }
70
+ function parseTsconfigPaths(paths) {
71
+ if (!paths)
72
+ return [];
73
+ return Object.entries(paths).map(([pattern, substitutions]) => ({
74
+ pattern,
75
+ substitutions: substitutions.map(toPosix),
76
+ }));
77
+ }
78
+ function loadTsconfig(repoPath) {
79
+ const path = join(repoPath, "tsconfig.json");
80
+ if (!existsSync(path))
81
+ return undefined;
82
+ const raw = readJson(path);
83
+ if (!raw)
84
+ return undefined;
85
+ const compilerOptions = (raw.compilerOptions ?? {});
86
+ return {
87
+ path: toPosix(relative(repoPath, path)),
88
+ baseUrl: typeof compilerOptions.baseUrl === "string" ? compilerOptions.baseUrl : undefined,
89
+ pathAliases: parseTsconfigPaths((compilerOptions.paths ?? {})),
90
+ allowJs: compilerOptions.allowJs === true,
91
+ include: Array.isArray(raw.include) ? raw.include.map(String) : [],
92
+ exclude: Array.isArray(raw.exclude) ? raw.exclude.map(String) : [],
93
+ };
94
+ }
95
+ function inferRootKind(name) {
96
+ if (name === "src" || name === "lib")
97
+ return "source";
98
+ if (name === "app" || name === "pages")
99
+ return "app";
100
+ if (name === "scripts" || name === "tools" || name === "bin")
101
+ return "scripts";
102
+ if (name === "ops")
103
+ return "operations";
104
+ if (name === "tests" || name === "test")
105
+ return "tests";
106
+ if (name === "api")
107
+ return "api";
108
+ return "source";
109
+ }
110
+ function listDirectSubdirectories(dirPath) {
111
+ if (!existsSync(dirPath))
112
+ return [];
113
+ return readdirSync(dirPath, { withFileTypes: true })
114
+ .filter((entry) => entry.isDirectory())
115
+ .map((entry) => entry.name);
116
+ }
117
+ function discoverSourceRoots(repoPath, sourceRoots, excludeDirs) {
118
+ const roots = [];
119
+ const exclusions = new Set(excludeDirs ?? []);
120
+ function tryRoot(name, kind) {
121
+ if (exclusions.has(name))
122
+ return;
123
+ const full = join(repoPath, name);
124
+ if (existsSync(full) && statSync(full).isDirectory()) {
125
+ roots.push({ path: name, kind });
126
+ }
127
+ }
128
+ if (sourceRoots && sourceRoots.length > 0) {
129
+ for (const name of sourceRoots) {
130
+ tryRoot(name, inferRootKind(name));
131
+ }
132
+ return roots;
133
+ }
134
+ tryRoot("src", "source");
135
+ tryRoot("app", "app");
136
+ tryRoot("pages", "app");
137
+ tryRoot("lib", "source");
138
+ tryRoot("scripts", "scripts");
139
+ // Phase 01 follow-up (2026-08-26): "scripts" is not the only conventional name for build and
140
+ // release tooling. Recognising tools/ and bin/ is what lets impact classification derive "is this
141
+ // auxiliary code?" from the repository instead of assuming DiffCI's own two directory names.
142
+ tryRoot("tools", "scripts");
143
+ tryRoot("bin", "scripts");
144
+ tryRoot("ops", "operations");
145
+ tryRoot("tests", "tests");
146
+ tryRoot("test", "tests");
147
+ tryRoot("api", "api");
148
+ // Real finding, Stage 0 medium batch (2026-08-21): colinhacks/zod (and other monorepos - trpc,
149
+ // vitest before it was excluded on the separate tsconfig gap) have a top-level scripts/ directory
150
+ // (an AUXILIARY root - build/release tooling, not application code) but no src/app/lib/tests/test/api
151
+ // at the root; their real source and tests live nested under packages/<name>/src/. The old
152
+ // `roots.length === 0` fallback condition meant scripts/ alone being present was enough to skip the
153
+ // "scan every top-level directory" fallback entirely, so packages/ - where everything actually is -
154
+ // was never scanned at all: discoverTests() came back with testsTotal:0 for every single delta in
155
+ // these repositories, and downstream selected-test counts (computed independently via the impact
156
+ // graph, not this file list) were then compared against a total of 0, producing the impossible
157
+ // "selected > total" records caught in Gate C's aggregation. Fixed by only skipping the fallback when
158
+ // a PRIMARY (code-bearing) root was found - scripts/ops alone no longer suppresses it.
159
+ //
160
+ // Phase 01 (2026-08-26) extends the same reasoning to `tests`: a top-level test/ or tests/ directory
161
+ // is no more evidence that a repository's CODE lives at the root than a scripts/ directory is.
162
+ // facebook/docusaurus has exactly that shape - a root test/ with all real code under packages/ - and
163
+ // the fallback stayed suppressed, so packages/ was never scanned at all.
164
+ const hasPrimarySourceRoot = roots.some((r) => r.kind !== "scripts" && r.kind !== "operations" && r.kind !== "tests");
165
+ if (!hasPrimarySourceRoot) {
166
+ const covered = new Set(roots.map((r) => r.path));
167
+ const dirs = listDirectSubdirectories(repoPath)
168
+ .filter((name) => !IGNORED_DIRS.has(name) && !exclusions.has(name) && !covered.has(name));
169
+ for (const name of dirs) {
170
+ roots.push({ path: name, kind: "source" });
171
+ }
172
+ }
173
+ return roots;
174
+ }
175
+ /**
176
+ * Matches a repo-relative (posix) path against a glob pattern. Delegates to the shared matcher in
177
+ * test-discovery.ts (Phase 01, 2026-08-26) - this file previously carried its own near-identical
178
+ * copy, so "is this a test?" had two answers that could and did drift. The shared one additionally
179
+ * understands the extended-glob syntax that vitest's and jest's own default include globs use.
180
+ */
181
+ function matchesTestGlob(path, pattern) {
182
+ return matchesGlob(path, pattern);
183
+ }
184
+ function scanFiles(dirPath, repoPath, excludeDirs, callback) {
185
+ const entries = readdirSync(dirPath, { withFileTypes: true });
186
+ for (const entry of entries) {
187
+ if (entry.isDirectory()) {
188
+ if (excludeDirs.has(entry.name) || entry.name.startsWith("."))
189
+ continue;
190
+ scanFiles(join(dirPath, entry.name), repoPath, excludeDirs, callback);
191
+ continue;
192
+ }
193
+ if (!entry.isFile())
194
+ continue;
195
+ callback(repoRelative(repoPath, join(dirPath, entry.name)), entry.name);
196
+ }
197
+ }
198
+ /**
199
+ * Scans the WHOLE repository for test files, not only the discovered source roots (Phase 01,
200
+ * 2026-08-26).
201
+ *
202
+ * Source roots are a guess at where a repository keeps its code, assembled from a fixed list of
203
+ * top-level directory names. Where a repository keeps its TESTS is exactly the thing that must not
204
+ * be guessed. Measured cost of the previous behaviour: `facebook/docusaurus` has 241 test files and
205
+ * DiffCI discovered 2 - the two in the root `__tests__/`. Its top-level `test/` directory counted as
206
+ * a "primary" source root, which suppressed the fallback that would have scanned `packages/`, where
207
+ * the other 239 live. Selecting from a test universe that is 1% of the real one is not a coverage
208
+ * gap, it is a selection built on a false denominator.
209
+ *
210
+ * The exclusion set (node_modules, build output, VCS internals, dotfiles) still applies, so this is
211
+ * bounded by the repository's own committed tree.
212
+ */
213
+ function discoverTests(repoPath, patterns, excludeDirs, matcherOptions = {}) {
214
+ const counts = new Map();
215
+ const filePaths = [];
216
+ const exclusions = new Set([...IGNORED_DIRS.values(), ...excludeDirs]);
217
+ const isTest = createTestFileMatcher(patterns, matcherOptions);
218
+ if (existsSync(repoPath)) {
219
+ scanFiles(repoPath, repoPath, exclusions, (relPath) => {
220
+ if (!isTest(relPath))
221
+ return;
222
+ // Attribute the file to the first pattern that explains it, for the `tests` glob summary.
223
+ const pattern = patterns.find((p) => matchesTestGlob(relPath, p)) ?? patterns[0];
224
+ if (pattern !== undefined)
225
+ counts.set(pattern, (counts.get(pattern) ?? 0) + 1);
226
+ filePaths.push(relPath);
227
+ });
228
+ }
229
+ const locations = Array.from(counts.entries())
230
+ .map(([glob, count]) => ({ glob, count }))
231
+ .sort((a, b) => a.glob.localeCompare(b.glob));
232
+ return { locations, filePaths: filePaths.sort() };
233
+ }
234
+ function discoverConfigFiles(repoPath, excludeDirs) {
235
+ const configNames = new Set([
236
+ "package.json",
237
+ "package-lock.json",
238
+ "pnpm-lock.yaml",
239
+ "yarn.lock",
240
+ "bun.lock",
241
+ "bun.lockb",
242
+ "tsconfig.json",
243
+ "jsconfig.json",
244
+ "next.config.js",
245
+ "next.config.mjs",
246
+ "next.config.ts",
247
+ "tailwind.config.js",
248
+ "tailwind.config.ts",
249
+ "postcss.config.js",
250
+ "postcss.config.mjs",
251
+ "eslint.config.js",
252
+ "eslint.config.mjs",
253
+ "eslint.config.ts",
254
+ "biome.json",
255
+ ".eslintrc.json",
256
+ ".prettierrc",
257
+ "playwright.config.ts",
258
+ "vitest.config.ts",
259
+ "jest.config.js",
260
+ "next-env.d.ts",
261
+ ".gitignore",
262
+ ".env.example",
263
+ ".env.local.example",
264
+ ]);
265
+ const found = [];
266
+ const exclusions = new Set([...IGNORED_DIRS.values(), ...excludeDirs]);
267
+ function walk(dirPath) {
268
+ const entries = readdirSync(dirPath, { withFileTypes: true });
269
+ for (const entry of entries) {
270
+ const full = join(dirPath, entry.name);
271
+ if (entry.isDirectory()) {
272
+ if (exclusions.has(entry.name) || entry.name.startsWith("."))
273
+ continue;
274
+ if (entry.name === "ops" || entry.name === "scripts") {
275
+ found.push(repoRelative(repoPath, full));
276
+ }
277
+ walk(full);
278
+ continue;
279
+ }
280
+ if (configNames.has(entry.name)) {
281
+ found.push(repoRelative(repoPath, full));
282
+ }
283
+ }
284
+ }
285
+ walk(repoPath);
286
+ return found.sort();
287
+ }
288
+ function isSourceExt(ext) {
289
+ const sourceExts = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]);
290
+ return sourceExts.has(ext.toLowerCase());
291
+ }
292
+ function classifyEntryPoints(repoPath, isNext, roots, excludeDirs) {
293
+ const entries = [];
294
+ const exclusions = new Set([...IGNORED_DIRS.values(), ...excludeDirs]);
295
+ function walk(dirPath, fromRoot) {
296
+ const entriesList = readdirSync(dirPath, { withFileTypes: true });
297
+ for (const entry of entriesList) {
298
+ const full = join(dirPath, entry.name);
299
+ const rel = repoRelative(repoPath, full);
300
+ if (entry.isDirectory()) {
301
+ if (exclusions.has(entry.name) || entry.name.startsWith("."))
302
+ continue;
303
+ walk(full, fromRoot);
304
+ continue;
305
+ }
306
+ if (!entry.isFile())
307
+ continue;
308
+ const ext = extname(entry.name).toLowerCase();
309
+ if (!isSourceExt(ext))
310
+ continue;
311
+ const name = basename(entry.name, ext);
312
+ const lower = name.toLowerCase();
313
+ if (isNext && rel.startsWith(fromRoot + "/")) {
314
+ if (lower === "page") {
315
+ entries.push({ path: rel, kind: "next-page" });
316
+ }
317
+ else if (lower === "layout") {
318
+ entries.push({ path: rel, kind: "next-layout" });
319
+ }
320
+ else if (lower === "route") {
321
+ entries.push({ path: rel, kind: "next-route" });
322
+ }
323
+ else if (lower === "api") {
324
+ entries.push({ path: rel, kind: "next-api" });
325
+ }
326
+ else if (lower === "loading") {
327
+ entries.push({ path: rel, kind: "next-loading" });
328
+ }
329
+ else if (lower === "error") {
330
+ entries.push({ path: rel, kind: "next-error" });
331
+ }
332
+ else if (lower === "template") {
333
+ entries.push({ path: rel, kind: "next-template" });
334
+ }
335
+ }
336
+ if (lower.includes(".test") || lower.includes(".spec")) {
337
+ entries.push({ path: rel, kind: "test" });
338
+ }
339
+ if (fromRoot === "scripts") {
340
+ entries.push({ path: rel, kind: "script" });
341
+ }
342
+ }
343
+ }
344
+ for (const root of roots) {
345
+ const fullRoot = join(repoPath, root.path);
346
+ if (!existsSync(fullRoot))
347
+ continue;
348
+ walk(fullRoot, root.path);
349
+ }
350
+ return entries;
351
+ }
352
+ export function analyzeRepository(options = {}) {
353
+ const repoPath = options.repoPath ? resolve(options.repoPath) : process.cwd();
354
+ const excludeDirs = options.excludeDirs ?? [];
355
+ const packageManager = detectPackageManager(repoPath);
356
+ const packageJsonRaw = readJson(join(repoPath, "package.json"));
357
+ const dependencies = Object.keys(packageJsonRaw?.dependencies ?? {});
358
+ const devDependencies = Object.keys(packageJsonRaw?.devDependencies ?? {});
359
+ const scripts = packageJsonRaw?.scripts ?? {};
360
+ const isNext = dependencies.includes("next") || devDependencies.includes("next");
361
+ const tsconfig = loadTsconfig(repoPath);
362
+ const roots = discoverSourceRoots(repoPath, options.sourceRoots, excludeDirs);
363
+ // Test universe = DiffCI's conventional defaults, PLUS the default include globs of every test
364
+ // framework the repository declares, PLUS what its own root Vitest/Jest configs declare explicitly
365
+ // (static read, never executed). The middle term is Phase 01's addition: a repository that relies
366
+ // on its runner's defaults - immer, execa - previously contributed nothing at all.
367
+ const testDiscovery = discoverTestRunnerConfigs(repoPath, scripts);
368
+ const declaredFrameworks = detectDeclaredFrameworks(packageJsonRaw);
369
+ const diffciConfig = readRepositoryConfig(repoPath, packageJsonRaw);
370
+ // `testDiscovery.patterns` is authoritative: DiffCI's conventional globs plus whatever the
371
+ // repository declared explicitly. A framework's own defaults are added on top, and are the only
372
+ // patterns its default excludes are allowed to veto.
373
+ const authoritativePatterns = options.testPatterns ?? testDiscovery.patterns;
374
+ // DEFECT 17. When discovery replaced the defaults, the repository has told us exactly which files
375
+ // its runner executes - and adding the framework DEFAULT includes back on top here would re-widen
376
+ // the universe that was just narrowed, making the whole fix a no-op. That is precisely how
377
+ // ts-jest reported 40 executable tests when jest runs 20.
378
+ const testPatterns = options.testPatterns ??
379
+ (testDiscovery.replacedDefaults
380
+ ? [...authoritativePatterns]
381
+ : Array.from(new Set([...authoritativePatterns, ...defaultIncludesFor(declaredFrameworks.frameworks)])));
382
+ const testExcludePatterns = options.testPatterns
383
+ ? []
384
+ : [...defaultExcludesFor(declaredFrameworks.frameworks), ...testDiscovery.excludeGlobs];
385
+ const { locations: tests, filePaths: testFilePaths } = discoverTests(repoPath, testPatterns, excludeDirs, {
386
+ excludePatterns: testExcludePatterns,
387
+ // When the repository own declaration is in force it is the whole story, so nothing may
388
+ // override the excludes it also declared. Otherwise DiffCI conventional patterns still win,
389
+ // as they have since Phase 01.
390
+ authoritativePatterns: testDiscovery.replacedDefaults ? [] : authoritativePatterns,
391
+ ignoreRegexes: compileIgnoreRegexes(testDiscovery.ignoreRegexSources),
392
+ roots: testDiscovery.roots,
393
+ });
394
+ const workflows = discoverWorkflows(repoPath);
395
+ const configFiles = discoverConfigFiles(repoPath, excludeDirs);
396
+ const entryPoints = classifyEntryPoints(repoPath, isNext, roots, excludeDirs);
397
+ const testFileCount = testFilePaths.length;
398
+ const lockfile = packageManager === "npm"
399
+ ? "package-lock.json"
400
+ : packageManager === "yarn"
401
+ ? "yarn.lock"
402
+ : packageManager === "pnpm"
403
+ ? "pnpm-lock.yaml"
404
+ : packageManager === "bun"
405
+ ? (existsSync(join(repoPath, "bun.lockb")) ? "bun.lockb" : "bun.lock")
406
+ : undefined;
407
+ const nextConfigFile = findConfig(repoPath, "next.config");
408
+ return {
409
+ packageManager,
410
+ packageJson: {
411
+ name: packageJsonRaw?.name,
412
+ version: packageJsonRaw?.version,
413
+ scripts,
414
+ dependencies,
415
+ devDependencies,
416
+ },
417
+ lockfile,
418
+ tsconfig,
419
+ nextConfig: {
420
+ exists: !!nextConfigFile,
421
+ file: nextConfigFile,
422
+ },
423
+ sourceRoots: roots,
424
+ tests,
425
+ testFilePaths,
426
+ testPatterns: [...testPatterns],
427
+ testExcludePatterns: [...testExcludePatterns],
428
+ testAuthoritativePatterns: testDiscovery.replacedDefaults ? [] : [...authoritativePatterns],
429
+ testIgnoreRegexSources: [...testDiscovery.ignoreRegexSources],
430
+ testRoots: [...testDiscovery.roots],
431
+ testRunnerConfigs: testDiscovery.configs,
432
+ diffciConfig,
433
+ testUniverse: {
434
+ declaredFrameworks: declaredFrameworks.frameworks,
435
+ frameworkEvidence: declaredFrameworks.evidence,
436
+ discoveredTestFiles: testFilePaths.length,
437
+ // A repository that declares a test framework and in which DiffCI can find no test file at all
438
+ // is a repository DiffCI does not understand. Recorded as a fact here; acted on in
439
+ // ImpactAnalyzer, which must fall back rather than propose a selection against an empty
440
+ // universe (Phase 01 F1, 2026-08-26).
441
+ blindSpot: declaredFrameworks.frameworks.length > 0 && testFilePaths.length === 0,
442
+ },
443
+ workflows,
444
+ configFiles,
445
+ pathAliases: tsconfig?.pathAliases ?? [],
446
+ entryPoints,
447
+ stats: {
448
+ sourceFiles: roots.length,
449
+ testFiles: testFileCount,
450
+ workflowFiles: workflows.length,
451
+ configFiles: configFiles.length,
452
+ },
453
+ };
454
+ }