@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.
Files changed (54) hide show
  1. package/dist/cli/index.js +355 -55
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/cli/serve.js +1 -0
  4. package/dist/client/readOrCompute.d.ts +77 -0
  5. package/dist/client/readOrCompute.js +85 -0
  6. package/dist/client/state.d.ts +1 -0
  7. package/dist/client/state.js +1 -0
  8. package/dist/constitution/g2.d.ts +1 -0
  9. package/dist/constitution/service.js +8 -0
  10. package/dist/constitution/sourceMutation.js +23 -18
  11. package/dist/core/agenthook.d.ts +14 -0
  12. package/dist/core/agenthook.js +48 -5
  13. package/dist/core/changeProof.js +5 -1
  14. package/dist/core/checkreport.d.ts +7 -0
  15. package/dist/core/checkreport.js +20 -3
  16. package/dist/core/compare.js +3 -2
  17. package/dist/core/config.d.ts +16 -0
  18. package/dist/core/config.js +13 -0
  19. package/dist/core/machine.d.ts +20 -0
  20. package/dist/core/machine.js +101 -0
  21. package/dist/core/taskReportEvidence.js +6 -6
  22. package/dist/core/types.d.ts +67 -1
  23. package/dist/core/types.js +3 -0
  24. package/dist/core/workspace.d.ts +256 -0
  25. package/dist/core/workspace.js +359 -0
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/helm.d.ts +17 -28
  31. package/dist/extractors/helm.js +12 -12
  32. package/dist/extractors/indexer.js +171 -7
  33. package/dist/extractors/k8sManifest.d.ts +59 -0
  34. package/dist/extractors/k8sManifest.js +507 -0
  35. package/dist/extractors/workspaces.d.ts +28 -0
  36. package/dist/extractors/workspaces.js +427 -0
  37. package/dist/integrations/claudemd.js +1 -0
  38. package/dist/integrations/gitignore.d.ts +27 -2
  39. package/dist/integrations/gitignore.js +103 -17
  40. package/dist/integrations/hooks.d.ts +63 -7
  41. package/dist/integrations/hooks.js +350 -38
  42. package/dist/integrations/scaffold.js +11 -0
  43. package/dist/integrations/workspaceLedger.d.ts +93 -0
  44. package/dist/integrations/workspaceLedger.js +307 -0
  45. package/dist/mcp/server.js +59 -5
  46. package/dist/serve/app.d.ts +2 -0
  47. package/dist/serve/app.js +107 -92
  48. package/dist/serve/mcpHttp.d.ts +27 -0
  49. package/dist/serve/mcpHttp.js +95 -0
  50. package/dist/store/hunchStore.d.ts +4 -2
  51. package/dist/store/hunchStore.js +23 -6
  52. package/dist/store/stateBinding.js +83 -35
  53. package/package.json +1 -1
  54. package/server.json +2 -2
@@ -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
- const out = gitSafe(["-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
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
- /** The unified diff for a commit, truncated to keep synthesis prompts bounded.
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
- export function commitDiff(sha, cwd, maxBytes = 60_000) {
2309
- const out = gitSafe(["show", sha, "--no-color", "--format=", "--unified=2", "--", ...DIFF_NOISE], cwd);
2310
- return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
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
- const out = gitSafe(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
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 = gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
2449
- const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
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
- const out = gitSafe(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
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
- /** The PR's unified diff vs `base` (3-dot), for the Regression Guard's structural
2471
- * analysis. Same noise-exclusion + truncation budget as commit/staged diffs. */
2472
- export function rangeDiff(base, cwd, head = "HEAD", maxBytes = 60_000) {
2473
- const out = gitSafe(["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", `${base}...${head}`, "--", ...DIFF_NOISE], cwd);
2474
- return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
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
- /** Unified diff of the staged changes (for the Regression Guard's structural
2477
- * analysis). Excludes machine-generated noise and truncates at the SAME budget as
2478
- * commitDiff, so the staged and `--commit` guard paths can't diverge on big diffs. */
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 complete local working tree vs HEAD. Git's normal diff
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 symbols.
2486
- * Binary/unreadable files remain in workingFiles (scope checks still apply) but
2487
- * intentionally contribute no synthetic content to regression analysis. */
2488
- export function workingDiff(cwd, maxBytes = 60_000) {
2489
- let out = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
2490
- const tracked = new Set(gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
2491
- const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
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
- const text = readWorkingFile(file);
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 { /* unreadable / directory / binary: scope-only is still safe */ }
2505
- }
2506
- return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
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
- * Deterministic text scan for Helm's `define`/`include`/`template` Go-template
3
- * actions — NOT a Go-template parser, and NOT a tree-sitter query. tree-sitter-yaml
4
- * has no notion of `{{ }}` content: parsing `{{ include "x" . }}` inside a real
5
- * YAML mapping value produces only stray `{` flow-mapping-open tokens, with the
6
- * enclosed text lost to ERROR recovery (verified directly against this repo's
7
- * tree-sitter-yaml bundle). There is nothing for a tree-sitter query to capture,
8
- * so this runs as a sidecar text scan over the raw source — invoked only for
9
- * files under a Helm chart (indexer.ts's chart-root detection), never for
10
- * arbitrary YAML.
11
- *
12
- * Known bounded limitation: this is a token scan, not a full parser. A literal
13
- * `}}` inside a quoted argument, or an include/define-shaped string inside a
14
- * `{{/* comment *}}`, can misattribute a byte range or produce a phantom call.
15
- * Both are bounded failure modes (a stray reference to a real symbol name, or a
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: ParsedSymbol[];
29
- calls: ParsedCall[];
17
+ symbols: HelmSymbol[];
18
+ calls: HelmCall[];
30
19
  }
31
20
  export declare function extractHelmDirectives(source: string): HelmExtraction;
@@ -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 byte range or produce a phantom call.
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 byte offset of
43
- // `raw`'s first character within `source`, needed for call-site atByte math.
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 endByte = m.index + m[0].length;
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, startByte: m.index });
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({ startByte: m.index });
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
- startByte: open.startByte,
64
- endByte,
65
- loc: source.slice(open.startByte, endByte).split("\n").length,
66
- bodyText: source.slice(open.startByte, endByte).slice(0, MAX_BODY_TEXT_CHARS),
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 atByte = innerStart + call.index;
72
- calls.push({ callee: call[1], atByte, endByte: atByte + call[0].length, member: false });
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.get(chartRoot) ?? chartFiles.set(chartRoot, []).get(chartRoot)).push(path);
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
- parsed.symbols = [...parsed.symbols, ...helm.symbols].sort((a, b) => a.startByte - b.startByte);
169
- parsed.calls = [...parsed.calls, ...helm.calls];
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.get(ps.name) ?? nameIndex.set(ps.name, []).get(ps.name)).push(id);
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. No test currently covers the nested
453
- * charts/<sub>/Chart.yaml case — tracked as issue #42. */
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