@blzsky/variant 0.2.1 → 0.3.0-canary.1
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 +222 -85
- package/dist/{build-3DSBMMI3.js → build-GCUFPPIB.js} +11 -10
- package/dist/check-AKYABBIV.js +1459 -0
- package/dist/{chunk-43VLELTL.js → chunk-2UVLKBLV.js} +1 -1
- package/dist/chunk-34VGKPEM.js +59 -0
- package/dist/chunk-BNACT47L.js +590 -0
- package/dist/chunk-C3NZIFW6.js +174 -0
- package/dist/{chunk-ZH2PQUNL.js → chunk-EFCMBBBT.js} +72 -110
- package/dist/chunk-H55GYSQ6.js +102 -0
- package/dist/chunk-HL65IPEQ.js +167 -0
- package/dist/chunk-IHXJ72XA.js +31 -0
- package/dist/{chunk-FZEXZBXB.js → chunk-JEJRXPCU.js} +41 -9
- package/dist/chunk-LUTP2TVU.js +94 -0
- package/dist/{chunk-7OPTWU6E.js → chunk-MKUYZIXF.js} +1 -1
- package/dist/chunk-MNYKLWRC.js +377 -0
- package/dist/{chunk-Q7RLUZSN.js → chunk-NJSTAWDM.js} +10 -3
- package/dist/{chunk-26DHEXGS.js → chunk-NOC53Z5Q.js} +1 -1
- package/dist/chunk-O6FCFZDF.js +127 -0
- package/dist/{chunk-6BXSZPHC.js → chunk-OON6O4S5.js} +99 -6
- package/dist/{chunk-FA64KWJ2.js → chunk-TPMHY4L6.js} +16 -31
- package/dist/chunk-TRUK3TJF.js +575 -0
- package/dist/chunk-WTXWEEI2.js +21 -0
- package/dist/{chunk-RC23JQKI.js → chunk-WULBOSG5.js} +15 -2
- package/dist/chunk-X3VVGTEV.js +268 -0
- package/dist/{chunk-TCISS2AS.js → chunk-XXMD4NC5.js} +120 -25
- package/dist/cli.js +89 -30
- package/dist/{diff-HSJBZFMC.js → diff-ST5KHT7P.js} +5 -5
- package/dist/{doctor-B7MHFIYJ.js → doctor-OEIY4Y5X.js} +41 -8
- package/dist/{env-I7JZRWDN.js → env-EQAOZCFR.js} +8 -9
- package/dist/git-VGHUF2SY.js +30 -0
- package/dist/impact-AXLZTLVT.js +266 -0
- package/dist/index.js +2 -2
- package/dist/init-QCOZWLOO.js +793 -0
- package/dist/{insight-JQLTGFYB.js → insight-KRWT6Z47.js} +9 -10
- package/dist/pr-7JXUC5BQ.js +710 -0
- package/dist/{run-C6VAQOVM.js → run-UOPS5FRU.js} +11 -10
- package/dist/stryker-host.d.ts +2 -0
- package/dist/stryker-host.js +135 -0
- package/dist/stryker-plugins.d.ts +33 -0
- package/dist/stryker-plugins.js +142 -0
- package/dist/{symbol-graph-E5GXKDFZ.js → symbol-graph-GYOI4GU4.js} +3 -3
- package/dist/{tsconfig-paths-E3UEDGXZ.js → tsconfig-paths-SV3PULCW.js} +3 -1
- package/dist/vitest.cjs +629 -0
- package/dist/vitest.d.cts +181 -0
- package/dist/vitest.d.ts +181 -0
- package/dist/vitest.js +358 -0
- package/dist/workspace-IQFLR3RY.js +65 -0
- package/package.json +17 -3
- package/dist/check-SIQJU6Z2.js +0 -44
- package/dist/chunk-DRMASVYN.js +0 -25
- package/dist/chunk-KSHWC2XN.js +0 -78
- package/dist/chunk-QRPI6TJE.js +0 -68
- package/dist/chunk-VYWPQKMS.js +0 -88
- package/dist/chunk-XTYVVUWW.js +0 -279
- package/dist/impact-PRZ6HEP5.js +0 -735
- package/dist/init-O2BY3FOY.js +0 -158
- package/dist/pr-Y724QAHE.js +0 -165
- package/dist/workspace-HRTFGWKN.js +0 -186
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/core/history/impact-log.ts
|
|
2
|
+
import { appendFile, mkdir, readFile, writeFile } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
var MAX_IMPACT_RECORDS = 1e3;
|
|
5
|
+
var LOG_FILENAME = "impact.jsonl";
|
|
6
|
+
var RECONCILIATION_FILENAME = "reconciliation.jsonl";
|
|
7
|
+
function defaultHistoryDir(cwd) {
|
|
8
|
+
return path.join(cwd, ".variant", "history");
|
|
9
|
+
}
|
|
10
|
+
async function appendImpactPrediction(historyDir, prediction) {
|
|
11
|
+
return appendRecord(historyDir, LOG_FILENAME, prediction);
|
|
12
|
+
}
|
|
13
|
+
async function appendReconciliation(historyDir, reconciliation) {
|
|
14
|
+
return appendRecord(historyDir, RECONCILIATION_FILENAME, reconciliation);
|
|
15
|
+
}
|
|
16
|
+
async function appendRecord(historyDir, filename, record) {
|
|
17
|
+
try {
|
|
18
|
+
await mkdir(historyDir, { recursive: true });
|
|
19
|
+
const file = path.join(historyDir, filename);
|
|
20
|
+
await appendFile(file, `${JSON.stringify(record)}
|
|
21
|
+
`);
|
|
22
|
+
const lines = (await readFile(file, "utf8")).split("\n").filter((l) => l.trim() !== "");
|
|
23
|
+
if (lines.length > MAX_IMPACT_RECORDS) {
|
|
24
|
+
await writeFile(file, `${lines.slice(-MAX_IMPACT_RECORDS).join("\n")}
|
|
25
|
+
`);
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function readImpactPredictions(historyDir) {
|
|
33
|
+
return readRecords(historyDir, LOG_FILENAME);
|
|
34
|
+
}
|
|
35
|
+
async function readRecords(historyDir, filename) {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = await readFile(path.join(historyDir, filename), "utf8");
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const line of raw.split("\n")) {
|
|
44
|
+
const trimmed = line.trim();
|
|
45
|
+
if (trimmed === "") continue;
|
|
46
|
+
try {
|
|
47
|
+
out.push(JSON.parse(trimmed));
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
defaultHistoryDir,
|
|
56
|
+
appendImpactPrediction,
|
|
57
|
+
appendReconciliation,
|
|
58
|
+
readImpactPredictions
|
|
59
|
+
};
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createClassifier
|
|
3
|
+
} from "./chunk-JEJRXPCU.js";
|
|
4
|
+
import {
|
|
5
|
+
buildImportGraph,
|
|
6
|
+
computeAffectedFiles,
|
|
7
|
+
importersOfUnindexed,
|
|
8
|
+
readManifests,
|
|
9
|
+
workspaceBlindSpots
|
|
10
|
+
} from "./chunk-TRUK3TJF.js";
|
|
11
|
+
import {
|
|
12
|
+
mapLimit
|
|
13
|
+
} from "./chunk-GDSZFIHM.js";
|
|
14
|
+
import {
|
|
15
|
+
getChangedFiles
|
|
16
|
+
} from "./chunk-MKUYZIXF.js";
|
|
17
|
+
import {
|
|
18
|
+
readPathAliases
|
|
19
|
+
} from "./chunk-X3VVGTEV.js";
|
|
20
|
+
import {
|
|
21
|
+
readFileAtRef,
|
|
22
|
+
readFilesAtRef
|
|
23
|
+
} from "./chunk-OON6O4S5.js";
|
|
24
|
+
import {
|
|
25
|
+
updateSymbolGraph
|
|
26
|
+
} from "./chunk-XXMD4NC5.js";
|
|
27
|
+
import {
|
|
28
|
+
isStarReexportKey
|
|
29
|
+
} from "./chunk-WULBOSG5.js";
|
|
30
|
+
|
|
31
|
+
// src/core/semantic/verdict.ts
|
|
32
|
+
function deriveVerdict(classifications, options = {}) {
|
|
33
|
+
if (options.forceBuild === true) return "build-required";
|
|
34
|
+
let verdict = "safe-to-skip";
|
|
35
|
+
for (const classification of classifications) {
|
|
36
|
+
if (classification === "breaking") return "build-required";
|
|
37
|
+
if (classification === "internal" || classification === "unanalyzed") {
|
|
38
|
+
verdict = "build-recommended";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return verdict;
|
|
42
|
+
}
|
|
43
|
+
function resolutionOf(confidence) {
|
|
44
|
+
if (confidence >= 0.9) return "high";
|
|
45
|
+
if (confidence >= 0.7) return "medium";
|
|
46
|
+
return "low";
|
|
47
|
+
}
|
|
48
|
+
var VERDICT_TEXT = {
|
|
49
|
+
"safe-to-skip": "safe to skip build",
|
|
50
|
+
"build-recommended": "build recommended",
|
|
51
|
+
"build-required": "build required"
|
|
52
|
+
};
|
|
53
|
+
function verdictText(verdict) {
|
|
54
|
+
return VERDICT_TEXT[verdict];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/core/semantic/blast-radius.ts
|
|
58
|
+
import { readFile } from "fs/promises";
|
|
59
|
+
import path from "path";
|
|
60
|
+
var TS_FILE = /\.(?:ts|tsx|mts|cts)$/;
|
|
61
|
+
var JS_FILE = /\.(?:[cm]?js|jsx)$/;
|
|
62
|
+
var COMMONJS_EXPORT = /\bmodule\.exports\b|\bexports\.[A-Za-z_$]|\bexports\[/;
|
|
63
|
+
var MODULE_FILE = /\.(?:[cm]?[jt]sx?|json)$/;
|
|
64
|
+
function isModuleFile(file) {
|
|
65
|
+
return MODULE_FILE.test(file);
|
|
66
|
+
}
|
|
67
|
+
var DECLARATION_FILE = /\.d\.(?:ts|mts|cts)$/;
|
|
68
|
+
var MAX_NOTE_FILES = 3;
|
|
69
|
+
var CLASSIFY_CONCURRENCY = 16;
|
|
70
|
+
function isOutsideWorkspace(file) {
|
|
71
|
+
return file.startsWith("../");
|
|
72
|
+
}
|
|
73
|
+
function isTypeScriptSource(file) {
|
|
74
|
+
return TS_FILE.test(file) && !DECLARATION_FILE.test(file);
|
|
75
|
+
}
|
|
76
|
+
function isSource(file) {
|
|
77
|
+
return isTypeScriptSource(file) || JS_FILE.test(file);
|
|
78
|
+
}
|
|
79
|
+
function isAnalyzable(file) {
|
|
80
|
+
return isSource(file) && !isOutsideWorkspace(file);
|
|
81
|
+
}
|
|
82
|
+
function widenCommonJs(impact, before, after) {
|
|
83
|
+
if (!JS_FILE.test(impact.filePath)) return impact;
|
|
84
|
+
if (impact.classification === "non-impacting") return impact;
|
|
85
|
+
if (!COMMONJS_EXPORT.test(`${before ?? ""}
|
|
86
|
+
${after ?? ""}`)) return impact;
|
|
87
|
+
return {
|
|
88
|
+
...impact,
|
|
89
|
+
classification: "breaking",
|
|
90
|
+
propagates: true,
|
|
91
|
+
ungated: true
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function unanalyzedImpact(file) {
|
|
95
|
+
return {
|
|
96
|
+
filePath: file,
|
|
97
|
+
classification: "unanalyzed",
|
|
98
|
+
impactedSymbols: [],
|
|
99
|
+
propagates: false,
|
|
100
|
+
notes: []
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async function batchedReaderFor(cwd, baseRef, relPaths) {
|
|
104
|
+
const batch = await readFilesAtRef(cwd, baseRef, relPaths);
|
|
105
|
+
if (batch === null) return (rel) => readFileAtRef(cwd, baseRef, rel);
|
|
106
|
+
return async (rel) => batch.get(rel) ?? null;
|
|
107
|
+
}
|
|
108
|
+
async function traceBlastRadius(cwd, options = {}) {
|
|
109
|
+
const baseRef = options.baseRef ?? "HEAD~1";
|
|
110
|
+
const changedFiles = options.changedFiles ?? await getChangedFiles({ cwd, baseRef });
|
|
111
|
+
if (changedFiles === null) return null;
|
|
112
|
+
const packageDirs = packageDirsFrom(cwd, options.packageGraph);
|
|
113
|
+
let importGraph = options.importGraph;
|
|
114
|
+
if (importGraph === void 0) {
|
|
115
|
+
const [{ graph: symbolGraph }, pathAliases] = await Promise.all([
|
|
116
|
+
updateSymbolGraph(
|
|
117
|
+
cwd,
|
|
118
|
+
options.graphDir === void 0 ? {} : { graphDir: options.graphDir }
|
|
119
|
+
),
|
|
120
|
+
options.pathAliases ?? readPathAliases(cwd)
|
|
121
|
+
]);
|
|
122
|
+
importGraph = buildImportGraph(symbolGraph, {
|
|
123
|
+
packageDirs,
|
|
124
|
+
pathAliases,
|
|
125
|
+
packageExports: packageExportsFrom(options.packageGraph)
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const indexed = importGraph.imports;
|
|
129
|
+
const files = changedFiles.map(toPosix).sort();
|
|
130
|
+
const analyzable = files.filter(isAnalyzable);
|
|
131
|
+
const readBefore = options.readBefore ?? await batchedReaderFor(cwd, baseRef, analyzable);
|
|
132
|
+
const readAfter = options.readAfter ?? (async (rel) => {
|
|
133
|
+
try {
|
|
134
|
+
return await readFile(path.join(cwd, rel), "utf8");
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
const classify = createClassifier();
|
|
140
|
+
const changed = await mapLimit(files, CLASSIFY_CONCURRENCY, async (file) => {
|
|
141
|
+
if (!isAnalyzable(file)) return unanalyzedImpact(file);
|
|
142
|
+
const [before, after] = await Promise.all([
|
|
143
|
+
readBefore(file),
|
|
144
|
+
readAfter(file)
|
|
145
|
+
]);
|
|
146
|
+
if (after !== null && !indexed.has(file)) return unanalyzedImpact(file);
|
|
147
|
+
return widenCommonJs(
|
|
148
|
+
toFileImpact(
|
|
149
|
+
await classify({
|
|
150
|
+
filePath: file,
|
|
151
|
+
before: before ?? "",
|
|
152
|
+
after: after ?? ""
|
|
153
|
+
})
|
|
154
|
+
),
|
|
155
|
+
before,
|
|
156
|
+
after
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
const manifests = options.manifests ?? await readManifests(cwd);
|
|
160
|
+
const blindSpots = workspaceBlindSpots({
|
|
161
|
+
externals: importGraph.externals,
|
|
162
|
+
packageNames: new Set(
|
|
163
|
+
(options.packageGraph?.packages ?? []).map((pkg) => pkg.manifest.name)
|
|
164
|
+
),
|
|
165
|
+
manifests
|
|
166
|
+
});
|
|
167
|
+
return assembleBlastRadius(baseRef, changed, importGraph, {
|
|
168
|
+
packageDirs,
|
|
169
|
+
workspaceNotes: blindSpots.notes,
|
|
170
|
+
undiscoveredPackages: blindSpots.undiscovered,
|
|
171
|
+
packageRoots: [...manifests.keys()],
|
|
172
|
+
...options.tasks === void 0 ? {} : { tasks: options.tasks }
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function assembleBlastRadius(baseRef, changed, importGraph, options = {}) {
|
|
176
|
+
const notes = /* @__PURE__ */ new Set();
|
|
177
|
+
const repositoryNotes = new Set(options.workspaceNotes);
|
|
178
|
+
const affected = /* @__PURE__ */ new Set();
|
|
179
|
+
const firstHop = /* @__PURE__ */ new Set();
|
|
180
|
+
for (const impact of changed) {
|
|
181
|
+
for (const note of impact.notes) notes.add(note);
|
|
182
|
+
if (impact.classification === "non-impacting") continue;
|
|
183
|
+
affected.add(impact.filePath);
|
|
184
|
+
if (impact.classification === "unanalyzed" || !importGraph.imports.has(impact.filePath)) {
|
|
185
|
+
for (const importer of importersOfUnindexed(
|
|
186
|
+
importGraph,
|
|
187
|
+
impact.filePath
|
|
188
|
+
)) {
|
|
189
|
+
firstHop.add(importer);
|
|
190
|
+
}
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!impact.propagates) continue;
|
|
194
|
+
const ungated = impact.ungated === true || impact.impactedSymbols.some(isStarReexportKey);
|
|
195
|
+
for (const dependent of importGraph.dependents.get(impact.filePath) ?? []) {
|
|
196
|
+
const edge = importGraph.edges.get(dependent)?.get(impact.filePath);
|
|
197
|
+
if (edge === void 0) {
|
|
198
|
+
firstHop.add(dependent);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (edge.dynamic) {
|
|
202
|
+
notes.add(
|
|
203
|
+
`${dependent}: dynamic import of ${impact.filePath} \u2014 names unknowable`
|
|
204
|
+
);
|
|
205
|
+
firstHop.add(dependent);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (ungated || edge.names.has("*")) {
|
|
209
|
+
firstHop.add(dependent);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (impact.impactedSymbols.some((s) => edge.names.has(s))) {
|
|
213
|
+
firstHop.add(dependent);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const file of computeAffectedFiles(firstHop, importGraph)) {
|
|
218
|
+
affected.add(file);
|
|
219
|
+
}
|
|
220
|
+
const unanalyzed = changed.filter((impact) => impact.classification === "unanalyzed").map((impact) => impact.filePath);
|
|
221
|
+
const outside = unanalyzed.filter(isOutsideWorkspace);
|
|
222
|
+
const inside = unanalyzed.filter((file) => !isOutsideWorkspace(file));
|
|
223
|
+
const unindexed = inside.filter(isSource);
|
|
224
|
+
const notTypeScript = inside.filter((file) => !isSource(file));
|
|
225
|
+
if (notTypeScript.length > 0) {
|
|
226
|
+
const one = notTypeScript.length === 1;
|
|
227
|
+
notes.add(
|
|
228
|
+
`${notTypeScript.length} changed ${one ? "file is" : "files are"} not TypeScript or JavaScript and ${one ? "was" : "were"} not analyzed (${listPaths(notTypeScript)})`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (unindexed.length > 0) {
|
|
232
|
+
const one = unindexed.length === 1;
|
|
233
|
+
notes.add(
|
|
234
|
+
`${unindexed.length} changed source ${one ? "file is" : "files are"} not in variant's index (ignored by git, in a build directory, or too large) and ${one ? "was" : "were"} not analyzed (${listPaths(unindexed)})`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (outside.length > 0) {
|
|
238
|
+
const one = outside.length === 1;
|
|
239
|
+
notes.add(
|
|
240
|
+
`${outside.length} changed ${one ? "file is" : "files are"} outside the directory variant ran in and ${one ? "was" : "were"} not analyzed (${listPaths(outside)})`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const withUnresolved = [...affected].sort().flatMap((file) => {
|
|
244
|
+
const specs = importGraph.unresolved.get(file);
|
|
245
|
+
return specs === void 0 || specs.size === 0 ? [] : [`${file} (${[...specs].sort().join(", ")})`];
|
|
246
|
+
});
|
|
247
|
+
if (withUnresolved.length > 0) {
|
|
248
|
+
const one = withUnresolved.length === 1;
|
|
249
|
+
notes.add(
|
|
250
|
+
`${withUnresolved.length} affected ${one ? "file has" : "files have"} unresolved imports: ${listPaths(withUnresolved)}`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const impacting = changed.filter((impact) => impact.classification !== "non-impacting").map((impact) => impact.filePath);
|
|
254
|
+
if (importGraph.computed.size > 0) {
|
|
255
|
+
const loaders = [...importGraph.computed].sort();
|
|
256
|
+
repositoryNotes.add(
|
|
257
|
+
`${loaders.length} file(s) load a module through a fully computed import() or require() specifier (${listPaths(loaders)}); a change reached only that way selects no tests`
|
|
258
|
+
);
|
|
259
|
+
const touched = new Set(
|
|
260
|
+
impacting.filter(isModuleFile).map((file) => packageRootOf(file, options.packageRoots))
|
|
261
|
+
);
|
|
262
|
+
const near = loaders.filter(
|
|
263
|
+
(loader) => touched.has(packageRootOf(loader, options.packageRoots))
|
|
264
|
+
);
|
|
265
|
+
if (near.length > 0) {
|
|
266
|
+
notes.add(
|
|
267
|
+
`${near.length} file(s) in a package this change touches load a module through a fully computed import() or require() specifier (${listPaths(near)}); a changed file reached only that way selects no tests`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
for (const [name, dir] of options.undiscoveredPackages ?? []) {
|
|
272
|
+
const prefix = dir === "" ? "" : `${dir}/`;
|
|
273
|
+
const inside2 = impacting.filter((file) => file.startsWith(prefix));
|
|
274
|
+
if (inside2.length > 0) {
|
|
275
|
+
const one = inside2.length === 1;
|
|
276
|
+
notes.add(
|
|
277
|
+
`${inside2.length} changed ${one ? "file belongs" : "files belong"} to ${name}, a local package variant did not find as a workspace package (${listPaths(inside2)}); files importing it by name are not reached`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const packageDirs = options.packageDirs ?? {};
|
|
282
|
+
const affectedPackages = /* @__PURE__ */ new Set();
|
|
283
|
+
for (const file of affected) {
|
|
284
|
+
const pkg = fileToPackage(file, packageDirs);
|
|
285
|
+
if (pkg !== void 0) affectedPackages.add(pkg);
|
|
286
|
+
}
|
|
287
|
+
const affectedTasks = [];
|
|
288
|
+
for (const taskName of Object.keys(options.tasks ?? {})) {
|
|
289
|
+
const sep = taskName.lastIndexOf(":");
|
|
290
|
+
if (sep === -1) continue;
|
|
291
|
+
if (affectedPackages.has(taskName.slice(0, sep))) {
|
|
292
|
+
affectedTasks.push(taskName);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const noteList = [...notes].sort();
|
|
296
|
+
const confidence = Math.max(
|
|
297
|
+
0.3,
|
|
298
|
+
Math.round((1 - noteList.length * 0.1) * 100) / 100
|
|
299
|
+
);
|
|
300
|
+
return {
|
|
301
|
+
baseRef,
|
|
302
|
+
changed,
|
|
303
|
+
affectedFiles: [...affected].sort(),
|
|
304
|
+
affectedPackages: [...affectedPackages].sort(),
|
|
305
|
+
affectedTasks: affectedTasks.sort(),
|
|
306
|
+
confidence,
|
|
307
|
+
notes: noteList,
|
|
308
|
+
repositoryNotes: [...repositoryNotes].sort()
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function packageRootOf(file, packageRoots) {
|
|
312
|
+
let best = "";
|
|
313
|
+
for (const root of packageRoots ?? []) {
|
|
314
|
+
if (root !== "" && file.startsWith(`${root}/`) && root.length > best.length) {
|
|
315
|
+
best = root;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return best;
|
|
319
|
+
}
|
|
320
|
+
function toFileImpact(result) {
|
|
321
|
+
const impactedSymbols = [
|
|
322
|
+
...result.exportedSymbols.added.filter(isStarReexportKey),
|
|
323
|
+
...result.exportedSymbols.removed,
|
|
324
|
+
...result.exportedSymbols.changed.filter((c) => c.kind !== "body").map((c) => c.name)
|
|
325
|
+
].sort();
|
|
326
|
+
const ungated = result.syntaxErrors || impactedSymbols.some(isStarReexportKey);
|
|
327
|
+
return {
|
|
328
|
+
filePath: result.filePath,
|
|
329
|
+
classification: result.classification,
|
|
330
|
+
impactedSymbols,
|
|
331
|
+
propagates: result.classification === "breaking",
|
|
332
|
+
...ungated && { ungated: true },
|
|
333
|
+
notes: result.confidenceNotes.map((n) => `${result.filePath}: ${n}`)
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function packageDirsFrom(cwd, packageGraph) {
|
|
337
|
+
const out = {};
|
|
338
|
+
for (const pkg of packageGraph?.packages ?? []) {
|
|
339
|
+
out[pkg.manifest.name] = toPosix(path.relative(cwd, pkg.dir));
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
function packageExportsFrom(packageGraph) {
|
|
344
|
+
const out = {};
|
|
345
|
+
for (const pkg of packageGraph?.packages ?? []) {
|
|
346
|
+
if (pkg.manifest.exports !== void 0) {
|
|
347
|
+
out[pkg.manifest.name] = pkg.manifest.exports;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
function listPaths(items) {
|
|
353
|
+
return items.length > MAX_NOTE_FILES ? `${items.slice(0, MAX_NOTE_FILES).join(", ")}, \u2026 ${items.length - MAX_NOTE_FILES} more` : items.join(", ");
|
|
354
|
+
}
|
|
355
|
+
function fileToPackage(file, packageDirs) {
|
|
356
|
+
let bestName;
|
|
357
|
+
let bestLength = -1;
|
|
358
|
+
for (const [name, dir] of Object.entries(packageDirs)) {
|
|
359
|
+
const prefix = dir === "" ? "" : `${dir}/`;
|
|
360
|
+
if (file.startsWith(prefix) && prefix.length > bestLength) {
|
|
361
|
+
bestName = name;
|
|
362
|
+
bestLength = prefix.length;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return bestName;
|
|
366
|
+
}
|
|
367
|
+
function toPosix(p) {
|
|
368
|
+
return p.replace(/\\/g, "/");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/core/semantic/test-impact.ts
|
|
372
|
+
var JAVASCRIPT_TEST_FILE = /\.(?:test|spec)\.(?:js|jsx|mjs|cjs)$/;
|
|
373
|
+
function isJavaScriptTestFile(file) {
|
|
374
|
+
return JAVASCRIPT_TEST_FILE.test(file);
|
|
375
|
+
}
|
|
376
|
+
function defaultIsTestFile(file) {
|
|
377
|
+
return /(^|\/)__tests__\//.test(file) || /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(file);
|
|
378
|
+
}
|
|
379
|
+
var TEST_CONFIG_FILE = /(^|\/)(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|bun\.lockb?|bunfig\.toml|tsconfig[^/]*\.json|(vitest|jest|playwright|vite|babel|next)(\.[^/.]+)?\.config\.[^/.]+(\.[^/]+)?|vitest\.(workspace|projects)\.[^/.]+|\.babelrc(\.[^/]+)?)$/;
|
|
380
|
+
var TEST_SETUP_FILE = /(^|\/)((vitest|jest)\.setup|setup-?tests|global-?(setup|teardown)|tests?\/setup)\.[cm]?[jt]sx?$/i;
|
|
381
|
+
var PROSE_FILE = /\.(?:md|markdown|rst|txt|adoc)$/i;
|
|
382
|
+
var UNREACHED_CONFIDENCE = 0.5;
|
|
383
|
+
function invalidatesAllTests(filePath) {
|
|
384
|
+
return TEST_CONFIG_FILE.test(filePath) || TEST_SETUP_FILE.test(filePath);
|
|
385
|
+
}
|
|
386
|
+
function buildTestTrace(graph, options = {}) {
|
|
387
|
+
const isTest = options.isTestFile ?? defaultIsTestFile;
|
|
388
|
+
const closures = /* @__PURE__ */ new Map();
|
|
389
|
+
for (const file of [...graph.imports.keys()].sort()) {
|
|
390
|
+
if (isTest(file)) closures.set(file, closureOf(file, graph));
|
|
391
|
+
}
|
|
392
|
+
return { closures };
|
|
393
|
+
}
|
|
394
|
+
function buildCoverageMap(trace) {
|
|
395
|
+
const testsFor = /* @__PURE__ */ new Map();
|
|
396
|
+
for (const [test, closure] of trace.closures) {
|
|
397
|
+
for (const file of closure) {
|
|
398
|
+
let tests = testsFor.get(file);
|
|
399
|
+
if (tests === void 0) {
|
|
400
|
+
tests = /* @__PURE__ */ new Set();
|
|
401
|
+
testsFor.set(file, tests);
|
|
402
|
+
}
|
|
403
|
+
tests.add(test);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { testsFor };
|
|
407
|
+
}
|
|
408
|
+
function computeTestImpact(radius, graph, options = {}) {
|
|
409
|
+
const trace = buildTestTrace(graph, options);
|
|
410
|
+
const coverage = buildCoverageMap(trace);
|
|
411
|
+
const allTests = [...trace.closures.keys()];
|
|
412
|
+
const notes = /* @__PURE__ */ new Set();
|
|
413
|
+
let selectAll = false;
|
|
414
|
+
for (const impact of radius.changed) {
|
|
415
|
+
if (invalidatesAllTests(impact.filePath)) {
|
|
416
|
+
selectAll = true;
|
|
417
|
+
notes.add(
|
|
418
|
+
`${impact.filePath}: build/test configuration changed \u2014 running all tests`
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const affected = /* @__PURE__ */ new Set();
|
|
423
|
+
if (selectAll) {
|
|
424
|
+
for (const test of allTests) affected.add(test);
|
|
425
|
+
} else {
|
|
426
|
+
for (const file of radius.affectedFiles) {
|
|
427
|
+
for (const test of coverage.testsFor.get(file) ?? []) {
|
|
428
|
+
affected.add(test);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const unindexed = (file) => !graph.imports.has(file);
|
|
433
|
+
const javascriptTests = [...options.javascriptTests ?? []].filter(unindexed).sort();
|
|
434
|
+
const unselectedTests = radius.changed.map((impact) => impact.filePath).filter((file) => isJavaScriptTestFile(file) && unindexed(file));
|
|
435
|
+
if (unselectedTests.length > 0) {
|
|
436
|
+
const one = unselectedTests.length === 1;
|
|
437
|
+
notes.add(
|
|
438
|
+
`${unselectedTests.length} changed test ${one ? "file is" : "files are"} JavaScript, which variant does not index, and ${one ? "was" : "were"} not selected (${listPaths(unselectedTests)})`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
const repositoryNotes = [];
|
|
442
|
+
if (javascriptTests.length > 0) {
|
|
443
|
+
const one = javascriptTests.length === 1;
|
|
444
|
+
repositoryNotes.push(
|
|
445
|
+
`${javascriptTests.length} JavaScript test ${one ? "file is" : "files are"} not indexed (${listPaths(javascriptTests)}); variant cannot select ${one ? "it" : "them"}, and code only ${one ? "it imports" : "they import"} looks untested`
|
|
446
|
+
);
|
|
447
|
+
const touched = new Set(
|
|
448
|
+
radius.changed.filter(
|
|
449
|
+
(impact) => impact.classification !== "non-impacting" && isModuleFile(impact.filePath) && !isJavaScriptTestFile(impact.filePath)
|
|
450
|
+
).map((impact) => packageRootOf(impact.filePath, options.packageRoots))
|
|
451
|
+
);
|
|
452
|
+
const near = javascriptTests.filter(
|
|
453
|
+
(test) => touched.has(packageRootOf(test, options.packageRoots))
|
|
454
|
+
);
|
|
455
|
+
if (near.length > 0) {
|
|
456
|
+
const oneNear = near.length === 1;
|
|
457
|
+
notes.add(
|
|
458
|
+
`${near.length} JavaScript test ${oneNear ? "file" : "files"} in a package this change touches cannot be selected (${listPaths(near)})`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const tested = (file) => (coverage.testsFor.get(file)?.size ?? 0) > 0;
|
|
463
|
+
const unreached = [];
|
|
464
|
+
if (!selectAll) {
|
|
465
|
+
for (const impact of radius.changed) {
|
|
466
|
+
if (impact.classification === "non-impacting") continue;
|
|
467
|
+
if (PROSE_FILE.test(impact.filePath)) continue;
|
|
468
|
+
if (unselectedTests.includes(impact.filePath)) continue;
|
|
469
|
+
if (graph.imports.has(impact.filePath)) {
|
|
470
|
+
if (!tested(impact.filePath)) unreached.push(impact.filePath);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (impact.classification !== "unanalyzed") continue;
|
|
474
|
+
const reached = [...importersOfUnindexed(graph, impact.filePath)].some(
|
|
475
|
+
tested
|
|
476
|
+
);
|
|
477
|
+
if (!reached) unreached.push(impact.filePath);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
let blindSpots = 0;
|
|
481
|
+
for (const test of affected) {
|
|
482
|
+
const closure = trace.closures.get(test);
|
|
483
|
+
if (closure === void 0) continue;
|
|
484
|
+
for (const file of closure) {
|
|
485
|
+
const unresolvedForFile = graph.unresolved.get(file);
|
|
486
|
+
if (unresolvedForFile !== void 0 && unresolvedForFile.size > 0) {
|
|
487
|
+
blindSpots++;
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (blindSpots > 0) {
|
|
493
|
+
notes.add(
|
|
494
|
+
`${blindSpots} selected test file(s) have unresolved imports in their closure \u2014 fixtures or assets may be missed`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
const noteList = [...notes].sort();
|
|
498
|
+
const resolved = Math.max(
|
|
499
|
+
0.3,
|
|
500
|
+
Math.round((radius.confidence - noteList.length * 0.1) * 100) / 100
|
|
501
|
+
);
|
|
502
|
+
const confidence = selectAll ? 1 : unreached.length > 0 || unselectedTests.length > 0 ? Math.min(resolved, UNREACHED_CONFIDENCE) : resolved;
|
|
503
|
+
return {
|
|
504
|
+
affectedTests: [...affected].sort(),
|
|
505
|
+
totalTests: allTests.length,
|
|
506
|
+
selectAll,
|
|
507
|
+
unreached,
|
|
508
|
+
unselectedTests,
|
|
509
|
+
javascriptTests: javascriptTests.length,
|
|
510
|
+
confidence,
|
|
511
|
+
resolution: resolutionOf(confidence),
|
|
512
|
+
notes: noteList,
|
|
513
|
+
repositoryNotes
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
async function traceTestImpact(cwd, options = {}) {
|
|
517
|
+
let importGraph = options.importGraph;
|
|
518
|
+
if (importGraph === void 0) {
|
|
519
|
+
const [{ graph: symbolGraph }, pathAliases] = await Promise.all([
|
|
520
|
+
updateSymbolGraph(
|
|
521
|
+
cwd,
|
|
522
|
+
options.graphDir === void 0 ? {} : { graphDir: options.graphDir }
|
|
523
|
+
),
|
|
524
|
+
options.pathAliases ?? readPathAliases(cwd)
|
|
525
|
+
]);
|
|
526
|
+
importGraph = buildImportGraph(symbolGraph, {
|
|
527
|
+
packageDirs: packageDirsFrom(cwd, options.packageGraph),
|
|
528
|
+
pathAliases,
|
|
529
|
+
packageExports: packageExportsFrom(options.packageGraph)
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
const [manifests, javascriptTests] = await Promise.all([
|
|
533
|
+
options.manifests ?? readManifests(cwd),
|
|
534
|
+
options.javascriptTests ?? findJavaScriptTests(cwd)
|
|
535
|
+
]);
|
|
536
|
+
const radius = await traceBlastRadius(cwd, {
|
|
537
|
+
...options,
|
|
538
|
+
importGraph,
|
|
539
|
+
manifests
|
|
540
|
+
});
|
|
541
|
+
if (radius === null) return null;
|
|
542
|
+
return {
|
|
543
|
+
radius,
|
|
544
|
+
graph: importGraph,
|
|
545
|
+
tests: computeTestImpact(radius, importGraph, {
|
|
546
|
+
...options.isTestFile !== void 0 && {
|
|
547
|
+
isTestFile: options.isTestFile
|
|
548
|
+
},
|
|
549
|
+
javascriptTests,
|
|
550
|
+
packageRoots: [...manifests.keys()]
|
|
551
|
+
})
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
async function findJavaScriptTests(cwd) {
|
|
555
|
+
const fg = (await import("fast-glob")).default;
|
|
556
|
+
const found = await fg("**/*.{test,spec}.{js,jsx,mjs,cjs}", {
|
|
557
|
+
cwd,
|
|
558
|
+
onlyFiles: true,
|
|
559
|
+
ignore: ["**/node_modules/**", "**/dist/**", ".git/**", ".variant/**"]
|
|
560
|
+
});
|
|
561
|
+
return found.map((file) => file.replace(/\\/g, "/")).sort();
|
|
562
|
+
}
|
|
563
|
+
function closureOf(start, graph) {
|
|
564
|
+
const closure = /* @__PURE__ */ new Set([start]);
|
|
565
|
+
let frontier = /* @__PURE__ */ new Set([start]);
|
|
566
|
+
while (frontier.size > 0) {
|
|
567
|
+
const next = /* @__PURE__ */ new Set();
|
|
568
|
+
for (const file of frontier) {
|
|
569
|
+
for (const target of graph.imports.get(file) ?? []) {
|
|
570
|
+
if (!closure.has(target)) {
|
|
571
|
+
closure.add(target);
|
|
572
|
+
next.add(target);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
frontier = next;
|
|
577
|
+
}
|
|
578
|
+
return closure;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export {
|
|
582
|
+
packageRootOf,
|
|
583
|
+
deriveVerdict,
|
|
584
|
+
verdictText,
|
|
585
|
+
isJavaScriptTestFile,
|
|
586
|
+
defaultIsTestFile,
|
|
587
|
+
PROSE_FILE,
|
|
588
|
+
invalidatesAllTests,
|
|
589
|
+
traceTestImpact
|
|
590
|
+
};
|