@davesheffer/hunch 1.45.1 → 1.46.0
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/dist/cli/index.js +165 -33
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +2 -1
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +82 -26
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +10 -0
- package/dist/core/delivery.js +117 -9
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/pipeline.d.ts +9 -2
- package/dist/core/pipeline.js +13 -3
- package/dist/core/procstart.d.ts +17 -3
- package/dist/core/procstart.js +26 -5
- package/dist/core/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.d.ts +1 -0
- package/dist/core/taskSelection.js +132 -11
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/whyRecord.d.ts +47 -0
- package/dist/core/whyRecord.js +105 -0
- package/dist/extractors/git.d.ts +32 -0
- package/dist/extractors/git.js +206 -94
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +6 -4
- package/dist/integrations/uninstall.d.ts +17 -4
- package/dist/integrations/uninstall.js +35 -13
- package/dist/mcp/server.js +179 -30
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +6 -5
- package/dist/serve/writelock.js +26 -21
- package/dist/store/hunchStore.d.ts +12 -1
- package/dist/store/hunchStore.js +86 -4
- package/dist/store/jsonStore.d.ts +24 -4
- package/dist/store/jsonStore.js +75 -26
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/** Change-scoped test selection for `hunch verify` (dec_70d82c408e). Pure
|
|
2
|
+
* selection rules over injected inputs, plus the git/filesystem readers that
|
|
3
|
+
* feed them. FAIL CLOSED: a change nothing can attribute runs the full suite,
|
|
4
|
+
* and a test that starts a child process is assumed to reach the whole program
|
|
5
|
+
* unless a trustworthy impact-map entry narrows it. */
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { pathMatchesGlob } from "./glob.js";
|
|
12
|
+
import { relativeImportCandidates } from "./relativeImports.js";
|
|
13
|
+
export const DEFAULT_FULL_TRIGGERS = [
|
|
14
|
+
"package.json",
|
|
15
|
+
"package-lock.json",
|
|
16
|
+
"tsconfig*.json",
|
|
17
|
+
"tooling/run-tests.mjs",
|
|
18
|
+
"test/fixtures.ts",
|
|
19
|
+
"test/helpers*",
|
|
20
|
+
"test/cli-invocation.ts",
|
|
21
|
+
".github/workflows/**",
|
|
22
|
+
];
|
|
23
|
+
/** Repo-root markdown (README.md, CHANGELOG.md, ...) is the only non-code change
|
|
24
|
+
* that can skip the full suite: it selects tests that mention it, else nothing. */
|
|
25
|
+
export function isDocsOnly(file) {
|
|
26
|
+
return !file.includes("/") && file.endsWith(".md");
|
|
27
|
+
}
|
|
28
|
+
/** Source the import graph can follow. Any other changed file may be read at
|
|
29
|
+
* runtime in ways no static scan sees (readdirSync walks, globs), so it runs FULL. */
|
|
30
|
+
const CODE_FILE_RE = /\.(?:tsx?|jsx?|mjs|cjs)$/;
|
|
31
|
+
/** Selected tests above this share of the suite run the full suite instead. */
|
|
32
|
+
export const MAX_SELECTED_SHARE = 0.6;
|
|
33
|
+
const norm = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
34
|
+
const sorted = (xs) => [...new Set(xs)].sort();
|
|
35
|
+
const basename = (p) => p.slice(p.lastIndexOf("/") + 1);
|
|
36
|
+
/** A basename mention bounded by non-path characters ("d.mjs" ≠ "child.mjs"). */
|
|
37
|
+
const mentionRe = (name) => new RegExp(`(?<![\\w.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`);
|
|
38
|
+
/** A test file by name, existing or deleted: `test/.../*.test.<ext>`. Other
|
|
39
|
+
* files under test/ are helpers and keep the code/trigger rules. */
|
|
40
|
+
const TEST_FILE_RE = /^test\/(?:.+\/)?[^/]+\.test\.[cm]?[jt]sx?$/;
|
|
41
|
+
/** A test that enumerates test files (a readdir/glob over a test directory, a
|
|
42
|
+
* generic `.test.ts` suffix) or reads the repo index: adding, deleting or
|
|
43
|
+
* editing any test file can change what it sees. Over-matching only widens. */
|
|
44
|
+
const META_TEST_RE = /\b(?:readdir|readdirSync|opendir|opendirSync|glob|globSync)\s*\([^)]*test|["'`*]\.test\.[cm]?[jt]sx?\b|\.hunch[\\/"', ]+index\.json/;
|
|
45
|
+
export const isMetaTest = (text) => META_TEST_RE.test(text);
|
|
46
|
+
/** A map entry may narrow a spawning test only when it is complete and was
|
|
47
|
+
* recorded against the same entry closure as now. */
|
|
48
|
+
export function entryTrusted(entry, closureHash) {
|
|
49
|
+
return !!entry && entry.complete === true && !!entry.closureHash && entry.closureHash === closureHash;
|
|
50
|
+
}
|
|
51
|
+
export function selectTests(input) {
|
|
52
|
+
const changed = sorted(input.changedFiles.map(norm));
|
|
53
|
+
const allTests = sorted(input.allTests.map(norm));
|
|
54
|
+
const testSet = new Set(allTests);
|
|
55
|
+
const triggers = input.fullTriggers ?? DEFAULT_FULL_TRIGGERS;
|
|
56
|
+
const whole = new Set(input.wholeProgram ?? []);
|
|
57
|
+
const entries = input.impactMap?.map ?? {};
|
|
58
|
+
const reasons = new Map();
|
|
59
|
+
const unattributed = [];
|
|
60
|
+
const nonCode = [];
|
|
61
|
+
const mapUsed = new Set();
|
|
62
|
+
let triggerReason;
|
|
63
|
+
const pick = (test, why) => {
|
|
64
|
+
if (!testSet.has(test))
|
|
65
|
+
return;
|
|
66
|
+
let set = reasons.get(test);
|
|
67
|
+
if (!set)
|
|
68
|
+
reasons.set(test, set = new Set());
|
|
69
|
+
set.add(why);
|
|
70
|
+
};
|
|
71
|
+
for (const file of changed) {
|
|
72
|
+
const trigger = triggers.find((g) => pathMatchesGlob(file, g));
|
|
73
|
+
if (trigger) {
|
|
74
|
+
triggerReason ??= `${file} matches full-suite trigger ${trigger}`;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let attributed = false;
|
|
78
|
+
const hit = (test, why) => { pick(test, why); attributed = true; };
|
|
79
|
+
/** Tests a change to code file `target` reaches (graph, map, whole program). */
|
|
80
|
+
const viaCode = (target, note) => {
|
|
81
|
+
for (const test of allTests) {
|
|
82
|
+
const entry = entries[test];
|
|
83
|
+
// The map only ever ADDS: a stale or partial entry never hides a test
|
|
84
|
+
// the static graph (or the whole-program rule) still attributes.
|
|
85
|
+
if (entry?.files.includes(target)) {
|
|
86
|
+
hit(test, `covers ${target} (impact map)${note}`);
|
|
87
|
+
mapUsed.add(test);
|
|
88
|
+
}
|
|
89
|
+
if (input.importGraph(test).has(target))
|
|
90
|
+
hit(test, `imports ${target}${note}`);
|
|
91
|
+
if (whole.has(target) && input.spawning?.(test)) {
|
|
92
|
+
if (entryTrusted(entry, input.closureHash))
|
|
93
|
+
mapUsed.add(test);
|
|
94
|
+
else
|
|
95
|
+
hit(test, `spawns a process without a trusted impact-map entry → whole program (${target})${note}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
for (const [test, globs] of Object.entries(input.gateMaps ?? {})) {
|
|
100
|
+
if (globs.some((g) => pathMatchesGlob(file, g)))
|
|
101
|
+
hit(norm(test), `gate map covers ${file}`);
|
|
102
|
+
}
|
|
103
|
+
if (testSet.has(file) || TEST_FILE_RE.test(file)) {
|
|
104
|
+
// A changed, added or deleted test file: itself, tests that name it, tests
|
|
105
|
+
// that enumerate test files, and tests reaching src/tooling that names it.
|
|
106
|
+
// A deleted one nothing reads stays unattributed → FULL.
|
|
107
|
+
if (testSet.has(file))
|
|
108
|
+
hit(file, "changed test file");
|
|
109
|
+
const name = mentionRe(basename(file));
|
|
110
|
+
for (const test of allTests) {
|
|
111
|
+
if (test === file)
|
|
112
|
+
continue;
|
|
113
|
+
const text = input.testText?.(test) ?? "";
|
|
114
|
+
if (text.includes(file) || name.test(text))
|
|
115
|
+
hit(test, `mentions ${file}`);
|
|
116
|
+
if (isMetaTest(text))
|
|
117
|
+
hit(test, `enumerates test files (${file})`);
|
|
118
|
+
}
|
|
119
|
+
for (const namer of input.testNamers?.(file) ?? [])
|
|
120
|
+
viaCode(norm(namer), ` (names ${file})`);
|
|
121
|
+
if (!attributed)
|
|
122
|
+
unattributed.push(file);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (isDocsOnly(file)) {
|
|
126
|
+
for (const test of allTests) {
|
|
127
|
+
const text = input.testText?.(test) ?? "";
|
|
128
|
+
if (text.includes(file) || mentionRe(basename(file)).test(text))
|
|
129
|
+
hit(test, `mentions ${file}`);
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!CODE_FILE_RE.test(file)) {
|
|
134
|
+
nonCode.push(file);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
viaCode(file, "");
|
|
138
|
+
if (!attributed)
|
|
139
|
+
unattributed.push(file);
|
|
140
|
+
}
|
|
141
|
+
const reasonRecord = {};
|
|
142
|
+
for (const test of sorted(reasons.keys()))
|
|
143
|
+
reasonRecord[test] = sorted(reasons.get(test));
|
|
144
|
+
const selected = Object.keys(reasonRecord);
|
|
145
|
+
const used = sorted(mapUsed);
|
|
146
|
+
const full = (fullReason) => ({ mode: "full", tests: allTests, reasons: reasonRecord, fullReason, unattributed, mapUsed: used });
|
|
147
|
+
if (triggerReason)
|
|
148
|
+
return full(triggerReason);
|
|
149
|
+
if (nonCode.length) {
|
|
150
|
+
unattributed.unshift(...nonCode);
|
|
151
|
+
const more = nonCode.length > 1 ? ` (+${nonCode.length - 1} more)` : "";
|
|
152
|
+
return full(`non-code change ${nonCode[0]}${more}: runtime file reads are not traceable`);
|
|
153
|
+
}
|
|
154
|
+
if (unattributed.length) {
|
|
155
|
+
const more = unattributed.length > 1 ? ` (+${unattributed.length - 1} more)` : "";
|
|
156
|
+
return full(`no test attributed to ${unattributed[0]}${more}`);
|
|
157
|
+
}
|
|
158
|
+
if (selected.length > allTests.length * MAX_SELECTED_SHARE) {
|
|
159
|
+
return full(`selection too broad (${selected.length} of ${allTests.length} > ${MAX_SELECTED_SHARE * 100}%)`);
|
|
160
|
+
}
|
|
161
|
+
if (!selected.length)
|
|
162
|
+
return { mode: "none", tests: [], reasons: {}, unattributed, mapUsed: used };
|
|
163
|
+
return { mode: "selected", tests: selected, reasons: reasonRecord, unattributed, mapUsed: used };
|
|
164
|
+
}
|
|
165
|
+
// ---- repo readers ---------------------------------------------------------
|
|
166
|
+
function git(root, args) {
|
|
167
|
+
return execFileSync("git", args, { cwd: root, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
168
|
+
}
|
|
169
|
+
/** Tracked + untracked (non-ignored) repo files, repo-relative posix, sorted. */
|
|
170
|
+
export function listRepoFiles(root) {
|
|
171
|
+
const out = git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
|
|
172
|
+
return sorted(out.split("\0").filter(Boolean).map(norm).filter((f) => existsSync(join(root, f))));
|
|
173
|
+
}
|
|
174
|
+
export function discoverTests(files, glob = "test/**/*.test.ts") {
|
|
175
|
+
return sorted(files.filter((f) => pathMatchesGlob(f, glob)));
|
|
176
|
+
}
|
|
177
|
+
const CODE_FILE = /\.(?:[cm]?[jt]sx?)$/;
|
|
178
|
+
const IMPORT_RE = /(?:\bimport\s*(?:type\s+)?(?:[\w*{}\s,$]+?\s*from\s*)?|\bexport\s+(?:type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s*from\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)["']([^"'\n]+)["']/g;
|
|
179
|
+
/** Static imports, re-exports, `import()`/`require()` with literal specifiers. */
|
|
180
|
+
export function importSpecifiers(source) {
|
|
181
|
+
const out = [];
|
|
182
|
+
for (const m of source.matchAll(IMPORT_RE))
|
|
183
|
+
out.push(m[1]);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/** Transitive relative-import closure per file, memoized for this call. An
|
|
187
|
+
* unresolvable relative specifier (e.g. a deleted file) records every
|
|
188
|
+
* candidate path, so the deletion stays attributable to its importers. */
|
|
189
|
+
export function buildImportGraph(root, files = listRepoFiles(root)) {
|
|
190
|
+
const available = new Set(files);
|
|
191
|
+
const direct = new Map();
|
|
192
|
+
const edges = (file) => {
|
|
193
|
+
let deps = direct.get(file);
|
|
194
|
+
if (deps)
|
|
195
|
+
return deps;
|
|
196
|
+
deps = [];
|
|
197
|
+
if (CODE_FILE.test(file) && available.has(file)) {
|
|
198
|
+
let text = "";
|
|
199
|
+
try {
|
|
200
|
+
text = readFileSync(join(root, file), "utf8");
|
|
201
|
+
}
|
|
202
|
+
catch { /* unreadable → no edges */ }
|
|
203
|
+
for (const spec of importSpecifiers(text)) {
|
|
204
|
+
const candidates = relativeImportCandidates(file, spec);
|
|
205
|
+
const hit = candidates.find((c) => available.has(c));
|
|
206
|
+
if (hit)
|
|
207
|
+
deps.push(hit);
|
|
208
|
+
else
|
|
209
|
+
deps.push(...candidates);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
direct.set(file, deps);
|
|
213
|
+
return deps;
|
|
214
|
+
};
|
|
215
|
+
const closure = new Map();
|
|
216
|
+
return (file) => {
|
|
217
|
+
const start = norm(file);
|
|
218
|
+
const cached = closure.get(start);
|
|
219
|
+
if (cached)
|
|
220
|
+
return cached;
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
const stack = [...edges(start)];
|
|
223
|
+
while (stack.length) {
|
|
224
|
+
const next = stack.pop();
|
|
225
|
+
if (seen.has(next) || next === start)
|
|
226
|
+
continue;
|
|
227
|
+
seen.add(next);
|
|
228
|
+
stack.push(...edges(next));
|
|
229
|
+
}
|
|
230
|
+
closure.set(start, seen);
|
|
231
|
+
return seen;
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/** A test (or one of its non-src helpers) that can start a child process. Over-
|
|
235
|
+
* matching only widens selection, so any child_process use counts. */
|
|
236
|
+
const SPAWN_RE = /child_process|\bexeca\b|cli-invocation|hunchCliArgs|StdioClientTransport|(?<![\w$])(?:spawn|spawnSync|execFile|execFileSync|execSync|exec|fork)\s*\(/;
|
|
237
|
+
/** Entries whose import closure (edges included) keys map-entry trust. */
|
|
238
|
+
export const CLOSURE_ROOTS = ["src/cli/index.ts", "src/mcp/server.ts", "src/cli/dna.ts"];
|
|
239
|
+
const PATH_TOKEN_RE = /[\w.@-]+(?:\/[\w.@-]+)+/g;
|
|
240
|
+
const JOIN_CALL_RE = /\b(?:join|resolve)\s*\(/g;
|
|
241
|
+
/** String-literal arguments of each `join(`/`resolve(` call, found with a
|
|
242
|
+
* balanced-paren scan so nested calls (`join(process.cwd(), "src")`) parse. */
|
|
243
|
+
export function joinSegmentRuns(text) {
|
|
244
|
+
const runs = [];
|
|
245
|
+
for (const m of text.matchAll(JOIN_CALL_RE)) {
|
|
246
|
+
const segs = [];
|
|
247
|
+
let depth = 1;
|
|
248
|
+
const stop = Math.min(text.length, m.index + m[0].length + 4000);
|
|
249
|
+
for (let i = m.index + m[0].length; i < stop && depth > 0; i++) {
|
|
250
|
+
const ch = text[i];
|
|
251
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
252
|
+
const end = text.indexOf(ch, i + 1);
|
|
253
|
+
if (end < 0)
|
|
254
|
+
break;
|
|
255
|
+
if (depth === 1 && end > i + 1)
|
|
256
|
+
segs.push(text.slice(i + 1, end));
|
|
257
|
+
i = end;
|
|
258
|
+
}
|
|
259
|
+
else if (ch === "(")
|
|
260
|
+
depth++;
|
|
261
|
+
else if (ch === ")")
|
|
262
|
+
depth--;
|
|
263
|
+
}
|
|
264
|
+
runs.push(segs);
|
|
265
|
+
}
|
|
266
|
+
return runs;
|
|
267
|
+
}
|
|
268
|
+
/** Repo files a test's text names: path-like tokens anywhere (string literals,
|
|
269
|
+
* `--eval` import specifiers, template strings) and `join(…, "src", "x.ts")`
|
|
270
|
+
* segment runs, resolved against the REPO ROOT (and the test's own dir for
|
|
271
|
+
* `./`/`../` tokens), with `.js`→`.ts` mapping. */
|
|
272
|
+
export function literalRepoPaths(text, fromFile, available) {
|
|
273
|
+
const out = new Set();
|
|
274
|
+
const tryPath = (raw) => {
|
|
275
|
+
const p = norm(raw);
|
|
276
|
+
const bases = p.startsWith(".") ? relativeImportCandidates(fromFile, p) : [];
|
|
277
|
+
const rooted = p.replace(/^(?:\.\.?\/)+/, "");
|
|
278
|
+
const variants = (q) => [q, q.replace(/\.js$/, ".ts"), q.replace(/\.jsx$/, ".tsx"), `${q}.ts`];
|
|
279
|
+
// Bare src-relative names ("core/x.ts") read via `../src/${name}` resolve under src/ too.
|
|
280
|
+
for (const c of [...bases, ...variants(rooted), ...variants(`src/${rooted}`)]) {
|
|
281
|
+
if (available.has(c)) {
|
|
282
|
+
out.add(c);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
for (const m of text.matchAll(PATH_TOKEN_RE))
|
|
288
|
+
tryPath(m[0]);
|
|
289
|
+
for (const segs of joinSegmentRuns(text)) {
|
|
290
|
+
for (let i = 0; i < segs.length; i++)
|
|
291
|
+
tryPath(segs.slice(i).join("/"));
|
|
292
|
+
}
|
|
293
|
+
return sorted(out);
|
|
294
|
+
}
|
|
295
|
+
/** Arguments of each `join(`/`resolve(` call: a plain string literal's value, or
|
|
296
|
+
* `null` for anything computed (identifiers, calls, `${}` templates). */
|
|
297
|
+
export function joinCallArgs(text) {
|
|
298
|
+
const calls = [];
|
|
299
|
+
for (const m of text.matchAll(JOIN_CALL_RE)) {
|
|
300
|
+
const args = [];
|
|
301
|
+
let cur = "";
|
|
302
|
+
const flush = () => {
|
|
303
|
+
const t = cur.trim();
|
|
304
|
+
if (t)
|
|
305
|
+
args.push(/^(["'`])([^"'`$]*)\1$/.exec(t)?.[2] ?? null);
|
|
306
|
+
cur = "";
|
|
307
|
+
};
|
|
308
|
+
let depth = 1;
|
|
309
|
+
const stop = Math.min(text.length, m.index + m[0].length + 4000);
|
|
310
|
+
for (let i = m.index + m[0].length; i < stop; i++) {
|
|
311
|
+
const ch = text[i];
|
|
312
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
313
|
+
const end = text.indexOf(ch, i + 1);
|
|
314
|
+
if (end < 0)
|
|
315
|
+
break;
|
|
316
|
+
cur += text.slice(i, end + 1);
|
|
317
|
+
i = end;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (ch === "(")
|
|
321
|
+
depth++;
|
|
322
|
+
else if (ch === ")" && --depth === 0) {
|
|
323
|
+
flush();
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
else if (ch === "," && depth === 1) {
|
|
327
|
+
flush();
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
cur += ch;
|
|
331
|
+
}
|
|
332
|
+
calls.push(args);
|
|
333
|
+
}
|
|
334
|
+
return calls;
|
|
335
|
+
}
|
|
336
|
+
const TEMPLATE_PATH_RE = /(?<![\w.@-])((?:[\w.@-]+\/)*[\w.@-]+)\/\$\{/g;
|
|
337
|
+
const CONCAT_PATH_RE = /["'`]([\w.@/-]+)["'`]\s*\+/g;
|
|
338
|
+
/** `${ROOT}/${dir}`: a computed segment right after another computed one. */
|
|
339
|
+
const TEMPLATE_AFTER_EXPR_RE = /\}\/\$\{/;
|
|
340
|
+
/** `const SRC = join(ROOT, "src")` / `resolve(...)`: an alias of a repo dir. */
|
|
341
|
+
const DIR_ALIAS_RE = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:path\.)?(?:join|resolve)\s*\(/g;
|
|
342
|
+
const DOTS_ONLY = (p) => /^(?:\.{1,2}\/?)+$/.test(norm(p));
|
|
343
|
+
/** Top-level repo dirs a text reaches through a COMPUTED path: `` `../src/${p}` ``,
|
|
344
|
+
* `"src/" + p`, `join(root, "src", p)`, or a later use of an alias like
|
|
345
|
+
* `const SRC = join(ROOT, "src")`. A test doing that may read any file under
|
|
346
|
+
* the dir, so it depends on all of them. A computed segment right after the
|
|
347
|
+
* root (`` `../${dir}/x` ``, `"../" + dir`, `resolve(__dirname, "..", dir)`,
|
|
348
|
+
* `` `${ROOT}/${dir}` ``) may be ANY top-level dir (fail closed). */
|
|
349
|
+
export function templatedRepoDirs(text, topDirs) {
|
|
350
|
+
const out = new Set();
|
|
351
|
+
const all = () => { for (const dir of topDirs)
|
|
352
|
+
out.add(dir); };
|
|
353
|
+
const dirOf = (path) => norm(path).split("/").find((seg) => topDirs.has(seg));
|
|
354
|
+
const add = (path) => { const dir = dirOf(path); if (dir)
|
|
355
|
+
out.add(dir); };
|
|
356
|
+
for (const m of text.matchAll(TEMPLATE_PATH_RE))
|
|
357
|
+
DOTS_ONLY(m[1]) ? all() : add(m[1]);
|
|
358
|
+
if (TEMPLATE_AFTER_EXPR_RE.test(text))
|
|
359
|
+
all();
|
|
360
|
+
for (const m of text.matchAll(CONCAT_PATH_RE))
|
|
361
|
+
/^(?:\.{1,2}\/)+$/.test(m[1]) ? all() : add(m[1]);
|
|
362
|
+
for (const args of joinCallArgs(text)) {
|
|
363
|
+
args.forEach((arg, i) => {
|
|
364
|
+
if (arg === null)
|
|
365
|
+
return;
|
|
366
|
+
if (DOTS_ONLY(arg) && args[i + 1] === null)
|
|
367
|
+
all();
|
|
368
|
+
else if (args.slice(i + 1).includes(null))
|
|
369
|
+
add(arg);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
for (const m of text.matchAll(DIR_ALIAS_RE)) {
|
|
373
|
+
const args = joinCallArgs(text.slice(m.index + m[0].length - 1).replace(/^\(/, "join("))[0] ?? [];
|
|
374
|
+
const literals = args.filter((a) => a !== null);
|
|
375
|
+
const dir = literals.map(dirOf).find(Boolean);
|
|
376
|
+
// An alias of a FILE (`join(ROOT, "src", "cli", "index.ts")`) is a literal path, not a dir.
|
|
377
|
+
if (!dir || /\.[\w-]+$/.test(literals.join("/")))
|
|
378
|
+
continue;
|
|
379
|
+
// Any later use of the alias (join(SRC, name), readdirSync(SRC), `${SRC}/…`) reaches the whole dir.
|
|
380
|
+
const uses = text.slice(m.index + m[0].length).match(new RegExp(`(?<![\\w$.])${m[1].replace(/\$/g, "\\$")}(?![\\w$])`));
|
|
381
|
+
if (uses)
|
|
382
|
+
out.add(dir);
|
|
383
|
+
}
|
|
384
|
+
return sorted(out);
|
|
385
|
+
}
|
|
386
|
+
export function testDependencyModel(root, files, graph = buildImportGraph(root, files)) {
|
|
387
|
+
const available = new Set(files);
|
|
388
|
+
const read = (file) => { try {
|
|
389
|
+
return readFileSync(join(root, file), "utf8");
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return "";
|
|
393
|
+
} };
|
|
394
|
+
const helpers = (test) => [test, ...[...graph(test)].filter((f) => available.has(f) && !f.startsWith("src/")).sort()];
|
|
395
|
+
const textMemo = new Map();
|
|
396
|
+
const testText = (testFile) => {
|
|
397
|
+
const test = norm(testFile);
|
|
398
|
+
let text = textMemo.get(test);
|
|
399
|
+
if (text === undefined)
|
|
400
|
+
textMemo.set(test, text = helpers(test).map(read).join("\n"));
|
|
401
|
+
return text;
|
|
402
|
+
};
|
|
403
|
+
const depMemo = new Map();
|
|
404
|
+
const topDirs = new Set(files.filter((f) => f.includes("/")).map((f) => f.slice(0, f.indexOf("/"))));
|
|
405
|
+
const dependencies = (testFile) => {
|
|
406
|
+
const test = norm(testFile);
|
|
407
|
+
const cached = depMemo.get(test);
|
|
408
|
+
if (cached)
|
|
409
|
+
return cached;
|
|
410
|
+
const deps = new Set(graph(test));
|
|
411
|
+
for (const file of helpers(test)) {
|
|
412
|
+
const text = read(file);
|
|
413
|
+
for (const named of literalRepoPaths(text, file, available)) {
|
|
414
|
+
if (named === test)
|
|
415
|
+
continue;
|
|
416
|
+
deps.add(named);
|
|
417
|
+
for (const dep of graph(named))
|
|
418
|
+
deps.add(dep);
|
|
419
|
+
}
|
|
420
|
+
for (const dir of templatedRepoDirs(text, topDirs)) {
|
|
421
|
+
for (const f of files)
|
|
422
|
+
if (f.startsWith(`${dir}/`) && f !== test)
|
|
423
|
+
deps.add(f);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
depMemo.set(test, deps);
|
|
427
|
+
return deps;
|
|
428
|
+
};
|
|
429
|
+
const spawning = (testFile) => SPAWN_RE.test(testText(testFile));
|
|
430
|
+
const tooling = files.filter((f) => /^tooling\/[^/]+\.mjs$/.test(f));
|
|
431
|
+
const wholeProgram = new Set(files.filter((f) => f.startsWith("src/")));
|
|
432
|
+
for (const t of tooling) {
|
|
433
|
+
wholeProgram.add(t);
|
|
434
|
+
for (const dep of graph(t))
|
|
435
|
+
wholeProgram.add(dep);
|
|
436
|
+
}
|
|
437
|
+
let namers;
|
|
438
|
+
const testNamers = (testFile) => {
|
|
439
|
+
if (!namers) {
|
|
440
|
+
namers = new Map();
|
|
441
|
+
for (const file of files.filter((f) => /^(?:src|tooling)\//.test(f) && CODE_FILE.test(f))) {
|
|
442
|
+
const text = read(file);
|
|
443
|
+
const tokens = [...text.matchAll(PATH_TOKEN_RE)].map((m) => m[0]);
|
|
444
|
+
for (const segs of joinSegmentRuns(text))
|
|
445
|
+
for (let i = 0; i < segs.length; i++)
|
|
446
|
+
tokens.push(segs.slice(i).join("/"));
|
|
447
|
+
for (const raw of tokens) {
|
|
448
|
+
const p = norm(raw).replace(/^(?:\.\.?\/)+/, "");
|
|
449
|
+
if (!p.startsWith("test/"))
|
|
450
|
+
continue;
|
|
451
|
+
let set = namers.get(p);
|
|
452
|
+
if (!set)
|
|
453
|
+
namers.set(p, set = new Set());
|
|
454
|
+
set.add(file);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return sorted(namers.get(norm(testFile)) ?? []);
|
|
459
|
+
};
|
|
460
|
+
return { dependencies, spawning, testText, wholeProgram, closureHash: closureHash(root, files, graph), testNamers };
|
|
461
|
+
}
|
|
462
|
+
/** Structural hash of the entry closures: every reachable file and its direct
|
|
463
|
+
* edges. Content edits don't change it; a new import or file in the closure does. */
|
|
464
|
+
export function closureHash(root, files, graph = buildImportGraph(root, files)) {
|
|
465
|
+
const available = new Set(files);
|
|
466
|
+
const roots = [...CLOSURE_ROOTS.filter((f) => available.has(f)), ...files.filter((f) => /^tooling\/[^/]+\.mjs$/.test(f))];
|
|
467
|
+
const reach = new Set();
|
|
468
|
+
for (const r of roots) {
|
|
469
|
+
reach.add(r);
|
|
470
|
+
for (const f of graph(r))
|
|
471
|
+
reach.add(f);
|
|
472
|
+
}
|
|
473
|
+
const hash = createHash("sha256");
|
|
474
|
+
for (const file of sorted(reach)) {
|
|
475
|
+
// Each reachable file's own closure: any added/removed edge changes one of them.
|
|
476
|
+
hash.update(`${file}\0${available.has(file) ? "1" : "0"}\0${sorted(graph(file)).join("\0")}\n`);
|
|
477
|
+
}
|
|
478
|
+
return hash.digest("hex");
|
|
479
|
+
}
|
|
480
|
+
/** Repo files with any executed range in V8 coverage reports
|
|
481
|
+
* (`NODE_V8_COVERAGE` JSON: `{ result: [{ url, functions: [{ ranges }] }] }`). */
|
|
482
|
+
export function coverageFiles(reports, root, repoFiles) {
|
|
483
|
+
const out = new Set();
|
|
484
|
+
for (const report of reports) {
|
|
485
|
+
const scripts = report?.result;
|
|
486
|
+
if (!Array.isArray(scripts))
|
|
487
|
+
continue;
|
|
488
|
+
for (const script of scripts) {
|
|
489
|
+
if (typeof script.url !== "string" || !script.url.startsWith("file:"))
|
|
490
|
+
continue;
|
|
491
|
+
if (!script.functions?.some((f) => f.ranges?.some((r) => (r.count ?? 0) > 0)))
|
|
492
|
+
continue;
|
|
493
|
+
let abs;
|
|
494
|
+
try {
|
|
495
|
+
abs = fileURLToPath(script.url.replace(/[?#].*$/, ""));
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const rel = relative(root, abs);
|
|
501
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel))
|
|
502
|
+
continue;
|
|
503
|
+
const file = norm(rel);
|
|
504
|
+
if (repoFiles.has(file))
|
|
505
|
+
out.add(file);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return sorted(out);
|
|
509
|
+
}
|
|
510
|
+
/** Parse `git diff --name-status -z` output; renames/copies yield both paths. */
|
|
511
|
+
function nameStatusPaths(out) {
|
|
512
|
+
const parts = out.split("\0").filter(Boolean);
|
|
513
|
+
const paths = [];
|
|
514
|
+
for (let i = 0; i < parts.length;) {
|
|
515
|
+
const status = parts[i++];
|
|
516
|
+
const n = /^[RC]/.test(status) ? 2 : 1;
|
|
517
|
+
for (let k = 0; k < n && i < parts.length; k++)
|
|
518
|
+
paths.push(parts[i++]);
|
|
519
|
+
}
|
|
520
|
+
return paths;
|
|
521
|
+
}
|
|
522
|
+
function hasHead(root) {
|
|
523
|
+
try {
|
|
524
|
+
git(root, ["rev-parse", "--verify", "-q", "HEAD"]);
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/** Changed files for a verify run. `worktree` (default): staged + unstaged +
|
|
532
|
+
* untracked; `staged`: the index only; `base`: merge-base(ref, HEAD)..HEAD
|
|
533
|
+
* plus the working tree. */
|
|
534
|
+
export function changedFiles(root, mode = { kind: "worktree" }) {
|
|
535
|
+
const diff = (...args) => nameStatusPaths(git(root, ["diff", "--name-status", "-z", "-M", ...args]));
|
|
536
|
+
const untracked = () => git(root, ["ls-files", "-z", "--others", "--exclude-standard"]).split("\0").filter(Boolean);
|
|
537
|
+
const paths = [];
|
|
538
|
+
if (mode.kind === "staged") {
|
|
539
|
+
paths.push(...diff("--cached"));
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
if (mode.kind === "base") {
|
|
543
|
+
const base = git(root, ["merge-base", mode.ref, "HEAD"]).trim();
|
|
544
|
+
paths.push(...diff(base, "HEAD"));
|
|
545
|
+
}
|
|
546
|
+
if (hasHead(root))
|
|
547
|
+
paths.push(...diff("HEAD"));
|
|
548
|
+
else
|
|
549
|
+
paths.push(...diff("--cached"), ...diff());
|
|
550
|
+
paths.push(...untracked());
|
|
551
|
+
}
|
|
552
|
+
return sorted(paths.map(norm));
|
|
553
|
+
}
|
|
554
|
+
/** Load an impact map; a missing file is `null`, a malformed one throws. A
|
|
555
|
+
* legacy `test: string[]` entry loads as incomplete (never trusted). */
|
|
556
|
+
export function loadImpactMap(path) {
|
|
557
|
+
if (!existsSync(path))
|
|
558
|
+
return null;
|
|
559
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
560
|
+
const commit = typeof raw?.commit === "string" ? raw.commit : "";
|
|
561
|
+
const bad = () => new Error(`invalid impact map ${path}: expected { version, commit, map: { test: { files, complete, commit, closureHash } } }`);
|
|
562
|
+
if (!raw?.map || typeof raw.map !== "object" || Array.isArray(raw.map))
|
|
563
|
+
throw bad();
|
|
564
|
+
const strings = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
565
|
+
const map = {};
|
|
566
|
+
for (const [test, value] of Object.entries(raw.map)) {
|
|
567
|
+
if (strings(value)) {
|
|
568
|
+
map[norm(test)] = { files: value.map(norm), complete: false, commit, closureHash: "" };
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const e = value;
|
|
572
|
+
if (!e || typeof e !== "object" || !strings(e.files))
|
|
573
|
+
throw bad();
|
|
574
|
+
map[norm(test)] = {
|
|
575
|
+
files: e.files.map(norm),
|
|
576
|
+
complete: e.complete === true,
|
|
577
|
+
commit: typeof e.commit === "string" ? e.commit : commit,
|
|
578
|
+
closureHash: typeof e.closureHash === "string" ? e.closureHash : "",
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
version: Number(raw.version) || 0, commit,
|
|
583
|
+
...(typeof raw.generatedAt === "string" ? { generatedAt: raw.generatedAt } : {}),
|
|
584
|
+
...(typeof raw.closureHash === "string" ? { closureHash: raw.closureHash } : {}),
|
|
585
|
+
map,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
//# sourceMappingURL=testSelection.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** `hunch_why(<record id>)` (issue #474): every brief line Hunch delivers ends in
|
|
2
|
+
* `hunch_why("dec_…")`, so why() must resolve a record id, not only a path/symbol.
|
|
3
|
+
* The lookup lives in HunchStore.why(); this module holds the id shape and the one
|
|
4
|
+
* renderer the MCP tool and the CLI share, so the two surfaces cannot drift apart. */
|
|
5
|
+
import type { Bug, Component, Constraint, Decision, Finding } from "./types.js";
|
|
6
|
+
/** Targets why() tries as a record id first. Generated ids are `<prefix>_<hex>`
|
|
7
|
+
* (shortHash), but hand-written/imported ids (`dec_session_transport`) are valid too. */
|
|
8
|
+
export declare const WHY_RECORD_ID: RegExp;
|
|
9
|
+
/** The generated id shape: only a miss on THIS shape is reported as "No record <id>";
|
|
10
|
+
* any other miss (e.g. a symbol named `bug_handler`) falls through to path/symbol lookup. */
|
|
11
|
+
export declare const WHY_GENERATED_ID: RegExp;
|
|
12
|
+
/** One link of a decision's supersession chain, oldest first. */
|
|
13
|
+
export interface WhyChainLink {
|
|
14
|
+
id: string;
|
|
15
|
+
status: string;
|
|
16
|
+
title: string;
|
|
17
|
+
}
|
|
18
|
+
export type WhyRecord = {
|
|
19
|
+
kind: "decision";
|
|
20
|
+
record: Decision;
|
|
21
|
+
chain: WhyChainLink[];
|
|
22
|
+
} | {
|
|
23
|
+
kind: "constraint";
|
|
24
|
+
record: Constraint;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "bug";
|
|
27
|
+
record: Bug;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "finding";
|
|
30
|
+
record: Finding;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "component";
|
|
33
|
+
record: Component;
|
|
34
|
+
};
|
|
35
|
+
/** The by-id half of a WhyResult: `record` is null when no visible record has the id
|
|
36
|
+
* (or, with `asOf`, none was in force at that instant). */
|
|
37
|
+
export interface WhyById {
|
|
38
|
+
id: string;
|
|
39
|
+
record: WhyRecord | null;
|
|
40
|
+
asOf?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Per-list cap in hunch_why output, and the "what was withheld" note after a capped
|
|
43
|
+
* list — shared with the path/symbol view in the MCP server. */
|
|
44
|
+
export declare const WHY_CAP = 6;
|
|
45
|
+
export declare const more: (total: number, cap: number, hint?: string) => string;
|
|
46
|
+
/** Plain-text detail lines for a record looked up by id. */
|
|
47
|
+
export declare function formatWhyRecord(byId: WhyById): string[];
|