@davesheffer/hunch 1.46.0 → 1.47.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.
Files changed (56) hide show
  1. package/dist/cli/index.js +151 -321
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/constitution/delta.js +2 -2
  5. package/dist/constitution/sourceMutation.js +28 -18
  6. package/dist/core/commitrepair.d.ts +43 -138
  7. package/dist/core/commitrepair.js +76 -167
  8. package/dist/core/correction.js +9 -9
  9. package/dist/core/docanchors.js +9 -9
  10. package/dist/core/drift.js +22 -27
  11. package/dist/core/escalations.d.ts +16 -58
  12. package/dist/core/escalations.js +39 -121
  13. package/dist/core/glob.d.ts +7 -7
  14. package/dist/core/glob.js +12 -11
  15. package/dist/core/groundingLag.d.ts +2 -9
  16. package/dist/core/groundingLag.js +5 -11
  17. package/dist/core/groundingMerge.d.ts +2 -7
  18. package/dist/core/groundingMerge.js +35 -54
  19. package/dist/core/paths.d.ts +17 -24
  20. package/dist/core/paths.js +37 -43
  21. package/dist/core/refrepair.d.ts +2 -1
  22. package/dist/core/refrepair.js +4 -4
  23. package/dist/core/repairqueue.d.ts +17 -34
  24. package/dist/core/repairqueue.js +48 -98
  25. package/dist/core/types.d.ts +3 -3
  26. package/dist/core/types.js +1 -1
  27. package/dist/extractors/diff.d.ts +4 -4
  28. package/dist/extractors/diff.js +25 -29
  29. package/dist/extractors/git.d.ts +8 -23
  30. package/dist/extractors/git.js +45 -64
  31. package/dist/extractors/helm.d.ts +10 -0
  32. package/dist/extractors/helm.js +43 -59
  33. package/dist/extractors/indexer.d.ts +6 -0
  34. package/dist/extractors/indexer.js +126 -178
  35. package/dist/extractors/k8sManifest.d.ts +23 -80
  36. package/dist/extractors/k8sManifest.js +319 -690
  37. package/dist/extractors/languages.d.ts +28 -37
  38. package/dist/extractors/languages.js +55 -54
  39. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  40. package/dist/extractors/nativeTreeSitter.js +17 -14
  41. package/dist/extractors/parse.d.ts +7 -13
  42. package/dist/extractors/parse.js +39 -51
  43. package/dist/integrations/claudemd.js +1 -0
  44. package/dist/integrations/gitignore.js +3 -5
  45. package/dist/integrations/hooks.d.ts +9 -14
  46. package/dist/integrations/hooks.js +19 -28
  47. package/dist/integrations/mergeDriver.js +13 -14
  48. package/dist/mcp/server.d.ts +4 -5
  49. package/dist/mcp/server.js +60 -73
  50. package/dist/store/hunchStore.js +12 -18
  51. package/dist/synthesis/provider.d.ts +19 -21
  52. package/dist/synthesis/provider.js +67 -76
  53. package/dist/synthesis/synthesize.d.ts +8 -8
  54. package/dist/synthesis/synthesize.js +32 -49
  55. package/package.json +2 -2
  56. package/server.json +2 -2
@@ -861,9 +861,6 @@ export function headFileContent(root, rel) {
861
861
  encoding: "utf8",
862
862
  env: foreignRepoEnv(process.env),
863
863
  maxBuffer: 16 * 1024 * 1024,
864
- // An untracked/absent-at-HEAD path is an expected, silently-handled case
865
- // (falls through to the catch below) — don't let git's "fatal: path ...
866
- // does not exist in 'HEAD'" leak onto the caller's stderr for it.
867
864
  stdio: ["ignore", "pipe", "ignore"],
868
865
  });
869
866
  }
@@ -940,10 +937,7 @@ function isDerivedStoreArtifact(relativeName) {
940
937
  // staged alongside the record; treating it as a violation made every served write skip
941
938
  // the commit quietly and report durability "local" forever (1.26.0/1.26.1).
942
939
  || relativeName === "write.lock"
943
- // The post-merge hook's detected-but-unconfirmed repair queue and its
944
- // rejected-match tombstones (repairqueue.ts): clone-local scratch, never a
945
- // memory record — must never ride a public flush's `git add .` or an
946
- // overlay's force-add allowlist into shared/pushed memory.
940
+ // Clone-local scratch that must never ride a public flush or an overlay force-add into shared memory.
947
941
  || relativeName === "pending-commit-repairs.json"
948
942
  || relativeName === "dropped-commit-repairs.json";
949
943
  }
@@ -2653,83 +2647,70 @@ export function commitChanges(sha, cwd) {
2653
2647
  }
2654
2648
  return out;
2655
2649
  }
2656
- /** Default cap on how many commits mergeRangeChanges will inspect. A `git
2657
- * pull` bringing in an unusually large number of commits (hundreds) would
2658
- * otherwise spawn one `diff-tree` per commit with no bound — cheap insurance
2659
- * against an unbounded background hook run, not a hard product limit. */
2660
- const DEFAULT_MERGE_RANGE_CAP = 500;
2661
- /** Every commit newly reachable in `oldRef..newRef` (capped to the most
2662
- * recent `maxCount`), with the files each one changed (rename-aware, via
2663
- * commitChanges). Used to find the commit a squash-merge produced from a
2664
- * set of now-orphaned source-branch commits. */
2665
- export function mergeRangeChanges(oldRef, newRef, cwd, maxCount = DEFAULT_MERGE_RANGE_CAP) {
2666
- const shas = gitSafe(["rev-list", "--abbrev-commit", `--max-count=${maxCount}`, `${oldRef}..${newRef}`], cwd).split("\n").filter(Boolean).reverse();
2650
+ /** Default cap on commits the range walk inspects: insurance against an unbounded
2651
+ * background hook run when a pull brings in hundreds of commits. */
2652
+ const DEFAULT_RANGE_CAP = 500;
2653
+ /** Commits in `oldRef..newRef` (most recent `maxCount`, oldest first), each with the
2654
+ * non-deleted paths it touched. Squash-merge provenance repair reads this. A git
2655
+ * failure yields []. */
2656
+ export function mergeRangeChanges(oldRef, newRef, cwd, maxCount = DEFAULT_RANGE_CAP) {
2657
+ const raw = gitSafe(["rev-list", "--abbrev-commit", `--max-count=${maxCount}`, `${oldRef}..${newRef}`], cwd);
2658
+ if (!raw)
2659
+ return [];
2660
+ const shas = raw.split("\n").map((s) => s.trim()).filter(Boolean).reverse();
2667
2661
  return shas.map((sha) => ({
2668
2662
  sha,
2669
- // A deletion is never a sensible repair target — a decision's provenance
2670
- // should never resolve to the commit that removed the file it's about.
2671
- files: commitChanges(sha, cwd).filter((c) => c.status !== "deleted").map((c) => c.after ?? c.before).filter((f) => !!f),
2663
+ files: commitChanges(sha, cwd)
2664
+ .filter((c) => c.status !== "deleted")
2665
+ .map((c) => c.after ?? c.before)
2666
+ .filter((f) => !!f),
2672
2667
  }));
2673
2668
  }
2674
- /** Batched existence check for many commit shas at once — one `git cat-file
2675
- * --batch-check` process instead of one `rev-parse --verify` per sha. Used
2676
- * by drift.ts's default commitResolvable so hunch drift/heal/doctor spawn a
2677
- * single git process regardless of how many decisions carry a commit, not
2678
- * one per decision. Returns the subset of `shas` that resolve to a real
2679
- * commit object, or `null` if the check itself failed to run (not a git
2680
- * repo, git missing, timeout) — distinct from an empty set, which means
2681
- * "checked, and none of them resolve." A caller that collapsed those two
2682
- * would flag every commit as unresolvable on a transient environment error
2683
- * instead of failing open. */
2669
+ const HEX_COMMIT_RE = /^[0-9a-f]{7,64}$/i;
2670
+ /** Which of `shas` resolve to a commit, via one `git cat-file --batch-check`.
2671
+ * Malformed values are dropped before the stdin lines are built (positional mapping).
2672
+ * Returns null when git itself could not run: callers fail open. */
2684
2673
  export function commitsExist(shas, cwd) {
2685
- const resolvable = new Set();
2686
2674
  if (!shas.length)
2687
- return resolvable;
2688
- // Same shape guard as commitRepairStatus. Without it, a malformed value
2689
- // embedding its own newline would count as more input lines than entries
2690
- // in `shas`, shifting every later sha's output line and misclassifying it.
2691
- const valid = shas.filter((sha) => /^[0-9a-f]{7,64}$/i.test(sha));
2675
+ return new Set();
2676
+ const valid = shas.filter((s) => HEX_COMMIT_RE.test(s));
2692
2677
  if (!valid.length)
2693
- return resolvable;
2694
- let raw;
2678
+ return new Set();
2679
+ let out;
2695
2680
  try {
2696
- raw = execFileSync("git", ["-C", cwd, "cat-file", "--batch-check"], {
2697
- encoding: "utf8",
2698
- input: valid.map((sha) => `${sha}^{commit}\n`).join(""),
2699
- stdio: ["pipe", "pipe", "ignore"],
2700
- timeout: 10_000,
2681
+ out = execFileSync("git", ["-C", cwd, "cat-file", "--batch-check"], {
2682
+ input: valid.map((s) => `${s}^{commit}`).join("\n") + "\n",
2683
+ encoding: "utf8", timeout: 10_000, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 16 * 1024 * 1024,
2701
2684
  });
2702
2685
  }
2703
2686
  catch {
2704
2687
  return null;
2705
2688
  }
2706
- const lines = raw.split("\n").filter(Boolean);
2707
- for (let i = 0; i < valid.length && i < lines.length; i++) {
2708
- if (!lines[i].endsWith(" missing"))
2709
- resolvable.add(valid[i]);
2710
- }
2711
- return resolvable;
2712
- }
2713
- /** Classifies `commit` relative to `ref` for squash-merge repair eligibility.
2714
- * "orphaned" (exists in this repo, but is not an ancestor of `ref`) is the
2715
- * ONLY status planCommitRepair should ever act on. "unresolvable" (the
2716
- * commit doesn't exist here at all) is explicitly out of repair scope —
2717
- * drift.ts's commit-unresolvable kind is what flags that case, because
2718
- * there's no reliable signal left to match a replacement against. "unknown"
2719
- * (the git command itself failed to run — timeout, git missing) must never
2720
- * be treated as repair-eligible; collapsing "false" and "error" into one
2721
- * boolean is exactly the bug this type exists to prevent. */
2689
+ const lines = out.split("\n");
2690
+ const found = new Set();
2691
+ valid.forEach((sha, i) => {
2692
+ if (lines[i] !== undefined && !/\bmissing$/.test(lines[i].trim()))
2693
+ found.add(sha);
2694
+ });
2695
+ return found;
2696
+ }
2697
+ /** Whether `commit` is reachable from `ref`. Never collapses "no" and "error": only
2698
+ * "orphaned" is repair-eligible. A missing or non-directory cwd is "unresolvable". */
2722
2699
  export function commitRepairStatus(commit, ref, cwd) {
2723
- if (!/^[0-9a-f]{7,64}$/i.test(commit))
2700
+ if (!HEX_COMMIT_RE.test(commit))
2724
2701
  return "unresolvable";
2702
+ const opts = { cwd, stdio: "ignore", timeout: 5_000 };
2725
2703
  try {
2726
- execFileSync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${commit}^{commit}`], { stdio: "ignore", timeout: 5_000 });
2704
+ execFileSync("git", ["cat-file", "-e", `${commit}^{commit}`], opts);
2727
2705
  }
2728
2706
  catch (e) {
2729
- return typeof e.status === "number" ? "unresolvable" : "unknown";
2707
+ const err = e;
2708
+ if (typeof err.status === "number" || err.code === "ENOENT" || err.code === "ENOTDIR")
2709
+ return "unresolvable";
2710
+ return "unknown";
2730
2711
  }
2731
2712
  try {
2732
- execFileSync("git", ["-C", cwd, "merge-base", "--is-ancestor", commit, ref], { stdio: "ignore", timeout: 5_000 });
2713
+ execFileSync("git", ["merge-base", "--is-ancestor", commit, ref], opts);
2733
2714
  return "current";
2734
2715
  }
2735
2716
  catch (e) {
@@ -1,15 +1,25 @@
1
1
  import type { ParsedSymbolKind } from "./languages.js";
2
+ /**
3
+ * Deterministic token scan over Helm chart template text. Reports `define`
4
+ * blocks as symbols and `include` / `template` references as calls. This is
5
+ * not a Go-template parser: comments and quoting are deliberately ignored.
6
+ * All offsets are JS string (UTF-16) indices into the source.
7
+ */
2
8
  export interface HelmSymbol {
3
9
  name: string;
4
10
  kind: ParsedSymbolKind;
11
+ /** Index of the first `{` of the opening define action (inclusive). */
5
12
  startChar: number;
13
+ /** Index just past the `}}` of the matching end action (exclusive). */
6
14
  endChar: number;
7
15
  loc: number;
8
16
  bodyText: string;
9
17
  }
10
18
  export interface HelmCall {
11
19
  callee: string;
20
+ /** Index of the first letter of the `include` / `template` keyword. */
12
21
  atChar: number;
22
+ /** Index just past the closing quote of the name (exclusive). */
13
23
  endChar: number;
14
24
  member: boolean;
15
25
  }
@@ -1,76 +1,60 @@
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 char 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
1
  import { MAX_BODY_TEXT_CHARS } from "./parse.js";
27
- // Matches one `{{ ... }}` action, including the `{{-`/`-}}` whitespace-trim
28
- // markers. Non-greedy so a multi-action line matches each action separately.
29
- const ACTION = /\{\{-?([\s\S]*?)-?\}\}/g;
30
- const BLOCK_OPEN = new Set(["if", "range", "with", "define", "block"]);
31
- const NAME_ARG = /^"([^"]*)"/;
32
- // Matched against an action's RAW inner text (not just a leading keyword) so
33
- // `{{ $labels := include "x" . }}` and `{{ if include "x" . }}` are caught,
34
- // not only the standalone `{{ include "x" . }}` form.
35
- const CALL_SITE = /\b(?:include|template)\s+"([^"]+)"/g;
2
+ const OPENERS = new Set(["if", "range", "with", "block"]);
3
+ const CALL_RE = /(?<![A-Za-z0-9_])(?:include|template)\s+"([^"]+)"/g;
36
4
  export function extractHelmDirectives(source) {
37
5
  const symbols = [];
38
6
  const calls = [];
39
7
  const stack = [];
40
- for (const m of source.matchAll(ACTION)) {
41
- const raw = m[1];
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
- const innerStart = m.index + (source[m.index + 2] === "-" ? 3 : 2);
45
- const endChar = m.index + m[0].length;
46
- const body = raw.trim();
47
- const spaceIdx = body.search(/\s/);
48
- const keyword = spaceIdx === -1 ? body : body.slice(0, spaceIdx);
8
+ let pos = 0;
9
+ while (pos < source.length) {
10
+ const open = source.indexOf("{{", pos);
11
+ if (open === -1)
12
+ break;
13
+ const close = source.indexOf("}}", open + 2);
14
+ if (close === -1)
15
+ break;
16
+ const innerStart = source[open + 2] === "-" ? open + 3 : open + 2;
17
+ const innerEnd = close - 1 >= innerStart && source[close - 1] === "-" ? close - 1 : close;
18
+ const inner = innerEnd > innerStart ? source.slice(innerStart, innerEnd) : "";
19
+ const actionEnd = close + 2;
20
+ pos = actionEnd;
21
+ // Calls: any action, any keyword.
22
+ CALL_RE.lastIndex = 0;
23
+ for (let m = CALL_RE.exec(inner); m; m = CALL_RE.exec(inner)) {
24
+ const atChar = innerStart + m.index;
25
+ calls.push({ callee: m[1], atChar, endChar: atChar + m[0].length, member: false });
26
+ }
27
+ const trimmed = inner.trim();
28
+ const ws = trimmed.search(/\s/);
29
+ const keyword = ws === -1 ? trimmed : trimmed.slice(0, ws);
49
30
  if (keyword === "define") {
50
- const name = NAME_ARG.exec(body.slice(spaceIdx + 1).trim())?.[1];
51
- stack.push({ name, startChar: m.index });
31
+ const rest = trimmed.slice(keyword.length).trimStart();
32
+ let name = null;
33
+ if (rest.startsWith('"')) {
34
+ const q = rest.indexOf('"', 1);
35
+ const candidate = q === -1 ? "" : rest.slice(1, q);
36
+ if (candidate.length > 0)
37
+ name = candidate;
38
+ }
39
+ stack.push({ start: open, name });
52
40
  }
53
- else if (BLOCK_OPEN.has(keyword)) {
54
- // if/range/with/block: depth marker only, no symbol on its own.
55
- stack.push({ startChar: m.index });
41
+ else if (OPENERS.has(keyword)) {
42
+ stack.push({ start: open, name: null });
56
43
  }
57
44
  else if (keyword === "end") {
58
- const open = stack.pop();
59
- if (open?.name) {
45
+ const frame = stack.pop();
46
+ if (frame && frame.name !== null) {
47
+ const span = source.slice(frame.start, actionEnd);
60
48
  symbols.push({
61
- name: open.name,
49
+ name: frame.name,
62
50
  kind: "variable",
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),
51
+ startChar: frame.start,
52
+ endChar: actionEnd,
53
+ loc: span.split("\n").length,
54
+ bodyText: span.slice(0, MAX_BODY_TEXT_CHARS),
67
55
  });
68
56
  }
69
57
  }
70
- for (const call of raw.matchAll(CALL_SITE)) {
71
- const atChar = innerStart + call.index;
72
- calls.push({ callee: call[1], atChar, endChar: atChar + call[0].length, member: false });
73
- }
74
58
  }
75
59
  return { symbols, calls };
76
60
  }
@@ -68,3 +68,9 @@ export declare function isExtractorEdge(edge: Edge): boolean;
68
68
  export declare function mergeScannedEdges(stored: Edge[], scanned: Edge[]): Edge[];
69
69
  /** Persist one pure scan into the Git-native source of truth. */
70
70
  export declare function indexRepo(store: HunchStore, root: string, opts?: IndexRepoOptions): IndexResult;
71
+ /** Build a lookup from a tracked file to the root directory of its nearest Helm
72
+ * chart ("" = repo root), or null. A directory is a chart root when
73
+ * `<dir>/Chart.yaml` is tracked; nested subcharts resolve to their OWN root. This
74
+ * is a conservative approximation of Helm's release-global template namespace
75
+ * (issue #42): a parent including a subchart define is missed, never wrongly linked. */
76
+ export declare function nearestChartRoot(tracked: readonly string[]): (file: string) => string | null;