@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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { extname, posix } from "node:path";
|
|
2
|
+
import { refineConfidenceForDelta } from "./graph.js";
|
|
3
|
+
import { repositoryLayout, UNKNOWN_REPOSITORY_LAYOUT } from "./layout.js";
|
|
4
|
+
import { DEFAULT_TEST_FILE_MATCHER, matchesGlob as matchesTestGlob, testFileMatcherForProfile } from "./test-discovery.js";
|
|
5
|
+
import { resolveTestFixtureOwners } from "./test-fixture-ownership.js";
|
|
6
|
+
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]);
|
|
7
|
+
const ASSET_EXTENSIONS = new Set([".css", ".scss", ".sass", ".less", ".json", ".jsonc", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".wasm", ".md", ".txt"]);
|
|
8
|
+
const NEXT_ENTRY_NAMES = new Set(["page", "layout", "route", "api", "loading", "error", "template", "not-found", "middleware", "generatemetadata", "generatestaticparams"]);
|
|
9
|
+
function isSourceFilePath(filePath) { return SOURCE_EXTENSIONS.has(extname(filePath).toLowerCase()); }
|
|
10
|
+
function isAssetFilePath(filePath) { return ASSET_EXTENSIONS.has(extname(filePath).toLowerCase()); }
|
|
11
|
+
// Phase 01 follow-up (2026-08-26): "is this auxiliary code?" and "is this documentation?" are answered
|
|
12
|
+
// from the repository's own discovered layout (src/repo/layout.ts), not from DiffCI's directory names.
|
|
13
|
+
// These previously read `startsWith("scripts/") || startsWith("ops/")` and `startsWith("docs/")`, which
|
|
14
|
+
// classified nothing in a repository organised any other way and could never fire in one without those
|
|
15
|
+
// exact directories.
|
|
16
|
+
function isScriptFile(filePath, layout) { return layout.isScriptPath(filePath); }
|
|
17
|
+
function isDocumentationFile(filePath, layout) { return layout.isDocumentationPath(filePath); }
|
|
18
|
+
function isConfigFile(filePath) {
|
|
19
|
+
const CONFIG_FILE_NAMES = new Set(["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock", "tsconfig.json", "tsconfig.base.json", "tsconfig.build.json", "jsconfig.json"]);
|
|
20
|
+
const base = posix.basename(filePath);
|
|
21
|
+
if (CONFIG_FILE_NAMES.has(base))
|
|
22
|
+
return true;
|
|
23
|
+
if (base.startsWith("next.config"))
|
|
24
|
+
return true;
|
|
25
|
+
if (base.startsWith("tailwind.config"))
|
|
26
|
+
return true;
|
|
27
|
+
if (base.startsWith("postcss.config"))
|
|
28
|
+
return true;
|
|
29
|
+
if (base.startsWith("eslint.config"))
|
|
30
|
+
return true;
|
|
31
|
+
if (base.startsWith(".eslintrc"))
|
|
32
|
+
return true;
|
|
33
|
+
if (base.startsWith("vitest.config"))
|
|
34
|
+
return true;
|
|
35
|
+
if (base.startsWith("jest.config"))
|
|
36
|
+
return true;
|
|
37
|
+
if (base.startsWith("prettier.config"))
|
|
38
|
+
return true;
|
|
39
|
+
if (base.startsWith(".prettierrc"))
|
|
40
|
+
return true;
|
|
41
|
+
if (filePath.startsWith(".github/"))
|
|
42
|
+
return true;
|
|
43
|
+
if (filePath.includes("/Dockerfile"))
|
|
44
|
+
return true;
|
|
45
|
+
if (filePath.startsWith("docker"))
|
|
46
|
+
return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
// The "ops" prefix test that used to lead this was redundant - "ops" is in the set below - and it
|
|
50
|
+
// was the shape a hardcoded layout assumption takes, so the set alone now decides.
|
|
51
|
+
function isInfrastructureFile(filePath) { const INFRA_DIRS = new Set(["ops", "terraform", "cloudformation", "pulumi", "cdktf", "deploy", "deployments", "kubernetes", "k8s", "helm", "docker"]); const first = filePath.split("/")[0]; if (first && INFRA_DIRS.has(first))
|
|
52
|
+
return true; if (posix.basename(filePath).includes("Dockerfile"))
|
|
53
|
+
return true; return false; }
|
|
54
|
+
function isDatabaseFile(filePath) { const DATABASE_DIRS = new Set(["database", "migrations", "prisma", "drizzle", "supabase", "schema"]); const first = filePath.split("/")[0]; return first ? DATABASE_DIRS.has(first) : false; }
|
|
55
|
+
/**
|
|
56
|
+
* Next.js file-name conventions, applied ONLY to repositories that are Next.js applications.
|
|
57
|
+
*
|
|
58
|
+
* Phase 01 follow-up (2026-08-26): this took no layout argument and was called unconditionally, so
|
|
59
|
+
* every repository got Next.js semantics for any file named `page`, `layout`, `route`, `error`,
|
|
60
|
+
* `template` or `middleware`. Measured across the Phase 01 cohort it mislabelled files in five of
|
|
61
|
+
* nine repositories - `unjs/h3` has seven, where `route` and `error` are HTTP concepts with nothing
|
|
62
|
+
* to do with Next. The effect was conservative (entry points widen selection) but the plan asserted
|
|
63
|
+
* something false about the repository, and a wrong reason is not made acceptable by a safe outcome.
|
|
64
|
+
*/
|
|
65
|
+
function classifyNextEntryPoint(filePath, layout) { if (!layout.isNextApp)
|
|
66
|
+
return undefined; if (!isSourceFilePath(filePath))
|
|
67
|
+
return undefined; const base = posix.basename(filePath, extname(filePath)); const lower = base.toLowerCase(); if (lower === "page")
|
|
68
|
+
return "next-page"; if (lower === "layout")
|
|
69
|
+
return "next-layout"; if (lower === "route")
|
|
70
|
+
return "next-route"; if (lower === "api" && filePath.includes("/api/"))
|
|
71
|
+
return "next-api"; if (lower === "loading")
|
|
72
|
+
return "next-loading"; if (lower === "error")
|
|
73
|
+
return "next-error"; if (lower === "template")
|
|
74
|
+
return "next-template"; if (lower === "not-found")
|
|
75
|
+
return "next-error"; if (lower === "middleware")
|
|
76
|
+
return "next-api"; return undefined; }
|
|
77
|
+
function allChangePaths(file) { return file.oldPath ? [file.path, file.oldPath] : [file.path]; }
|
|
78
|
+
function nodeByPath(graph, path) { return graph.nodes.find((n) => n.path === path); }
|
|
79
|
+
function hasNode(graph, path) { return nodeByPath(graph, path) !== undefined; }
|
|
80
|
+
function isEntryPoint(profile, path) { return profile.entryPoints.find((e) => e.path === path); }
|
|
81
|
+
function isPotentialNextEntryPoint(path, layout) { if (!layout.isNextApp)
|
|
82
|
+
return false; if (!isSourceFilePath(path))
|
|
83
|
+
return false; const base = posix.basename(path, extname(path)).toLowerCase(); return NEXT_ENTRY_NAMES.has(base); }
|
|
84
|
+
function isNextLayoutProfile(layout) { return layout.isNextApp; }
|
|
85
|
+
function isNextLayoutEntry(layout, filePath) { if (!isNextLayoutProfile(layout))
|
|
86
|
+
return false; const base = posix.basename(filePath, extname(filePath)).toLowerCase(); return base === "layout"; }
|
|
87
|
+
function parentRouteDirectory(filePath) { const idx = filePath.lastIndexOf("/"); if (idx <= 0)
|
|
88
|
+
return undefined; return filePath.slice(0, idx); }
|
|
89
|
+
function isDescendantOf(parentDir, candidateFile) { const parent = parentDir.endsWith("/") ? parentDir : `${parentDir}/`; return candidateFile.startsWith(parent); }
|
|
90
|
+
function collectDescendantEntryPoints(profile, layout, layoutPath) { const layoutDir = parentRouteDirectory(layoutPath); if (!layoutDir)
|
|
91
|
+
return []; return profile.entryPoints.filter((ep) => { if (ep.path === layoutPath)
|
|
92
|
+
return false; if (!isPotentialNextEntryPoint(ep.path, layout))
|
|
93
|
+
return false; return isDescendantOf(layoutDir, ep.path); }); }
|
|
94
|
+
function shortestPathBFS(graph, start, target, direction, maxDepth = 8) {
|
|
95
|
+
if (start === target)
|
|
96
|
+
return [start];
|
|
97
|
+
const adjacency = direction === "dependents" ? (p) => graph.dependentsOf(p) : (p) => graph.dependenciesOf(p);
|
|
98
|
+
const visited = new Map();
|
|
99
|
+
const queue = [{ path: start, depth: 0 }];
|
|
100
|
+
visited.set(start, null);
|
|
101
|
+
while (queue.length > 0) {
|
|
102
|
+
const current = queue.shift();
|
|
103
|
+
if (current.depth >= maxDepth)
|
|
104
|
+
continue;
|
|
105
|
+
for (const next of adjacency(current.path)) {
|
|
106
|
+
if (visited.has(next))
|
|
107
|
+
continue;
|
|
108
|
+
visited.set(next, current.path);
|
|
109
|
+
if (next === target) {
|
|
110
|
+
const result = [target];
|
|
111
|
+
let back = current.path;
|
|
112
|
+
while (back) {
|
|
113
|
+
result.unshift(back);
|
|
114
|
+
back = visited.get(back);
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
queue.push({ path: next, depth: current.depth + 1 });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function shortestDependentPathToTest(graph, changedFilePath, testPath) {
|
|
124
|
+
const path = shortestPathBFS(graph, changedFilePath, testPath, "dependents", 12);
|
|
125
|
+
if (!path)
|
|
126
|
+
return undefined;
|
|
127
|
+
return { changedFile: changedFilePath, path, pathKind: "dependents" };
|
|
128
|
+
}
|
|
129
|
+
function collectScriptsAmong(candidates, isTestFile, layout) { const result = []; for (const path of candidates) {
|
|
130
|
+
if (isScriptFile(path, layout) && !isTestFile(path))
|
|
131
|
+
result.push(path);
|
|
132
|
+
} return result.sort(); }
|
|
133
|
+
function makeEvidence(reason, changedFile, message, affectedFile, path) { return { reason, changedFile, affectedFile, message, path }; }
|
|
134
|
+
function classifyChangedFile(file, isTestFile, layout, repositoryFiles) {
|
|
135
|
+
const path = file.path;
|
|
136
|
+
const oldPath = file.oldPath;
|
|
137
|
+
if (isConfigFile(path) || (oldPath && isConfigFile(oldPath)))
|
|
138
|
+
return "config";
|
|
139
|
+
if (isInfrastructureFile(path) || (oldPath && isInfrastructureFile(oldPath)))
|
|
140
|
+
return "infrastructure";
|
|
141
|
+
if (isDatabaseFile(path) || (oldPath && isDatabaseFile(oldPath)))
|
|
142
|
+
return "database";
|
|
143
|
+
if (isSourceFilePath(path)) {
|
|
144
|
+
if (isTestFile(path))
|
|
145
|
+
return "test";
|
|
146
|
+
if (isScriptFile(path, layout))
|
|
147
|
+
return "script";
|
|
148
|
+
if (classifyNextEntryPoint(path, layout))
|
|
149
|
+
return "entry-point";
|
|
150
|
+
return "source";
|
|
151
|
+
}
|
|
152
|
+
if (isAssetFilePath(path))
|
|
153
|
+
return "asset";
|
|
154
|
+
if (isDocumentationFile(path, layout))
|
|
155
|
+
return "docs";
|
|
156
|
+
if (isTranslatedDocumentationCompanion(path, repositoryFiles))
|
|
157
|
+
return "docs";
|
|
158
|
+
// Recorded test inputs under <scope>/tests/<snapshots|fixtures>/ are owned by the tests beside them
|
|
159
|
+
// (src/repo/test-fixture-ownership.ts). Only claimed when an owner can actually be resolved at HEAD.
|
|
160
|
+
if (resolveTestFixtureOwners(path, isTestFile, repositoryFiles))
|
|
161
|
+
return "test-fixture";
|
|
162
|
+
return "unknown";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Executable directly-changed tests (2026-08-24): the set of added / modified / renamed-destination /
|
|
166
|
+
* copied-destination test files in a delta that MUST be present in the final selected-test set. This is
|
|
167
|
+
* the "changed-test self-selection" invariant's input. Deleted tests are excluded (their paths no longer
|
|
168
|
+
* exist at HEAD and must never be executed; a deleted test is instead handled conservatively elsewhere).
|
|
169
|
+
* Only `file.path` is considered here: for a rename/copy that is the destination (the source identity is
|
|
170
|
+
* `file.oldPath` and is intentionally not a test to run).
|
|
171
|
+
*/
|
|
172
|
+
export function directlyChangedExecutableTests(delta, isTestFile) {
|
|
173
|
+
const result = new Set();
|
|
174
|
+
for (const file of delta.files) {
|
|
175
|
+
if (file.changeType === "deleted")
|
|
176
|
+
continue;
|
|
177
|
+
const path = file.path;
|
|
178
|
+
if (isSourceFilePath(path) && isTestFile(path))
|
|
179
|
+
result.add(path);
|
|
180
|
+
}
|
|
181
|
+
return Array.from(result).sort();
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Translated-documentation companion records (2026-08-23, docs/research/2026-08-23-deepseek-harness-
|
|
185
|
+
* benchmark.md): the deepseek-harness benchmark showed 26/28 fallbacks carried "Unknown changed file"
|
|
186
|
+
* and 777 of those files were `<doc>.i18n.yaml` - per-document translation-pairing metadata (the git
|
|
187
|
+
* blob hashes of `<doc>.md` / `<doc>.zh.md`) consumed only by a pre-push gate and a merge driver,
|
|
188
|
+
* never by runtime code or tests. Classifying them as docs is a RELATIONSHIP rule, not a YAML rule:
|
|
189
|
+
* 1. the file name is `<base>.<tag>.yaml|yml` where <tag> denotes translation METADATA
|
|
190
|
+
* (`i18n`, `l10n`, `translation`, `translations`) - NOT a locale code, because `<base>.en.yaml`
|
|
191
|
+
* is just as plausibly runtime i18n content loaded by a site generator;
|
|
192
|
+
* 2. a Markdown document `<base>.md` / `<base>.mdx` exists beside it at HEAD - the companion must
|
|
193
|
+
* actually accompany a document; and
|
|
194
|
+
* 3. the caller supplied the HEAD file list at all. Without it the relationship cannot be verified
|
|
195
|
+
* and the file stays "unknown" (full validation) - existing callers that pass nothing see
|
|
196
|
+
* byte-identical behavior.
|
|
197
|
+
* Ordinary YAML (CI config, locale bundles under `locales/`, anything without the documented
|
|
198
|
+
* companion) never reaches this check as docs. Config/infra/database classification runs first, so
|
|
199
|
+
* e.g. `.github/README.i18n.yaml` still counts as config.
|
|
200
|
+
*/
|
|
201
|
+
const TRANSLATION_METADATA_TAGS = new Set(["i18n", "l10n", "translation", "translations"]);
|
|
202
|
+
export function isTranslatedDocumentationCompanion(filePath, repositoryFiles) {
|
|
203
|
+
if (!repositoryFiles)
|
|
204
|
+
return false;
|
|
205
|
+
const base = posix.basename(filePath);
|
|
206
|
+
const match = /^(.+)\.([A-Za-z0-9_-]+)\.(yaml|yml)$/.exec(base);
|
|
207
|
+
if (!match)
|
|
208
|
+
return false;
|
|
209
|
+
const [, docStem, tag] = match;
|
|
210
|
+
if (!docStem || !tag || !TRANSLATION_METADATA_TAGS.has(tag.toLowerCase()))
|
|
211
|
+
return false;
|
|
212
|
+
const dir = posix.dirname(filePath);
|
|
213
|
+
const companionBase = dir === "." ? docStem : `${dir}/${docStem}`;
|
|
214
|
+
return repositoryFiles.has(`${companionBase}.md`) || repositoryFiles.has(`${companionBase}.mdx`);
|
|
215
|
+
}
|
|
216
|
+
function changedFileReasons(file) {
|
|
217
|
+
switch (file.changeType) {
|
|
218
|
+
case "added": return ["DIRECT_CHANGE"];
|
|
219
|
+
case "deleted": return ["DIRECT_CHANGE", "DELETED_FILE_LEGACY_DEPENDENTS"];
|
|
220
|
+
case "renamed": return ["DIRECT_CHANGE", "RENAMED_FILE_LEGACY_IDENTITY"];
|
|
221
|
+
default: return ["DIRECT_CHANGE"];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* No always-run policy by default (Phase 01 follow-up, 2026-08-26).
|
|
226
|
+
*
|
|
227
|
+
* This was a list of three checks matching `test-security.js`, `test-api-guardrails`, `verify-*.test.`
|
|
228
|
+
* and `scripts/*.test.mjs` - DiffCI's and DentalPresence's own file names, compiled into the engine and
|
|
229
|
+
* applied to every repository analysed. On any other repository they matched nothing, so the policy was
|
|
230
|
+
* a repo-specific default that was also dead weight everywhere else.
|
|
231
|
+
*
|
|
232
|
+
* The policy is worth having; the list belongs to the repository. A repository declares its own via
|
|
233
|
+
* `diffci.alwaysRunTests` in package.json or a `diffci.json` (src/repo/repo-config.ts), and this
|
|
234
|
+
* repository declares its three there. Absent configuration means no always-run policy rather than a
|
|
235
|
+
* guessed one.
|
|
236
|
+
*/
|
|
237
|
+
export const DEFAULT_ALWAYS_RUN_CHECKS = [];
|
|
238
|
+
/** Turns the repository's declared always-run globs into a check, so the policy is applied by exactly
|
|
239
|
+
* the same code path as an explicitly-constructed one. */
|
|
240
|
+
function alwaysRunChecksFromProfile(profile) {
|
|
241
|
+
const globs = profile.diffciConfig?.alwaysRunTests ?? [];
|
|
242
|
+
if (globs.length === 0)
|
|
243
|
+
return [];
|
|
244
|
+
return [
|
|
245
|
+
{
|
|
246
|
+
name: "repository-declared",
|
|
247
|
+
reason: "ALWAYS_RUN_POLICY",
|
|
248
|
+
patterns: [],
|
|
249
|
+
globs: [...globs],
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
export class ImpactAnalyzer {
|
|
254
|
+
alwaysRunChecks;
|
|
255
|
+
isTestFile = DEFAULT_TEST_FILE_MATCHER;
|
|
256
|
+
layout = UNKNOWN_REPOSITORY_LAYOUT;
|
|
257
|
+
repositoryFiles;
|
|
258
|
+
constructor(alwaysRunChecks = DEFAULT_ALWAYS_RUN_CHECKS) { this.alwaysRunChecks = alwaysRunChecks; }
|
|
259
|
+
analyze(delta, graphResult, profile, options = {}) {
|
|
260
|
+
const start = process.hrtime.bigint();
|
|
261
|
+
const { graph } = graphResult;
|
|
262
|
+
this.isTestFile = testFileMatcherForProfile(profile);
|
|
263
|
+
this.layout = repositoryLayout(profile);
|
|
264
|
+
this.repositoryFiles = options.repositoryFiles;
|
|
265
|
+
const changedImpacts = delta.files.map((file) => ({ file, category: classifyChangedFile(file, this.isTestFile, this.layout, options.repositoryFiles), reasons: changedFileReasons(file) }));
|
|
266
|
+
const evidence = [];
|
|
267
|
+
const riskSignals = [];
|
|
268
|
+
const fallbackReasons = [];
|
|
269
|
+
this.applyGlobalRiskRules(delta, riskSignals, fallbackReasons);
|
|
270
|
+
// Stage 1B fix (2026-08-21, docs/research/2026-08-21-stage1b-*.md): refine the graph's raw,
|
|
271
|
+
// delta-independent confidence to THIS delta's changed files - see refineConfidenceForDelta()'s doc
|
|
272
|
+
// comment in graph.ts for the full reasoning and what is (and is deliberately NOT) narrowed.
|
|
273
|
+
const changedPaths = delta.files.flatMap((f) => allChangePaths(f));
|
|
274
|
+
const effectiveGraphConfidence = refineConfidenceForDelta(graphResult, changedPaths);
|
|
275
|
+
if (effectiveGraphConfidence === "UNSAFE") {
|
|
276
|
+
const message = "Dependency graph confidence is UNSAFE; full validation required";
|
|
277
|
+
riskSignals.push({ level: "critical", reason: "GRAPH_CONFIDENCE_UNSAFE", message });
|
|
278
|
+
fallbackReasons.push(message);
|
|
279
|
+
}
|
|
280
|
+
// Phase 01 F1 (2026-08-26): a repository that declares a test framework and in which discovery
|
|
281
|
+
// found no test file at all is a repository whose test layout the engine does not understand.
|
|
282
|
+
// Every downstream consumer reads an empty selection as "nothing needs to run", so without this
|
|
283
|
+
// the engine is at its most confident exactly where it is most blind. Measured on immerjs/immer,
|
|
284
|
+
// whose entire `__tests__/` suite was invisible: graph confidence COMPLETE, 5 of 5 commits
|
|
285
|
+
// SELECTIVE, zero tests. Fails closed to FULL instead.
|
|
286
|
+
if (profile.testUniverse?.blindSpot === true) {
|
|
287
|
+
const declared = profile.testUniverse.declaredFrameworks.join(", ");
|
|
288
|
+
const message = `Repository declares ${declared} but no test files were discovered; full validation required`;
|
|
289
|
+
riskSignals.push({ level: "critical", reason: "TEST_UNIVERSE_EMPTY", message });
|
|
290
|
+
if (!fallbackReasons.includes(message))
|
|
291
|
+
fallbackReasons.push(message);
|
|
292
|
+
}
|
|
293
|
+
if (delta.files.length === 0) {
|
|
294
|
+
riskSignals.push({ level: "info", reason: "EMPTY_DELTA", message: "Empty delta; nothing to analyze" });
|
|
295
|
+
}
|
|
296
|
+
const affectedSources = new Map();
|
|
297
|
+
const affectedAssets = new Map();
|
|
298
|
+
const affectedEntryPoints = new Map();
|
|
299
|
+
const affectedTests = new Map();
|
|
300
|
+
const affectedScripts = new Map();
|
|
301
|
+
for (const changedImpact of changedImpacts) {
|
|
302
|
+
for (const path of allChangePaths(changedImpact.file)) {
|
|
303
|
+
this.processChangedPath(path, changedImpact, graph, profile, affectedSources, affectedAssets, affectedEntryPoints, affectedTests, affectedScripts, evidence, riskSignals, fallbackReasons);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
this.handleStructuralNextLayout(changedImpacts, profile, affectedEntryPoints, affectedSources, evidence);
|
|
307
|
+
this.handleAddedEntryPoints(delta, affectedEntryPoints, affectedSources, affectedTests, evidence, fallbackReasons);
|
|
308
|
+
this.collectAlwaysRunTests(profile, graph, affectedTests, evidence);
|
|
309
|
+
// Changed-test self-selection invariant (2026-08-24): every executable directly-changed test
|
|
310
|
+
// (added / modified / renamed-destination / copied-destination) MUST be present in the final
|
|
311
|
+
// selected-test set. This is a defense-in-depth guard over the traversal above so a selection
|
|
312
|
+
// regression cannot silently authorize a SAFE_TO_PROPOSE that would skip a just-edited test.
|
|
313
|
+
const directlyChangedTests = directlyChangedExecutableTests(delta, this.isTestFile);
|
|
314
|
+
const selectedTestPaths = new Set(affectedTests.keys());
|
|
315
|
+
const missingSelectedTests = directlyChangedTests.filter((t) => !selectedTestPaths.has(t));
|
|
316
|
+
if (missingSelectedTests.length > 0) {
|
|
317
|
+
const message = `Changed test selection invariant violated: ${missingSelectedTests.length} directly changed test(s) not selected (${missingSelectedTests.join(", ")})`;
|
|
318
|
+
riskSignals.push({ level: "critical", reason: "TEST_SELECTION_INVARIANT", message, paths: missingSelectedTests });
|
|
319
|
+
if (!fallbackReasons.includes(message))
|
|
320
|
+
fallbackReasons.push(message);
|
|
321
|
+
}
|
|
322
|
+
const fallbackRequired = effectiveGraphConfidence === "UNSAFE" || fallbackReasons.length > 0;
|
|
323
|
+
const analysisStatus = fallbackRequired ? "FALLBACK" : "SAFE_TO_PROPOSE";
|
|
324
|
+
const dedupedRiskSignals = Array.from(new Map(riskSignals.map((s) => [s.reason, s])).values()).sort((a, b) => a.reason.localeCompare(b.reason));
|
|
325
|
+
return {
|
|
326
|
+
changedFiles: changedImpacts,
|
|
327
|
+
affectedSourceFiles: Array.from(affectedSources.keys()).sort(),
|
|
328
|
+
affectedAssets: Array.from(affectedAssets.keys()).sort(),
|
|
329
|
+
affectedTests: Array.from(affectedTests.values()).sort((a, b) => a.path.localeCompare(b.path)),
|
|
330
|
+
affectedEntryPoints: Array.from(affectedEntryPoints.values()).sort((a, b) => a.path.localeCompare(b.path)),
|
|
331
|
+
affectedScripts: Array.from(affectedScripts.keys()).sort(),
|
|
332
|
+
riskSignals: dedupedRiskSignals,
|
|
333
|
+
fallbackRequired,
|
|
334
|
+
fallbackReasons,
|
|
335
|
+
analysisStatus,
|
|
336
|
+
effectiveGraphConfidence,
|
|
337
|
+
evidence: evidence.sort((a, b) => { const c = a.changedFile.localeCompare(b.changedFile); if (c !== 0)
|
|
338
|
+
return c; return a.message.localeCompare(b.message); }),
|
|
339
|
+
performance: { durationMs: Number(process.hrtime.bigint() - start) / 1_000_000 },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
applyGlobalRiskRules(delta, riskSignals, fallbackReasons) {
|
|
343
|
+
const reasons = [
|
|
344
|
+
{ reason: "CONFIG_GLOBAL", message: "Configuration file(s) changed; full validation required", check: (a) => a.configChanged },
|
|
345
|
+
{ reason: "DEPENDENCY_MANIFEST", message: "Dependency manifest changed; full validation required", check: (a) => a.dependencyManifestChanged },
|
|
346
|
+
{ reason: "LOCKFILE_GLOBAL", message: "Lockfile changed; full validation required", check: (a) => a.lockfileChanged },
|
|
347
|
+
{ reason: "WORKFLOW_GLOBAL", message: "GitHub workflow definition(s) changed; full validation required", check: (a) => a.workflowChanged },
|
|
348
|
+
{ reason: "INFRASTRUCTURE_GLOBAL", message: "Infrastructure definition(s) changed; full validation required", check: (a) => a.infrastructureChanged },
|
|
349
|
+
{ reason: "DATABASE_GLOBAL", message: "Database definition(s) changed; full validation required", check: (a) => a.databaseChanged },
|
|
350
|
+
];
|
|
351
|
+
for (const rule of reasons) {
|
|
352
|
+
if (rule.check(delta.analysis)) {
|
|
353
|
+
riskSignals.push({ level: "critical", reason: rule.reason, message: rule.message });
|
|
354
|
+
if (!fallbackReasons.includes(rule.message))
|
|
355
|
+
fallbackReasons.push(rule.message);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
processChangedPath(changedPath, changedImpact, graph, profile, affectedSources, affectedAssets, affectedEntryPoints, affectedTests, affectedScripts, evidence, riskSignals, fallbackReasons) {
|
|
360
|
+
const category = changedImpact.category;
|
|
361
|
+
if (category === "test-fixture") {
|
|
362
|
+
// Resolved per change PATH (a rename's old path resolves independently); an unresolvable side
|
|
363
|
+
// degrades to the unknown-file fallback below rather than being silently dropped.
|
|
364
|
+
const ownership = resolveTestFixtureOwners(changedPath, this.isTestFile, this.repositoryFiles);
|
|
365
|
+
if (ownership) {
|
|
366
|
+
for (const owner of ownership.owners) {
|
|
367
|
+
this.addAffectedTest(changedPath, owner, graph, affectedTests, "TEST_FIXTURE_OWNER", evidence);
|
|
368
|
+
}
|
|
369
|
+
evidence.push(makeEvidence("TEST_FIXTURE_OWNER", changedPath, `Test fixture ${changedPath} under ${ownership.fixtureDir} is owned by ${ownership.owners.length} test(s) in ${ownership.testsDir} (${ownership.scope})`));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (category === "unknown" || category === "test-fixture") {
|
|
374
|
+
const message = `Unknown changed file: ${changedPath}`;
|
|
375
|
+
evidence.push(makeEvidence("UNKNOWN_FILE", changedPath, message));
|
|
376
|
+
riskSignals.push({ level: "critical", reason: "UNKNOWN_FILE", message, paths: [changedPath] });
|
|
377
|
+
if (!fallbackReasons.includes(message))
|
|
378
|
+
fallbackReasons.push(message);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (category === "docs")
|
|
382
|
+
return;
|
|
383
|
+
if (isAssetFilePath(changedPath)) {
|
|
384
|
+
this.processAssetChange(changedPath, graph, profile, affectedAssets, affectedEntryPoints, affectedTests, affectedSources, evidence);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (category === "config" || category === "workflow" || category === "infrastructure" || category === "database") {
|
|
388
|
+
evidence.push(makeEvidence("CONFIG_GLOBAL", changedPath, `Global ${category} change at ${changedPath}`));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (category === "test") {
|
|
392
|
+
this.processTestChange(changedPath, changedImpact, graph, profile, affectedSources, affectedEntryPoints, affectedTests, affectedScripts, evidence, riskSignals, fallbackReasons);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (changedImpact.file.changeType === "deleted" && !hasNode(graph, changedPath)) {
|
|
396
|
+
const message = `Deleted source/asset ${changedPath} not present in HEAD dependency graph; legacy dependents cannot be determined`;
|
|
397
|
+
evidence.push(makeEvidence("DELETED_FILE_UNKNOWABLE_GRAPH", changedPath, message));
|
|
398
|
+
riskSignals.push({ level: "critical", reason: "DELETED_FILE_UNKNOWABLE_GRAPH", message, paths: [changedPath] });
|
|
399
|
+
if (!fallbackReasons.includes(message))
|
|
400
|
+
fallbackReasons.push(message);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.processSourceChange(changedPath, graph, profile, affectedSources, affectedEntryPoints, affectedTests, affectedScripts, evidence);
|
|
404
|
+
}
|
|
405
|
+
processAssetChange(assetPath, graph, profile, affectedAssets, affectedEntryPoints, affectedTests, affectedSources, evidence) {
|
|
406
|
+
if (!affectedAssets.has(assetPath))
|
|
407
|
+
affectedAssets.set(assetPath, []);
|
|
408
|
+
affectedAssets.get(assetPath).push(makeEvidence("ASSET_DEPENDENCY", assetPath, `Changed asset: ${assetPath}`));
|
|
409
|
+
if (!hasNode(graph, assetPath))
|
|
410
|
+
return;
|
|
411
|
+
const dependents = graph.transitiveDependentsOf(assetPath);
|
|
412
|
+
for (const dependent of dependents) {
|
|
413
|
+
if (nodeByPath(graph, dependent)?.isEntryPoint) {
|
|
414
|
+
this.addAffectedEntryPoint(dependent, profile, affectedEntryPoints, "ASSET_DEPENDENCY", assetPath, evidence);
|
|
415
|
+
}
|
|
416
|
+
if (this.isTestFile(dependent)) {
|
|
417
|
+
this.addAffectedTest(assetPath, dependent, graph, affectedTests, "ASSET_DEPENDENCY", evidence);
|
|
418
|
+
}
|
|
419
|
+
if (isSourceFilePath(dependent) && !this.isTestFile(dependent)) {
|
|
420
|
+
if (!affectedSources.has(dependent))
|
|
421
|
+
affectedSources.set(dependent, []);
|
|
422
|
+
affectedSources.get(dependent).push(makeEvidence("ASSET_DEPENDENCY", assetPath, `Asset ${assetPath} affects source ${dependent}`, dependent));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
processSourceChange(sourcePath, graph, profile, affectedSources, affectedEntryPoints, affectedTests, affectedScripts, evidence) {
|
|
427
|
+
if (!hasNode(graph, sourcePath)) {
|
|
428
|
+
if (isPotentialNextEntryPoint(sourcePath, this.layout) || isScriptFile(sourcePath, this.layout)) {
|
|
429
|
+
this.addAffectedEntryPoint(sourcePath, profile, affectedEntryPoints, "NEW_ENTRY_POINT", sourcePath, evidence);
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (!this.isTestFile(sourcePath)) {
|
|
434
|
+
if (!affectedSources.has(sourcePath))
|
|
435
|
+
affectedSources.set(sourcePath, []);
|
|
436
|
+
affectedSources.get(sourcePath).push(makeEvidence("DIRECT_CHANGE", sourcePath, `Changed source: ${sourcePath}`));
|
|
437
|
+
}
|
|
438
|
+
if (isEntryPoint(profile, sourcePath) || classifyNextEntryPoint(sourcePath, this.layout)) {
|
|
439
|
+
this.addAffectedEntryPoint(sourcePath, profile, affectedEntryPoints, "NEXT_ENTRY_POINT", sourcePath, evidence);
|
|
440
|
+
}
|
|
441
|
+
const dependents = graph.transitiveDependentsOf(sourcePath);
|
|
442
|
+
for (const dependent of dependents) {
|
|
443
|
+
if (!this.isTestFile(dependent)) {
|
|
444
|
+
if (!affectedSources.has(dependent))
|
|
445
|
+
affectedSources.set(dependent, []);
|
|
446
|
+
affectedSources.get(dependent).push(makeEvidence("DEPENDENCY", sourcePath, `${sourcePath} affects ${dependent} via dependency graph`, dependent));
|
|
447
|
+
}
|
|
448
|
+
if (nodeByPath(graph, dependent)?.isEntryPoint) {
|
|
449
|
+
this.addAffectedEntryPoint(dependent, profile, affectedEntryPoints, "DEPENDENCY", sourcePath, evidence);
|
|
450
|
+
}
|
|
451
|
+
if (this.isTestFile(dependent)) {
|
|
452
|
+
this.addAffectedTest(sourcePath, dependent, graph, affectedTests, "DEPENDENCY", evidence);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const reachable = new Set([sourcePath, ...dependents]);
|
|
456
|
+
for (const scriptPath of collectScriptsAmong(reachable, this.isTestFile, this.layout)) {
|
|
457
|
+
if (!affectedScripts.has(scriptPath))
|
|
458
|
+
affectedScripts.set(scriptPath, []);
|
|
459
|
+
affectedScripts.get(scriptPath).push(makeEvidence("DEPENDENCY", sourcePath, `${sourcePath} affects script ${scriptPath}`, scriptPath));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Handles a directly-changed test file (2026-08-24 fix). Previously changed tests fell through to
|
|
464
|
+
* processSourceChange, which never added the test itself to affectedTests, so a "test-only" diff
|
|
465
|
+
* (e.g. Nx #36723) authorized SAFE_TO_PROPOSE with zero selected tests. Now:
|
|
466
|
+
* - deleted test: never select the (nonexistent) path; fall back when its node is absent, else
|
|
467
|
+
* traverse its legacy dependents conservatively;
|
|
468
|
+
* - rename/copy source identity (oldPath): record the legacy identity only — the destination is the
|
|
469
|
+
* executable test and is handled on its own path iteration;
|
|
470
|
+
* - added / modified / renamed-destination / copied-destination: select the test itself and then
|
|
471
|
+
* traverse dependents (shared test helpers) exactly like processSourceChange.
|
|
472
|
+
*/
|
|
473
|
+
processTestChange(changedPath, changedImpact, graph, profile, affectedSources, affectedEntryPoints, affectedTests, affectedScripts, evidence, riskSignals, fallbackReasons) {
|
|
474
|
+
const file = changedImpact.file;
|
|
475
|
+
const isOldPath = file.oldPath !== undefined && changedPath === file.oldPath;
|
|
476
|
+
if (file.changeType === "deleted") {
|
|
477
|
+
// Never select a deleted test. If its node is still present (stale/base graph), traverse legacy
|
|
478
|
+
// dependents conservatively; otherwise safety cannot be established and we require full validation.
|
|
479
|
+
if (hasNode(graph, changedPath)) {
|
|
480
|
+
this.processSourceChange(changedPath, graph, profile, affectedSources, affectedEntryPoints, affectedTests, affectedScripts, evidence);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const message = `Deleted test ${changedPath}; legacy coverage/dependents cannot be established safely`;
|
|
484
|
+
evidence.push(makeEvidence("DELETED_FILE_UNKNOWABLE_GRAPH", changedPath, message));
|
|
485
|
+
riskSignals.push({ level: "critical", reason: "DELETED_FILE_UNKNOWABLE_GRAPH", message, paths: [changedPath] });
|
|
486
|
+
if (!fallbackReasons.includes(message))
|
|
487
|
+
fallbackReasons.push(message);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (isOldPath) {
|
|
491
|
+
evidence.push(makeEvidence("RENAMED_FILE_LEGACY_IDENTITY", changedPath, `Renamed test source ${changedPath}; destination handled separately`));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (this.isTestFile(changedPath)) {
|
|
495
|
+
this.addAffectedTest(changedPath, changedPath, graph, affectedTests, "DIRECT_TEST_CHANGE", evidence);
|
|
496
|
+
}
|
|
497
|
+
this.processSourceChange(changedPath, graph, profile, affectedSources, affectedEntryPoints, affectedTests, affectedScripts, evidence);
|
|
498
|
+
}
|
|
499
|
+
addAffectedEntryPoint(path, profile, affectedEntryPoints, reason, changedFile, evidence) {
|
|
500
|
+
const ep = isEntryPoint(profile, path);
|
|
501
|
+
const kind = ep?.kind ?? classifyNextEntryPoint(path, this.layout) ?? "unknown";
|
|
502
|
+
const existing = affectedEntryPoints.get(path);
|
|
503
|
+
if (existing) {
|
|
504
|
+
if (!existing.reasons.includes(reason))
|
|
505
|
+
existing.reasons.push(reason);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
affectedEntryPoints.set(path, { path, kind, reasons: [reason] });
|
|
509
|
+
}
|
|
510
|
+
evidence.push(makeEvidence(reason, changedFile, `Affected entry point ${path} (${kind})`, path));
|
|
511
|
+
}
|
|
512
|
+
addAffectedTest(changedFile, testPath, graph, affectedTests, reason, evidence) {
|
|
513
|
+
let existing = affectedTests.get(testPath);
|
|
514
|
+
if (!existing) {
|
|
515
|
+
existing = { path: testPath, reasons: [], evidence: [] };
|
|
516
|
+
affectedTests.set(testPath, existing);
|
|
517
|
+
}
|
|
518
|
+
if (!existing.reasons.includes(reason))
|
|
519
|
+
existing.reasons.push(reason);
|
|
520
|
+
const path = shortestDependentPathToTest(graph, changedFile, testPath);
|
|
521
|
+
const ev = makeEvidence(reason, changedFile, `Affected test: ${testPath} due to ${reason}`, testPath, path);
|
|
522
|
+
existing.evidence.push(ev);
|
|
523
|
+
evidence.push(ev);
|
|
524
|
+
}
|
|
525
|
+
handleStructuralNextLayout(changedImpacts, profile, affectedEntryPoints, affectedSources, evidence) {
|
|
526
|
+
for (const changedImpact of changedImpacts) {
|
|
527
|
+
if (changedImpact.category !== "entry-point")
|
|
528
|
+
continue;
|
|
529
|
+
for (const path of allChangePaths(changedImpact.file)) {
|
|
530
|
+
if (!isNextLayoutEntry(this.layout, path))
|
|
531
|
+
continue;
|
|
532
|
+
const descendants = collectDescendantEntryPoints(profile, this.layout, path);
|
|
533
|
+
for (const desc of descendants) {
|
|
534
|
+
this.addAffectedEntryPoint(desc.path, profile, affectedEntryPoints, "NEXT_LAYOUT_ANCESTOR", path, evidence);
|
|
535
|
+
if (!affectedSources.has(desc.path))
|
|
536
|
+
affectedSources.set(desc.path, []);
|
|
537
|
+
affectedSources.get(desc.path).push(makeEvidence("NEXT_LAYOUT_ANCESTOR", path, `Layout ancestor ${path} structurally affects ${desc.path}`, desc.path));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
handleAddedEntryPoints(delta, affectedEntryPoints, affectedSources, affectedTests, evidence, _fallbackReasons) {
|
|
543
|
+
for (const added of this.detectAddedEntryPoints(delta)) {
|
|
544
|
+
if (!affectedEntryPoints.has(added.path)) {
|
|
545
|
+
affectedEntryPoints.set(added.path, { path: added.path, kind: added.kind, reasons: ["NEW_ENTRY_POINT"] });
|
|
546
|
+
evidence.push(makeEvidence("NEW_ENTRY_POINT", added.path, `New entry point: ${added.path} (${added.kind})`, added.path));
|
|
547
|
+
}
|
|
548
|
+
if (added.kind === "test" && !affectedTests.has(added.path)) {
|
|
549
|
+
affectedTests.set(added.path, { path: added.path, reasons: ["NEW_TEST_FILE"], evidence: [makeEvidence("NEW_TEST_FILE", added.path, `New test file: ${added.path}`, added.path)] });
|
|
550
|
+
}
|
|
551
|
+
if (added.kind === "script") {
|
|
552
|
+
if (!affectedSources.has(added.path))
|
|
553
|
+
affectedSources.set(added.path, []);
|
|
554
|
+
affectedSources.get(added.path).push(makeEvidence("NEW_SCRIPT_FILE", added.path, `New script: ${added.path}`, added.path));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
detectAddedEntryPoints(delta) {
|
|
559
|
+
const result = [];
|
|
560
|
+
for (const file of delta.files) {
|
|
561
|
+
if (file.changeType !== "added")
|
|
562
|
+
continue;
|
|
563
|
+
const kind = classifyNextEntryPoint(file.path, this.layout);
|
|
564
|
+
if (kind)
|
|
565
|
+
result.push({ path: file.path, kind });
|
|
566
|
+
else if (isScriptFile(file.path, this.layout))
|
|
567
|
+
result.push({ path: file.path, kind: "script" });
|
|
568
|
+
else if (this.isTestFile(file.path))
|
|
569
|
+
result.push({ path: file.path, kind: "test" });
|
|
570
|
+
}
|
|
571
|
+
return result;
|
|
572
|
+
}
|
|
573
|
+
collectAlwaysRunTests(profile, graph, affectedTests, evidence) {
|
|
574
|
+
const alwaysRunPaths = new Set();
|
|
575
|
+
const knownTestPaths = new Set();
|
|
576
|
+
for (const testLocation of profile.tests) {
|
|
577
|
+
for (const node of graph.nodes) {
|
|
578
|
+
if (matchesTestGlob(node.path, testLocation.glob) || this.isTestFile(node.path)) {
|
|
579
|
+
knownTestPaths.add(node.path);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
for (const check of [...this.alwaysRunChecks, ...alwaysRunChecksFromProfile(profile)]) {
|
|
584
|
+
for (const path of knownTestPaths) {
|
|
585
|
+
if (check.patterns.some((pattern) => pattern.test(path))) {
|
|
586
|
+
alwaysRunPaths.add(path);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (check.globs?.some((glob) => matchesTestGlob(path, glob)))
|
|
590
|
+
alwaysRunPaths.add(path);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
for (const testPath of alwaysRunPaths) {
|
|
594
|
+
if (!affectedTests.has(testPath))
|
|
595
|
+
affectedTests.set(testPath, { path: testPath, reasons: ["ALWAYS_RUN_POLICY"], evidence: [] });
|
|
596
|
+
const existing = affectedTests.get(testPath);
|
|
597
|
+
if (!existing.reasons.includes("ALWAYS_RUN_POLICY"))
|
|
598
|
+
existing.reasons.push("ALWAYS_RUN_POLICY");
|
|
599
|
+
const ev = makeEvidence("ALWAYS_RUN_POLICY", testPath, `Always-run check policy includes ${testPath}`, testPath);
|
|
600
|
+
existing.evidence.push(ev);
|
|
601
|
+
evidence.push(ev);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/*
|
|
606
|
+
* The local `matchesGlob` that stood here was DELETED on 2026-08-30.
|
|
607
|
+
*
|
|
608
|
+
* It was the copy Phase 01 intended to remove when `test-discovery.ts` became the single definition of
|
|
609
|
+
* "does this path match this glob", and it carried two defects the shared one does not:
|
|
610
|
+
*
|
|
611
|
+
* PERFORMANCE. It translated `**\/?(*.)+(spec|test).[jt]s?(x)` into
|
|
612
|
+
* `^.([^/]*.)+(spec|test).[jt]s.(x)$` - a nested quantifier over an overlapping inner pattern, which
|
|
613
|
+
* backtracks catastrophically. Measured on one non-matching path: 0.69 ms at 10 characters, 106 ms at
|
|
614
|
+
* 20, 1.8 s at 24, 30 s at 28, roughly 4x per additional character. Prettier's paths are 30-60
|
|
615
|
+
* characters, so `prettier/prettier` spent 601 seconds per candidate against a full suite that runs in
|
|
616
|
+
* 214, and a single instrumented candidate was still inside this function after 84 minutes with 100%
|
|
617
|
+
* of CPU samples in it.
|
|
618
|
+
*
|
|
619
|
+
* CORRECTNESS. It stripped the leading `**\/` and then anchored with `^`, so
|
|
620
|
+
* `**\/__tests__\/**\/*.[jt]s?(x)` became `^__tests__\/...$` and matched NEITHER
|
|
621
|
+
* `src/__tests__/foo.test.js` NOR `__tests__/foo.test.js`.
|
|
622
|
+
*
|
|
623
|
+
* Both came from hand-translating glob syntax into regex by string substitution. There is now one
|
|
624
|
+
* implementation, in test-discovery.ts, imported above as `matchesTestGlob`.
|
|
625
|
+
*/
|