@davesheffer/hunch 1.38.1 → 1.39.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/dist/cli/index.js +355 -55
- package/dist/cli/integrations.js +10 -0
- package/dist/cli/serve.js +1 -0
- package/dist/client/readOrCompute.d.ts +77 -0
- package/dist/client/readOrCompute.js +85 -0
- package/dist/client/state.d.ts +1 -0
- package/dist/client/state.js +1 -0
- package/dist/constitution/g2.d.ts +1 -0
- package/dist/constitution/service.js +8 -0
- package/dist/constitution/sourceMutation.js +23 -18
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +48 -5
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +13 -0
- package/dist/core/machine.d.ts +20 -0
- package/dist/core/machine.js +101 -0
- package/dist/core/taskReportEvidence.js +6 -6
- package/dist/core/types.d.ts +67 -1
- package/dist/core/types.js +3 -0
- package/dist/core/workspace.d.ts +256 -0
- package/dist/core/workspace.js +359 -0
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/helm.d.ts +17 -28
- package/dist/extractors/helm.js +12 -12
- package/dist/extractors/indexer.js +171 -7
- package/dist/extractors/k8sManifest.d.ts +59 -0
- package/dist/extractors/k8sManifest.js +507 -0
- package/dist/extractors/workspaces.d.ts +28 -0
- package/dist/extractors/workspaces.js +427 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +63 -7
- package/dist/integrations/hooks.js +350 -38
- package/dist/integrations/scaffold.js +11 -0
- package/dist/integrations/workspaceLedger.d.ts +93 -0
- package/dist/integrations/workspaceLedger.js +307 -0
- package/dist/mcp/server.js +59 -5
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +107 -92
- package/dist/serve/mcpHttp.d.ts +27 -0
- package/dist/serve/mcpHttp.js +95 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +83 -35
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/extractors/git.js
CHANGED
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { MEMLOG_FORMAT } from "../core/memorylog.js";
|
|
10
10
|
import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
|
|
11
11
|
import { createRepoFileReader } from "../core/safeRepoFile.js";
|
|
12
|
+
import { DIFF_TRUNCATED_LINE } from "./diff.js";
|
|
12
13
|
import { initiatorChildEnv } from "../synthesis/initiator.js";
|
|
13
14
|
// `git` exports these repository-local variables to hooks. They outrank cwd/-C,
|
|
14
15
|
// so carrying them from the code repository into a command for the memory
|
|
@@ -71,6 +72,26 @@ function gitSafe(args, cwd, maxBuffer) {
|
|
|
71
72
|
return "";
|
|
72
73
|
}
|
|
73
74
|
}
|
|
75
|
+
/** Untrimmed stdout with the same child environment as git(); null on ANY failure
|
|
76
|
+
* (non-zero exit, spawn error, output over maxBuffer). For callers that must tell
|
|
77
|
+
* "git produced nothing" apart from "git could not answer". */
|
|
78
|
+
function gitOutputOrNull(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
79
|
+
try {
|
|
80
|
+
return execFileSync("git", args, {
|
|
81
|
+
cwd, encoding: "utf8", maxBuffer,
|
|
82
|
+
env: initiatorChildEnv(),
|
|
83
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Split `-z` path output. NUL-delimited paths are never C-quoted, so a path
|
|
91
|
+
* holding `"`, `\`, a tab or a newline enumerates as its literal spelling. */
|
|
92
|
+
function nulPaths(out) {
|
|
93
|
+
return out ? out.split("\0").filter(Boolean) : [];
|
|
94
|
+
}
|
|
74
95
|
/** Object-identity reads must not inherit clone-local replacement refs/grafts.
|
|
75
96
|
* Most Git helpers intentionally preserve ordinary repository behavior; use
|
|
76
97
|
* this narrower path only where a cross-clone canonical identity is minted. */
|
|
@@ -1808,6 +1829,14 @@ export function gitUntrackCached(cwd, paths) {
|
|
|
1808
1829
|
}
|
|
1809
1830
|
catch { /* best-effort: not a repo / nothing tracked */ }
|
|
1810
1831
|
}
|
|
1832
|
+
/** Tracked files under the given pathspecs (repo-relative, POSIX). Best-effort:
|
|
1833
|
+
* `[]` when not a repository or nothing is tracked there. */
|
|
1834
|
+
export function gitTrackedPaths(cwd, paths) {
|
|
1835
|
+
if (paths.length === 0)
|
|
1836
|
+
return [];
|
|
1837
|
+
const out = gitSafe(["-c", "core.quotePath=false", "ls-files", "--", ...paths], cwd);
|
|
1838
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
1839
|
+
}
|
|
1811
1840
|
/** Resolve any commit-ish (short sha / HEAD / branch) to a canonical full sha.
|
|
1812
1841
|
* Returns the input unchanged if it can't be resolved (e.g. not a git repo). */
|
|
1813
1842
|
export function revParse(ref, cwd) {
|
|
@@ -1851,8 +1880,7 @@ export function currentBranch(cwd) {
|
|
|
1851
1880
|
/** Files changed in a single commit. `--root` makes the initial commit (which
|
|
1852
1881
|
* has no parent) report its files as additions instead of returning nothing. */
|
|
1853
1882
|
export function commitFiles(sha, cwd) {
|
|
1854
|
-
|
|
1855
|
-
return out ? out.split("\n").filter(Boolean) : [];
|
|
1883
|
+
return nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "--name-only", "-z", "-r", "--root", sha], cwd));
|
|
1856
1884
|
}
|
|
1857
1885
|
/** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
|
|
1858
1886
|
* timeline (each commit that changed the graph). Newest first; empty on any error
|
|
@@ -2302,12 +2330,55 @@ const DIFF_NOISE = [
|
|
|
2302
2330
|
":(exclude,glob)**/__snapshots__/**",
|
|
2303
2331
|
":(exclude,glob)**/*.generated.*",
|
|
2304
2332
|
];
|
|
2305
|
-
/**
|
|
2333
|
+
/** Byte budget for a diff embedded in a SYNTHESIS prompt. Gates never use it: a
|
|
2334
|
+
* constraint check over a prefix of a change would read every file past the
|
|
2335
|
+
* cutoff as "no added lines" and pass it. */
|
|
2336
|
+
export const SYNTHESIS_DIFF_BUDGET = 60_000;
|
|
2337
|
+
function capDiff(out, maxBytes) {
|
|
2338
|
+
return out.length > maxBytes ? out.slice(0, maxBytes) + `\n${DIFF_TRUNCATED_LINE}` : out;
|
|
2339
|
+
}
|
|
2340
|
+
/** Output ceiling for a gating diff. Past it git's output cannot be captured; the
|
|
2341
|
+
* diff is then reported incomplete and the gate fails closed (dec_20db57c576). */
|
|
2342
|
+
const GATE_DIFF_MAX_BUFFER = 256 * 1024 * 1024;
|
|
2343
|
+
/** Pinned so user/repo config cannot change what the diff parser sees: literal
|
|
2344
|
+
* UTF-8 paths (issue #50) and a/ b/ prefixes regardless of diff.noprefix or
|
|
2345
|
+
* diff.mnemonicPrefix. */
|
|
2346
|
+
const GATE_DIFF_FLAGS = ["--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
2347
|
+
function gateDiff(args, cwd, what) {
|
|
2348
|
+
const out = gitOutputOrNull(["-c", "core.quotePath=false", ...args], cwd, GATE_DIFF_MAX_BUFFER);
|
|
2349
|
+
return out === null
|
|
2350
|
+
? { diff: "", incomplete: `git could not produce the ${what} diff (git error, or output over ${GATE_DIFF_MAX_BUFFER / (1024 * 1024)} MB)` }
|
|
2351
|
+
: { diff: out };
|
|
2352
|
+
}
|
|
2353
|
+
/** C-quote a path the way git does when it holds `"`, `\`, or a control character,
|
|
2354
|
+
* so a synthetic diff header round-trips through analyzeDiff's unquoting. */
|
|
2355
|
+
function quoteDiffPath(path) {
|
|
2356
|
+
if (!/["\\\x00-\x1f\x7f]/.test(path))
|
|
2357
|
+
return path;
|
|
2358
|
+
const named = { '"': '\\"', "\\": "\\\\", "\t": "\\t", "\n": "\\n", "\r": "\\r" };
|
|
2359
|
+
let out = "";
|
|
2360
|
+
for (const ch of path) {
|
|
2361
|
+
const code = ch.charCodeAt(0);
|
|
2362
|
+
if (named[ch])
|
|
2363
|
+
out += named[ch];
|
|
2364
|
+
else if (code < 0x20 || code === 0x7f)
|
|
2365
|
+
out += "\\" + code.toString(8).padStart(3, "0");
|
|
2366
|
+
else
|
|
2367
|
+
out += ch;
|
|
2368
|
+
}
|
|
2369
|
+
return `"${out}"`;
|
|
2370
|
+
}
|
|
2371
|
+
/** Complete gating diff of one commit (see GateDiff). */
|
|
2372
|
+
export function commitGateDiff(sha, cwd) {
|
|
2373
|
+
return gateDiff(["show", sha, "--format=", ...GATE_DIFF_FLAGS], cwd, `commit ${sha}`);
|
|
2374
|
+
}
|
|
2375
|
+
/** The unified diff for a commit, truncated to keep SYNTHESIS prompts bounded.
|
|
2306
2376
|
* Machine-generated noise (see DIFF_NOISE) is excluded so the model spends its
|
|
2307
|
-
* budget on code that encodes intent, not on regenerated lockfiles/build output.
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2377
|
+
* budget on code that encodes intent, not on regenerated lockfiles/build output.
|
|
2378
|
+
* Not for gating: use commitGateDiff. */
|
|
2379
|
+
export function commitDiff(sha, cwd, maxBytes = SYNTHESIS_DIFF_BUDGET) {
|
|
2380
|
+
const out = gitSafe(["-c", "core.quotePath=false", "show", sha, "--no-color", "--format=", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
2381
|
+
return capDiff(out, maxBytes);
|
|
2311
2382
|
}
|
|
2312
2383
|
/** Number of commits touching a file in the last `days` (churn). */
|
|
2313
2384
|
export function fileChurn(file, cwd, days = 90) {
|
|
@@ -2438,15 +2509,14 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
2438
2509
|
* POSIX paths nor constraint scope globs — a blocking constraint over such a
|
|
2439
2510
|
* file graded as a vacuous PASS, and its churn/last-commit metrics read zero. */
|
|
2440
2511
|
export function stagedFiles(cwd) {
|
|
2441
|
-
|
|
2442
|
-
return out ? out.split("\n").filter(Boolean) : [];
|
|
2512
|
+
return nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMR"], cwd));
|
|
2443
2513
|
}
|
|
2444
2514
|
/** Files changed anywhere in the working tree compared with HEAD: both staged
|
|
2445
2515
|
* and unstaged tracked files, plus untracked files. This powers the local,
|
|
2446
2516
|
* pre-commit Change Gate; it never mutates the index or asks an agent/model. */
|
|
2447
2517
|
export function workingFiles(cwd) {
|
|
2448
|
-
const changed =
|
|
2449
|
-
const untracked =
|
|
2518
|
+
const changed = nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMR"], cwd));
|
|
2519
|
+
const untracked = nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"], cwd));
|
|
2450
2520
|
return [...new Set([...changed, ...untracked])].sort();
|
|
2451
2521
|
}
|
|
2452
2522
|
/** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
|
|
@@ -2458,8 +2528,7 @@ export function revExists(ref, cwd) {
|
|
|
2458
2528
|
/** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
|
|
2459
2529
|
* i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
|
|
2460
2530
|
export function rangeFiles(base, cwd, head = "HEAD") {
|
|
2461
|
-
|
|
2462
|
-
return out ? out.split("\n").filter(Boolean) : [];
|
|
2531
|
+
return nulPaths(gitOutputOrNull(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMR", `${base}...${head}`], cwd));
|
|
2463
2532
|
}
|
|
2464
2533
|
/** Commit subjects on `head` since `base` (2-dot: commits added by the task),
|
|
2465
2534
|
* oldest-first, for distilling a runbook's ordered steps (roadmap #5). */
|
|
@@ -2467,43 +2536,78 @@ export function rangeSubjects(base, cwd, head = "HEAD", max = 50) {
|
|
|
2467
2536
|
const out = gitSafe(["log", "--reverse", `-n${max}`, "--format=%s", `${base}..${head}`], cwd);
|
|
2468
2537
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
2469
2538
|
}
|
|
2470
|
-
/**
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2539
|
+
/** Complete gating diff of a PR vs `base` (3-dot: the CI Constraint Guard's surface). */
|
|
2540
|
+
export function rangeGateDiff(base, cwd, head = "HEAD") {
|
|
2541
|
+
return gateDiff(["diff", ...GATE_DIFF_FLAGS, `${base}...${head}`], cwd, `${base}...${head}`);
|
|
2542
|
+
}
|
|
2543
|
+
/** The PR's unified diff vs `base` (3-dot), noise-excluded and capped at the
|
|
2544
|
+
* synthesis budget. Bounded-prompt use only; gates use rangeGateDiff. */
|
|
2545
|
+
export function rangeDiff(base, cwd, head = "HEAD", maxBytes = SYNTHESIS_DIFF_BUDGET) {
|
|
2546
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", `${base}...${head}`, "--", ...DIFF_NOISE], cwd);
|
|
2547
|
+
return capDiff(out, maxBytes);
|
|
2475
2548
|
}
|
|
2476
|
-
/**
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
2480
|
-
const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
2481
|
-
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
2549
|
+
/** Complete gating diff of the staged changes (pre-commit `hunch check`). */
|
|
2550
|
+
export function stagedGateDiff(cwd) {
|
|
2551
|
+
return gateDiff(["diff", "--cached", ...GATE_DIFF_FLAGS], cwd, "staged");
|
|
2482
2552
|
}
|
|
2483
|
-
/** Unified diff of the
|
|
2553
|
+
/** Unified diff of the staged changes, noise-excluded and capped at the synthesis
|
|
2554
|
+
* budget. Bounded-prompt use only; gates use stagedGateDiff. */
|
|
2555
|
+
export function stagedDiff(cwd, maxBytes = SYNTHESIS_DIFF_BUDGET) {
|
|
2556
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
2557
|
+
return capDiff(out, maxBytes);
|
|
2558
|
+
}
|
|
2559
|
+
/** Complete gating diff of the local working tree vs HEAD. Git's normal diff
|
|
2484
2560
|
* includes both staged and unstaged tracked edits; untracked text files are
|
|
2485
|
-
* appended as synthetic additions so guards can also see their added
|
|
2486
|
-
* Binary
|
|
2487
|
-
*
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2561
|
+
* appended as synthetic additions so guards can also see their added lines.
|
|
2562
|
+
* Binary files and non-regular paths (symlinks) contribute no content, as in a
|
|
2563
|
+
* git diff; an untracked regular file that cannot be read is listed in
|
|
2564
|
+
* `unreadFiles`, so content checks over it fail closed. */
|
|
2565
|
+
export function workingGateDiff(cwd) {
|
|
2566
|
+
// An unborn HEAD has no tracked diff (workingFiles enumerates only untracked
|
|
2567
|
+
// files there); any other failure to diff makes the gate input incomplete.
|
|
2568
|
+
const hasHead = revExists("HEAD", cwd);
|
|
2569
|
+
const tracked = hasHead ? gateDiff(["diff", "HEAD", ...GATE_DIFF_FLAGS], cwd, "working-tree") : { diff: "" };
|
|
2570
|
+
const trackedNames = hasHead
|
|
2571
|
+
? gitOutputOrNull(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMR"], cwd)
|
|
2572
|
+
: "";
|
|
2573
|
+
const untrackedNames = gitOutputOrNull(["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"], cwd);
|
|
2574
|
+
const incomplete = tracked.incomplete
|
|
2575
|
+
?? (trackedNames === null || untrackedNames === null ? "git could not enumerate the working-tree changes" : undefined);
|
|
2576
|
+
const trackedSet = new Set(nulPaths(trackedNames));
|
|
2577
|
+
const untracked = nulPaths(untrackedNames).filter((f) => !trackedSet.has(f));
|
|
2492
2578
|
const readWorkingFile = createRepoFileReader(cwd);
|
|
2579
|
+
const unreadFiles = [];
|
|
2580
|
+
let out = tracked.diff;
|
|
2493
2581
|
for (const file of untracked) {
|
|
2582
|
+
let regular = false;
|
|
2494
2583
|
try {
|
|
2495
|
-
|
|
2496
|
-
if (text === null)
|
|
2497
|
-
continue;
|
|
2498
|
-
if (text.includes("\0"))
|
|
2499
|
-
continue;
|
|
2500
|
-
const lines = text.split("\n");
|
|
2501
|
-
const add = lines.map((line) => `+${line}`).join("\n");
|
|
2502
|
-
out += `${out ? "\n" : ""}diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n${add}\n`;
|
|
2584
|
+
regular = lstatSync(join(cwd, file)).isFile();
|
|
2503
2585
|
}
|
|
2504
|
-
catch { /*
|
|
2505
|
-
|
|
2506
|
-
|
|
2586
|
+
catch { /* vanished: nothing to inspect */ }
|
|
2587
|
+
if (!regular)
|
|
2588
|
+
continue; // symlink / directory / gone: no file content to add
|
|
2589
|
+
const text = readWorkingFile(file);
|
|
2590
|
+
if (text === null) {
|
|
2591
|
+
unreadFiles.push(file);
|
|
2592
|
+
continue;
|
|
2593
|
+
}
|
|
2594
|
+
if (text.includes("\0"))
|
|
2595
|
+
continue; // binary: git shows no text hunks for it either
|
|
2596
|
+
const lines = text.split("\n");
|
|
2597
|
+
const add = lines.map((line) => `+${line}`).join("\n");
|
|
2598
|
+
const newPath = quoteDiffPath(`b/${file}`);
|
|
2599
|
+
out += `${out && !out.endsWith("\n") ? "\n" : ""}diff --git ${quoteDiffPath(`a/${file}`)} ${newPath}\nnew file mode 100644\n--- /dev/null\n+++ ${newPath}\n@@ -0,0 +1,${lines.length} @@\n${add}\n`;
|
|
2600
|
+
}
|
|
2601
|
+
return {
|
|
2602
|
+
diff: out,
|
|
2603
|
+
...(incomplete ? { incomplete } : {}),
|
|
2604
|
+
...(unreadFiles.length ? { unreadFiles } : {}),
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
/** Working-tree diff vs HEAD capped at the synthesis budget. Bounded-prompt use
|
|
2608
|
+
* only; gates and rule verification use workingGateDiff. */
|
|
2609
|
+
export function workingDiff(cwd, maxBytes = SYNTHESIS_DIFF_BUDGET) {
|
|
2610
|
+
return capDiff(workingGateDiff(cwd).diff, maxBytes);
|
|
2507
2611
|
}
|
|
2508
2612
|
/** Resolve a time-travel ref (commit / tag / branch / HEAD~n) to the ISO author-
|
|
2509
2613
|
* date of that commit — the instant valid-time windows are filtered against.
|
|
@@ -1,31 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* slightly-long symbol range) — the same class of accepted limitation
|
|
17
|
-
* `toleratedErrorScopes` documents for the tree-sitter grammars, not a silent gap.
|
|
18
|
-
* The chart-wide `importedFiles` widening this module's output flows through
|
|
19
|
-
* (indexer.ts) also lets a YAML alias resolve to an anchor in a sibling chart
|
|
20
|
-
* file, even though YAML anchors are properly document-scoped; this only fires
|
|
21
|
-
* when the alias has no matching anchor in its own file (i.e. only on input
|
|
22
|
-
* that's already invalid YAML on its own terms), so it's bounded, but it's a
|
|
23
|
-
* real, disclosed side effect of the chart-scoping mechanism, not something to
|
|
24
|
-
* silently rely on.
|
|
25
|
-
*/
|
|
26
|
-
import { type ParsedSymbol, type ParsedCall } from "./parse.js";
|
|
1
|
+
import type { ParsedSymbolKind } from "./languages.js";
|
|
2
|
+
export interface HelmSymbol {
|
|
3
|
+
name: string;
|
|
4
|
+
kind: ParsedSymbolKind;
|
|
5
|
+
startChar: number;
|
|
6
|
+
endChar: number;
|
|
7
|
+
loc: number;
|
|
8
|
+
bodyText: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HelmCall {
|
|
11
|
+
callee: string;
|
|
12
|
+
atChar: number;
|
|
13
|
+
endChar: number;
|
|
14
|
+
member: boolean;
|
|
15
|
+
}
|
|
27
16
|
export interface HelmExtraction {
|
|
28
|
-
symbols:
|
|
29
|
-
calls:
|
|
17
|
+
symbols: HelmSymbol[];
|
|
18
|
+
calls: HelmCall[];
|
|
30
19
|
}
|
|
31
20
|
export declare function extractHelmDirectives(source: string): HelmExtraction;
|
package/dist/extractors/helm.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Known bounded limitation: this is a token scan, not a full parser. A literal
|
|
13
13
|
* `}}` inside a quoted argument, or an include/define-shaped string inside a
|
|
14
|
-
* `{{/* comment *}}`, can misattribute a
|
|
14
|
+
* `{{/* comment *}}`, can misattribute a char range or produce a phantom call.
|
|
15
15
|
* Both are bounded failure modes (a stray reference to a real symbol name, or a
|
|
16
16
|
* slightly-long symbol range) — the same class of accepted limitation
|
|
17
17
|
* `toleratedErrorScopes` documents for the tree-sitter grammars, not a silent gap.
|
|
@@ -39,20 +39,20 @@ export function extractHelmDirectives(source) {
|
|
|
39
39
|
const stack = [];
|
|
40
40
|
for (const m of source.matchAll(ACTION)) {
|
|
41
41
|
const raw = m[1];
|
|
42
|
-
// "{{" is 2 chars; a trim-marker "{{-" is 3 — this is the
|
|
43
|
-
// `raw`'s first character within `source`, needed for call-site
|
|
42
|
+
// "{{" is 2 chars; a trim-marker "{{-" is 3 — this is the char offset of
|
|
43
|
+
// `raw`'s first character within `source`, needed for call-site atChar math.
|
|
44
44
|
const innerStart = m.index + (source[m.index + 2] === "-" ? 3 : 2);
|
|
45
|
-
const
|
|
45
|
+
const endChar = m.index + m[0].length;
|
|
46
46
|
const body = raw.trim();
|
|
47
47
|
const spaceIdx = body.search(/\s/);
|
|
48
48
|
const keyword = spaceIdx === -1 ? body : body.slice(0, spaceIdx);
|
|
49
49
|
if (keyword === "define") {
|
|
50
50
|
const name = NAME_ARG.exec(body.slice(spaceIdx + 1).trim())?.[1];
|
|
51
|
-
stack.push({ name,
|
|
51
|
+
stack.push({ name, startChar: m.index });
|
|
52
52
|
}
|
|
53
53
|
else if (BLOCK_OPEN.has(keyword)) {
|
|
54
54
|
// if/range/with/block: depth marker only, no symbol on its own.
|
|
55
|
-
stack.push({
|
|
55
|
+
stack.push({ startChar: m.index });
|
|
56
56
|
}
|
|
57
57
|
else if (keyword === "end") {
|
|
58
58
|
const open = stack.pop();
|
|
@@ -60,16 +60,16 @@ export function extractHelmDirectives(source) {
|
|
|
60
60
|
symbols.push({
|
|
61
61
|
name: open.name,
|
|
62
62
|
kind: "variable",
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
loc: source.slice(open.
|
|
66
|
-
bodyText: source.slice(open.
|
|
63
|
+
startChar: open.startChar,
|
|
64
|
+
endChar,
|
|
65
|
+
loc: source.slice(open.startChar, endChar).split("\n").length,
|
|
66
|
+
bodyText: source.slice(open.startChar, endChar).slice(0, MAX_BODY_TEXT_CHARS),
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
for (const call of raw.matchAll(CALL_SITE)) {
|
|
71
|
-
const
|
|
72
|
-
calls.push({ callee: call[1],
|
|
71
|
+
const atChar = innerStart + call.index;
|
|
72
|
+
calls.push({ callee: call[1], atChar, endChar: atChar + call[0].length, member: false });
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
return { symbols, calls };
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
12
|
import { dirname, join, posix } from "node:path";
|
|
13
|
-
import { parseSource, attributeCalls, attributeRelations } from "./parse.js";
|
|
13
|
+
import { parseSource, attributeCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
|
|
14
14
|
import { extractHelmDirectives } from "./helm.js";
|
|
15
|
+
import { extractK8sManifest } from "./k8sManifest.js";
|
|
15
16
|
import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
|
|
16
17
|
import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
|
|
17
18
|
import { resolveRelativeImport } from "../core/relativeImports.js";
|
|
@@ -100,6 +101,10 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
100
101
|
const perFileCalls = [];
|
|
101
102
|
const perFileImports = [];
|
|
102
103
|
const perFileRelations = [];
|
|
104
|
+
const k8sResourceIndex = [];
|
|
105
|
+
const k8sReferenceCandidates = [];
|
|
106
|
+
const k8sSelectors = [];
|
|
107
|
+
const k8sWorkloadLabels = [];
|
|
103
108
|
const phpNamespaces = new Map();
|
|
104
109
|
const phpUseDeclarations = new Map();
|
|
105
110
|
// Batched per-file git metrics (churn + last commit) in TWO `git log` spawns
|
|
@@ -113,7 +118,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
113
118
|
const chartRoot = chartRootFor(path);
|
|
114
119
|
if (chartRoot === null)
|
|
115
120
|
continue;
|
|
116
|
-
(chartFiles
|
|
121
|
+
pushInto(chartFiles, chartRoot, path);
|
|
117
122
|
}
|
|
118
123
|
const gitMeta = useGit ? fileGitMetrics(root, rels, opts.churn === false ? 0 : 90) : null;
|
|
119
124
|
let skipped = 0;
|
|
@@ -165,8 +170,50 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
165
170
|
// JS-falsy — `if (chartRoot)` would silently skip every repo-root chart.
|
|
166
171
|
if (chartRoot !== null) {
|
|
167
172
|
const helm = extractHelmDirectives(src);
|
|
168
|
-
|
|
169
|
-
|
|
173
|
+
// helm.ts's own offsets are named *Char (they're JS char indices, not
|
|
174
|
+
// UTF-8 bytes -- see its module doc comment); mapped here into
|
|
175
|
+
// parsed.symbols/calls's startByte/endByte/atByte fields, which carry
|
|
176
|
+
// the same char-index values under the shared ParsedSymbol/ParsedCall
|
|
177
|
+
// naming this merge target already uses.
|
|
178
|
+
const helmSymbols = helm.symbols.map((s) => ({
|
|
179
|
+
name: s.name, kind: s.kind, startByte: s.startChar, endByte: s.endChar, loc: s.loc, bodyText: s.bodyText,
|
|
180
|
+
}));
|
|
181
|
+
const helmCalls = helm.calls.map((c) => ({
|
|
182
|
+
callee: c.callee, atByte: c.atChar, endByte: c.endChar, member: c.member,
|
|
183
|
+
}));
|
|
184
|
+
parsed.symbols = [...parsed.symbols, ...helmSymbols].sort((a, b) => a.startByte - b.startByte);
|
|
185
|
+
parsed.calls = [...parsed.calls, ...helmCalls];
|
|
186
|
+
}
|
|
187
|
+
// Runs for EVERY yaml file, chart or not (unlike the Helm merge above) --
|
|
188
|
+
// raw manifests with no Chart.yaml are still in scope; what varies below is
|
|
189
|
+
// resolution SCOPE (chartRoot ?? this file), not whether extraction runs.
|
|
190
|
+
let k8sDocs = [];
|
|
191
|
+
// THIS file's K8s resource symbol OBJECTS (identity, not their byte
|
|
192
|
+
// offsets) -- scopes the id lookup below to exactly the symbols this pass
|
|
193
|
+
// creates. Identity, not a Set<number> of startBytes, because a byte
|
|
194
|
+
// value is not a reliable per-symbol key: a Helm `define` symbol that
|
|
195
|
+
// happens to share a startByte with a K8s doc symbol in the same .yaml
|
|
196
|
+
// file (both legitimately synthetic, both can start at byte 0) would
|
|
197
|
+
// otherwise be indistinguishable by offset alone, and the wrong (Helm)
|
|
198
|
+
// symbol id could get recorded instead of the K8s one.
|
|
199
|
+
const k8sSymbolObjects = new Set();
|
|
200
|
+
if (languageFor(rel)?.id === "yaml") {
|
|
201
|
+
k8sDocs = extractK8sManifest(src);
|
|
202
|
+
const k8sSymbols = k8sDocs
|
|
203
|
+
.filter((d) => d.resource !== null)
|
|
204
|
+
// k8sManifest.ts's own offsets are named *Char; mapped here into the
|
|
205
|
+
// shared startByte/endByte fields, same as the Helm merge above.
|
|
206
|
+
.map((d) => ({
|
|
207
|
+
name: `${d.resource.kind}/${displayNameText(d.resource.name)}`,
|
|
208
|
+
kind: "variable",
|
|
209
|
+
startByte: d.resource.startChar,
|
|
210
|
+
endByte: d.resource.endChar,
|
|
211
|
+
loc: src.slice(d.resource.startChar, d.resource.endChar).split("\n").length,
|
|
212
|
+
bodyText: src.slice(d.resource.startChar, d.resource.endChar).slice(0, MAX_BODY_TEXT_CHARS),
|
|
213
|
+
}));
|
|
214
|
+
for (const s of k8sSymbols)
|
|
215
|
+
k8sSymbolObjects.add(s);
|
|
216
|
+
parsed.symbols = [...parsed.symbols, ...k8sSymbols].sort((a, b) => a.startByte - b.startByte);
|
|
170
217
|
}
|
|
171
218
|
const m = gitMeta?.get(rel);
|
|
172
219
|
const churn = opts.churn === false ? (preservedChurn.get(rel) ?? 0) : (m?.churn ?? 0);
|
|
@@ -174,6 +221,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
174
221
|
const idsInFile = [];
|
|
175
222
|
const symbolIndexId = new Map();
|
|
176
223
|
const idCounts = new Map(); // disambiguate same (file,name,kind)
|
|
224
|
+
const k8sSymbolIdByStartByte = new Map();
|
|
177
225
|
for (const [index, ps] of parsed.symbols.entries()) {
|
|
178
226
|
const base = symbolId(rel, ps.name, ps.kind);
|
|
179
227
|
const n = idCounts.get(base) ?? 0;
|
|
@@ -182,7 +230,7 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
182
230
|
const id = n === 0 ? base : `${base}_${n}`;
|
|
183
231
|
idsInFile.push(id);
|
|
184
232
|
symbolIndexId.set(index, id);
|
|
185
|
-
(nameIndex
|
|
233
|
+
pushInto(nameIndex, ps.name, id);
|
|
186
234
|
symbols.push({
|
|
187
235
|
id, file: rel, name: ps.name, kind: ps.kind,
|
|
188
236
|
signature_hash: sha1(ps.bodyText).slice(0, 16),
|
|
@@ -190,6 +238,27 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
190
238
|
metrics: { loc: ps.loc, churn_90d: churn, bug_count: 0, fan_in: 0, fan_out: 0 },
|
|
191
239
|
last_changed: last,
|
|
192
240
|
});
|
|
241
|
+
if (k8sSymbolObjects.has(ps))
|
|
242
|
+
k8sSymbolIdByStartByte.set(ps.startByte, id);
|
|
243
|
+
}
|
|
244
|
+
for (const doc of k8sDocs) {
|
|
245
|
+
if (!doc.resource)
|
|
246
|
+
continue;
|
|
247
|
+
const fromId = k8sSymbolIdByStartByte.get(doc.resource.startChar);
|
|
248
|
+
if (!fromId)
|
|
249
|
+
continue;
|
|
250
|
+
const scope = chartRoot ?? rel;
|
|
251
|
+
k8sResourceIndex.push({ symbolId: fromId, scope, kind: doc.resource.kind, nameKey: nameKeyText(doc.resource.name) });
|
|
252
|
+
for (const ref of doc.references) {
|
|
253
|
+
k8sReferenceCandidates.push({
|
|
254
|
+
fromSymbolId: fromId, scope, refKind: ref.refKind, nameKey: nameKeyText(ref.name),
|
|
255
|
+
reason: `${doc.resource.kind}/${displayNameText(doc.resource.name)} references ${ref.refKind}/${displayNameText(ref.name)}`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (doc.selector)
|
|
259
|
+
k8sSelectors.push({ symbolId: fromId, scope, selector: doc.selector });
|
|
260
|
+
if (doc.labels)
|
|
261
|
+
k8sWorkloadLabels.push({ symbolId: fromId, scope, labels: doc.labels });
|
|
193
262
|
}
|
|
194
263
|
fileSymbols.set(rel, idsInFile);
|
|
195
264
|
fileSymbolIndexId.set(rel, symbolIndexId);
|
|
@@ -285,6 +354,77 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
285
354
|
}
|
|
286
355
|
}
|
|
287
356
|
}
|
|
357
|
+
// ---- K8s manifest cross-resource references (Phase 1: name-keyed) --------
|
|
358
|
+
// Own resolver, not resolveName(): resolveName() indexes by bare symbol name
|
|
359
|
+
// only, with no concept of Kubernetes kind -- a ConfigMap and a Secret that
|
|
360
|
+
// happen to share a name would incorrectly conflate. Same ambiguity contract
|
|
361
|
+
// as resolveName() though: 0 matches or 2+ matches -> no edge, never guess.
|
|
362
|
+
const kindNameIndex = new Map();
|
|
363
|
+
for (const r of k8sResourceIndex) {
|
|
364
|
+
const key = `${r.scope}:${r.kind}:${r.nameKey}`;
|
|
365
|
+
pushInto(kindNameIndex, key, r.symbolId);
|
|
366
|
+
}
|
|
367
|
+
for (const ref of k8sReferenceCandidates) {
|
|
368
|
+
const candidates = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
|
|
369
|
+
if (candidates.length !== 1)
|
|
370
|
+
continue; // 0 or 2+ -> ambiguous or absent, don't guess
|
|
371
|
+
const toId = candidates[0];
|
|
372
|
+
if (toId === ref.fromSymbolId)
|
|
373
|
+
continue;
|
|
374
|
+
addEdge({
|
|
375
|
+
schema: "hunch.edge/1",
|
|
376
|
+
id: edgeId(ref.fromSymbolId, toId, "references"),
|
|
377
|
+
from: ref.fromSymbolId, to: toId, type: "references",
|
|
378
|
+
reason: ref.reason, strength: 0.7,
|
|
379
|
+
provenance: extracted(0.7, [ref.scope]),
|
|
380
|
+
environment: null,
|
|
381
|
+
metadata: {},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
// ---- K8s manifest cross-resource references (Phase 2: label-selector) ----
|
|
385
|
+
// Structurally different from Phase 1: no name to look up, a SUBSET match
|
|
386
|
+
// between a Service's selector and a workload's pod-template labels, within
|
|
387
|
+
// the same scope. Only ever fires on LITERAL selector/labels (k8sSelectors/
|
|
388
|
+
// k8sWorkloadLabels are already filtered to literal-only by k8sManifest.ts --
|
|
389
|
+
// a block-form templated value is never guessed at).
|
|
390
|
+
//
|
|
391
|
+
// Deliberately NO ambiguity guard here, unlike Phase 1's "0 or 2+ candidates
|
|
392
|
+
// -> no edge": a Service legitimately fronting multiple workloads (blue/green,
|
|
393
|
+
// canary, a shared-label pair of Deployments) is normal, intentional
|
|
394
|
+
// Kubernetes usage, not an ambiguous match to decline -- Phase 1's guard
|
|
395
|
+
// exists because a ConfigMap named X is exactly one resource by definition,
|
|
396
|
+
// which has no analogue here. Fan-out is the correct behavior, not a gap.
|
|
397
|
+
const selectorsByScope = new Map();
|
|
398
|
+
for (const s of k8sSelectors)
|
|
399
|
+
pushInto(selectorsByScope, s.scope, s);
|
|
400
|
+
const labelsByScope = new Map();
|
|
401
|
+
for (const l of k8sWorkloadLabels)
|
|
402
|
+
pushInto(labelsByScope, l.scope, l);
|
|
403
|
+
for (const [scope, selectors] of selectorsByScope) {
|
|
404
|
+
const workloads = labelsByScope.get(scope) ?? [];
|
|
405
|
+
for (const svc of selectors) {
|
|
406
|
+
for (const wl of workloads) {
|
|
407
|
+
// Defensive, not currently reachable: k8sSelectors only ever holds
|
|
408
|
+
// Service symbols and k8sWorkloadLabels only ever holds symbols for
|
|
409
|
+
// kinds in LABELS_PATH_BY_KIND, which excludes Service -- so the two
|
|
410
|
+
// ids can never collide today.
|
|
411
|
+
if (svc.symbolId === wl.symbolId)
|
|
412
|
+
continue;
|
|
413
|
+
const isSubset = Object.entries(svc.selector).every(([k, v]) => wl.labels[k] === v);
|
|
414
|
+
if (!isSubset)
|
|
415
|
+
continue;
|
|
416
|
+
addEdge({
|
|
417
|
+
schema: "hunch.edge/1",
|
|
418
|
+
id: edgeId(svc.symbolId, wl.symbolId, "references"),
|
|
419
|
+
from: svc.symbolId, to: wl.symbolId, type: "references",
|
|
420
|
+
reason: "Service selector matches workload pod-template labels", strength: 0.6,
|
|
421
|
+
provenance: extracted(0.6, [scope]),
|
|
422
|
+
environment: null,
|
|
423
|
+
metadata: {},
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
288
428
|
// PHP's static type relationships use the same symbol graph and conservative
|
|
289
429
|
// resolver as calls. Ambiguous or dynamic targets produce no edge.
|
|
290
430
|
for (const { file, bySym } of perFileRelations) {
|
|
@@ -441,6 +581,16 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
441
581
|
return scan.result;
|
|
442
582
|
}
|
|
443
583
|
// ---- helpers --------------------------------------------------------------
|
|
584
|
+
/** Append `value` to the array at `key`, creating the array on first use.
|
|
585
|
+
* Function declaration (not `const`) so it's usable from pass-1 code above
|
|
586
|
+
* this section via hoisting, without reordering. */
|
|
587
|
+
function pushInto(map, key, value) {
|
|
588
|
+
const bucket = map.get(key);
|
|
589
|
+
if (bucket)
|
|
590
|
+
bucket.push(value);
|
|
591
|
+
else
|
|
592
|
+
map.set(key, [value]);
|
|
593
|
+
}
|
|
444
594
|
/** Nearest-ancestor Chart.yaml lookup, memoized per directory: walks a file's
|
|
445
595
|
* own directory upward through the tracked-file set until it finds
|
|
446
596
|
* `<dir>/Chart.yaml`, or returns null if the file isn't under any chart.
|
|
@@ -449,8 +599,9 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
449
599
|
* approximation, not full Helm semantics: Helm's template namespace is
|
|
450
600
|
* actually release-global, so a parent chart can legitimately include a
|
|
451
601
|
* subchart's define — nearest-ancestor scoping will miss that edge rather
|
|
452
|
-
* than fabricate a wrong one
|
|
453
|
-
*
|
|
602
|
+
* than fabricate a wrong one (test coverage: indexer.test.ts's nested-subchart
|
|
603
|
+
* and subchart-miss cases). Modeling the release-global namespace itself
|
|
604
|
+
* remains open — issue #42. */
|
|
454
605
|
function nearestChartRoot(rels) {
|
|
455
606
|
const tracked = new Set(rels);
|
|
456
607
|
const cache = new Map();
|
|
@@ -466,6 +617,19 @@ function nearestChartRoot(rels) {
|
|
|
466
617
|
};
|
|
467
618
|
return (file) => resolveDir(file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "");
|
|
468
619
|
}
|
|
620
|
+
/** Human-readable text for a name/kind field -- a literal value as-is, or a
|
|
621
|
+
* template's exact raw `{{ }}` source text (never evaluated). Used for
|
|
622
|
+
* display (symbol names, edge reasons); NOT for resolution-key equality --
|
|
623
|
+
* see nameKeyText below for that. */
|
|
624
|
+
function displayNameText(ref) {
|
|
625
|
+
return ref.form === "literal" ? ref.value : ref.sourceText;
|
|
626
|
+
}
|
|
627
|
+
/** Normalized resolution-key text for a name/kind field: a literal value or a
|
|
628
|
+
* template's exact raw source text, EACH PREFIXED so a literal "foo" can
|
|
629
|
+
* never collide with a template whose source text happens to read "foo". */
|
|
630
|
+
function nameKeyText(ref) {
|
|
631
|
+
return ref.form === "literal" ? `L:${ref.value}` : `T:${ref.sourceText}`;
|
|
632
|
+
}
|
|
469
633
|
/** Resolve a callee name to a symbol id: prefer same-file, otherwise require a
|
|
470
634
|
* unique symbol in a statically imported local file. A unique repository-wide
|
|
471
635
|
* name is not evidence of a binding: callback parameters and built-ins often
|