@davesheffer/hunch 1.46.0 → 1.47.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 (78) hide show
  1. package/dist/cli/index.js +280 -358
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/cli/taskReport.d.ts +6 -0
  5. package/dist/cli/taskReport.js +31 -8
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/sourceMutation.js +28 -18
  8. package/dist/core/commitrepair.d.ts +43 -138
  9. package/dist/core/commitrepair.js +76 -167
  10. package/dist/core/correction.d.ts +2 -0
  11. package/dist/core/correction.js +23 -10
  12. package/dist/core/docanchors.js +9 -9
  13. package/dist/core/drift.js +22 -27
  14. package/dist/core/escalations.d.ts +16 -58
  15. package/dist/core/escalations.js +39 -121
  16. package/dist/core/glob.d.ts +7 -7
  17. package/dist/core/glob.js +12 -11
  18. package/dist/core/groundingLag.d.ts +2 -9
  19. package/dist/core/groundingLag.js +5 -11
  20. package/dist/core/groundingMerge.d.ts +2 -7
  21. package/dist/core/groundingMerge.js +35 -54
  22. package/dist/core/paths.d.ts +17 -24
  23. package/dist/core/paths.js +37 -43
  24. package/dist/core/refrepair.d.ts +2 -1
  25. package/dist/core/refrepair.js +4 -4
  26. package/dist/core/repairqueue.d.ts +17 -34
  27. package/dist/core/repairqueue.js +48 -98
  28. package/dist/core/taskRecord.d.ts +1 -1
  29. package/dist/core/taskRecord.js +2 -2
  30. package/dist/core/taskReport.d.ts +8 -3
  31. package/dist/core/taskReport.js +7 -2
  32. package/dist/core/taskReportEvidence.d.ts +7 -0
  33. package/dist/core/taskReportEvidence.js +16 -4
  34. package/dist/core/taskReportHook.js +2 -2
  35. package/dist/core/taskReportPublic.js +2 -2
  36. package/dist/core/taskReportRender.js +2 -2
  37. package/dist/core/types.d.ts +3 -3
  38. package/dist/core/types.js +1 -1
  39. package/dist/extractors/diff.d.ts +4 -4
  40. package/dist/extractors/diff.js +25 -29
  41. package/dist/extractors/git.d.ts +18 -23
  42. package/dist/extractors/git.js +72 -64
  43. package/dist/extractors/helm.d.ts +10 -0
  44. package/dist/extractors/helm.js +43 -59
  45. package/dist/extractors/indexer.d.ts +6 -0
  46. package/dist/extractors/indexer.js +126 -178
  47. package/dist/extractors/k8sManifest.d.ts +23 -80
  48. package/dist/extractors/k8sManifest.js +319 -690
  49. package/dist/extractors/languages.d.ts +28 -37
  50. package/dist/extractors/languages.js +55 -54
  51. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  52. package/dist/extractors/nativeTreeSitter.js +17 -14
  53. package/dist/extractors/parse.d.ts +7 -13
  54. package/dist/extractors/parse.js +39 -51
  55. package/dist/integrations/claudemd.js +1 -0
  56. package/dist/integrations/gitignore.js +3 -5
  57. package/dist/integrations/health.js +1 -1
  58. package/dist/integrations/hooks.d.ts +9 -14
  59. package/dist/integrations/hooks.js +19 -28
  60. package/dist/integrations/mergeDriver.js +13 -14
  61. package/dist/integrations/probe.d.ts +11 -0
  62. package/dist/integrations/probe.js +29 -4
  63. package/dist/integrations/sync.d.ts +16 -4
  64. package/dist/integrations/sync.js +54 -3
  65. package/dist/integrations/workspaceLedger.d.ts +1 -1
  66. package/dist/mcp/server.d.ts +4 -5
  67. package/dist/mcp/server.js +71 -83
  68. package/dist/mcp/taskReportTools.js +4 -4
  69. package/dist/serve/app.js +2 -2
  70. package/dist/store/hunchStore.js +12 -18
  71. package/dist/synthesis/provider.d.ts +19 -21
  72. package/dist/synthesis/provider.js +67 -76
  73. package/dist/synthesis/synthesize.d.ts +8 -8
  74. package/dist/synthesis/synthesize.js +32 -49
  75. package/dist/taskReports.d.ts +1 -0
  76. package/dist/taskReports.js +2 -2
  77. package/package.json +2 -2
  78. package/server.json +2 -2
@@ -111,12 +111,12 @@ export type Resource = z.infer<typeof ResourceSchema>;
111
111
  export declare const EdgeType: z.ZodEnum<{
112
112
  depends_on: "depends_on";
113
113
  calls: "calls";
114
+ references: "references";
114
115
  imports: "imports";
115
116
  contains: "contains";
116
117
  implements: "implements";
117
118
  supersedes: "supersedes";
118
119
  related_to: "related_to";
119
- references: "references";
120
120
  provides: "provides";
121
121
  belongs_to: "belongs_to";
122
122
  implemented_by: "implemented_by";
@@ -173,12 +173,12 @@ export declare const EdgeSchema: z.ZodObject<{
173
173
  type: z.ZodEnum<{
174
174
  depends_on: "depends_on";
175
175
  calls: "calls";
176
+ references: "references";
176
177
  imports: "imports";
177
178
  contains: "contains";
178
179
  implements: "implements";
179
180
  supersedes: "supersedes";
180
181
  related_to: "related_to";
181
- references: "references";
182
182
  provides: "provides";
183
183
  belongs_to: "belongs_to";
184
184
  implemented_by: "implemented_by";
@@ -783,12 +783,12 @@ export declare const SCHEMAS: {
783
783
  type: z.ZodEnum<{
784
784
  depends_on: "depends_on";
785
785
  calls: "calls";
786
+ references: "references";
786
787
  imports: "imports";
787
788
  contains: "contains";
788
789
  implements: "implements";
789
790
  supersedes: "supersedes";
790
791
  related_to: "related_to";
791
- references: "references";
792
792
  provides: "provides";
793
793
  belongs_to: "belongs_to";
794
794
  implemented_by: "implemented_by";
@@ -147,12 +147,12 @@ export const ResourceSchema = z.object({
147
147
  export const EdgeType = z.enum([
148
148
  "depends_on",
149
149
  "calls",
150
+ "references",
150
151
  "imports",
151
152
  "contains",
152
153
  "implements",
153
154
  "supersedes",
154
155
  "related_to",
155
- "references",
156
156
  "provides",
157
157
  "belongs_to",
158
158
  "implemented_by",
@@ -29,10 +29,10 @@ export interface DiffAnalysis {
29
29
  removedDeps: string[];
30
30
  addedLines: number;
31
31
  removedLines: number;
32
- /** Added line bodies (the "+" content, marker stripped) per file — every file,
33
- * not just substantive ones. The text veto's symbol/pattern tiers match against
34
- * — call sites, not just declarations, which addedSymbols can't see. Keyed by
35
- * the same (new-path) key as perFile. */
32
+ /** Added line bodies (content after the `+`) per file, for EVERY file, not only
33
+ * substantive ones. The text veto's symbol/pattern tiers match call sites, which
34
+ * `addedSymbols` cannot see. Keyed by the same new-side path as the per-file
35
+ * declarations map. */
36
36
  addedLinesByFile: Map<string, string[]>;
37
37
  }
38
38
  /** Undo git's C-style path quoting. Even with core.quotePath=false, git quotes a
@@ -20,25 +20,21 @@ const DECL_PATTERNS = [
20
20
  { kind: "type", re: /^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*[=<]/ },
21
21
  { kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/ },
22
22
  { kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?function/ },
23
+ // No Python class pattern: the generic class pattern above already matches a
24
+ // Python class header, and the first match wins.
23
25
  { kind: "function", re: /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/ },
24
- // No Python-specific class pattern needed: the generic TS `class` pattern above has no
25
- // trailing-syntax requirement (no `{`/`:`), so it already matches Python's
26
- // `class Foo(Bar):` header too, and — since declOf() returns on the first match —
27
- // always wins for Python class lines before any Python-specific pattern would run.
28
26
  ];
29
27
  import { languageFor, isSubstantive } from "./languages.js";
30
28
  const IMPORT_RE = /^\s*import\s+(?:[^'"]*from\s+)?['"]([^'"]+)['"]/;
31
29
  const CONT_IMPORT_RE = /^\s*\}?\s*from\s+['"]([^'"]+)['"]/; // multi-line: "} from 'x'"
32
30
  const REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/;
33
31
  const PHP_REQUIRE_RE = /\b(?:require|require_once|include|include_once)\s*(?:\(\s*)?['"]([^'"]+)['"]/;
34
- // "import os" / "import a.b.c" / "import os as o" / "import os, sys" / trailing "# comment".
35
- // Anchored to the END of the line (optional "as alias", comma-separated modules, comment)
36
- // so it matches a COMPLETE Python import statement only — this deliberately rejects
37
- // TypeScript's `import Foo = Bar.Baz;` (import-equals), which would otherwise falsely
38
- // look like a Python "import Foo" prefix match.
39
- const PY_IMPORT_RE = /^\s*import\s+([A-Za-z_][\w.]*)(?:\s+as\s+\w+)?(?:\s*,\s*[A-Za-z_][\w.]*(?:\s+as\s+\w+)?)*\s*(?:#.*)?$/;
40
- const PY_FROM_IMPORT_RE = /^\s*from\s+([.\w]+)\s+import\s+/; // "from os import path" / "from . import x"
41
- const isCode = (p) => !!p && languageFor(p) !== null;
32
+ // A COMPLETE Python import statement: comma-separated dotted modules, each with an
33
+ // optional alias, an optional trailing comment, and nothing else. The anchoring is
34
+ // what rejects TypeScript's `import Foo = Bar.Baz;`.
35
+ const PY_IMPORT_RE = /^\s*import\s+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)(?:\s+as\s+\w+)?(?:\s*,\s*[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\s+as\s+\w+)?)*\s*(?:#.*)?$/;
36
+ const PY_FROM_RE = /^\s*from\s+(\.*[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*|\.+)\s+import\b/;
37
+ const isCode = (p) => p.length > 0 && languageFor(p) !== null;
42
38
  function declOf(line) {
43
39
  for (const { kind, re } of DECL_PATTERNS) {
44
40
  const m = re.exec(line);
@@ -52,7 +48,7 @@ function importOf(line) {
52
48
  CONT_IMPORT_RE.exec(line) ??
53
49
  REQUIRE_RE.exec(line) ??
54
50
  PHP_REQUIRE_RE.exec(line) ??
55
- PY_FROM_IMPORT_RE.exec(line) ??
51
+ PY_FROM_RE.exec(line) ??
56
52
  PY_IMPORT_RE.exec(line);
57
53
  return m ? m[1] : null;
58
54
  }
@@ -280,15 +276,15 @@ export function analyzeDiff(diff) {
280
276
  // ---- inside a hunk: content lines ----
281
277
  if (raw.startsWith("+")) {
282
278
  const body = raw.slice(1);
283
- // Raw added lines are captured for EVERY file, before the substantive gate:
279
+ // Raw added lines are captured for every file, before the substantive gate:
284
280
  // content-matched constraints and Veto tripwires are not code-only rules
285
281
  // (a blocking invariant legitimately scopes .github/workflows/**, *.sql,
286
282
  // Dockerfile). Skipping them here left `scopedAdded` empty, which
287
283
  // buildCheckReport reads as "cannot prove a violation ⇒ complies" — so the
288
284
  // pre-edit hook denied the edit while `hunch check --strict` passed the
289
- // very commit that landed it. The churn counters below now also include
290
- // prose (isSubstantive, issue #12); symbol/import extraction stays
291
- // code-only (isCode/languageFor) since declarations are a code concept.
285
+ // same diff. Churn counters below now include prose (isSubstantive, issue
286
+ // #12), while symbol/import extraction stays code-only (isCode): a
287
+ // declaration is a code concept.
292
288
  let lines = addedLinesBy.get(curFile);
293
289
  if (!lines) {
294
290
  lines = [];
@@ -301,12 +297,12 @@ export function analyzeDiff(diff) {
301
297
  if (!curAdded && !curDeleted)
302
298
  filesModified.add(curFile);
303
299
  if (isCode(curFile)) {
304
- const d = declOf(body);
305
- if (d)
306
- declsFor(curFile)?.added.set(d.name, d);
307
- const imp = importOf(body);
308
- if (imp && !imp.startsWith("."))
309
- addedImports.add(imp);
300
+ const decl = declOf(body);
301
+ if (decl)
302
+ declsFor(curFile).added.set(decl.name, decl);
303
+ const spec = importOf(body);
304
+ if (spec && !spec.startsWith("."))
305
+ addedImports.add(spec);
310
306
  }
311
307
  }
312
308
  else if (raw.startsWith("-")) {
@@ -317,12 +313,12 @@ export function analyzeDiff(diff) {
317
313
  filesModified.add(curFile);
318
314
  if (isCode(curFile)) {
319
315
  const body = raw.slice(1);
320
- const d = declOf(body);
321
- if (d)
322
- declsFor(curFile)?.removed.set(d.name, d);
323
- const imp = importOf(body);
324
- if (imp && !imp.startsWith("."))
325
- removedImports.add(imp);
316
+ const decl = declOf(body);
317
+ if (decl)
318
+ declsFor(curFile).removed.set(decl.name, decl);
319
+ const spec = importOf(body);
320
+ if (spec && !spec.startsWith("."))
321
+ removedImports.add(spec);
326
322
  }
327
323
  }
328
324
  }
@@ -266,6 +266,16 @@ export declare function pathKnownToHistory(root: string, file: string): boolean;
266
266
  /** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
267
267
  * Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
268
268
  export declare function currentBranch(cwd: string): string;
269
+ /** Whether `cwd` has its repository's default branch checked out: origin/HEAD's branch
270
+ * when the remote names one, else `main` or `master`. False on a detached HEAD or
271
+ * outside a repository, so a caller keeps its pre-existing behavior there. */
272
+ export declare function onDefaultBranch(cwd: string): boolean;
273
+ /** Whether a memory commit of `hunchDir` would have anything to stage: a tracked file git
274
+ * sees as changed (after its own line-ending normalization) or an untracked, non-ignored
275
+ * file, skipping the derived artifacts and the per-machine local.json the commit never
276
+ * stages. A hook's inherited GIT_DIR/GIT_WORK_TREE is stripped, so the repository is
277
+ * resolved from `hunchDir` itself. False when git cannot answer. */
278
+ export declare function hunchDirHasChanges(hunchDir: string): boolean;
269
279
  /** Whether HEAD is somewhere a write must not land: mid rebase/merge/cherry-pick/revert/bisect
270
280
  * ("git-operation-in-progress", which wins when both hold), or detached ("detached-head").
271
281
  * null when HEAD is a settled branch — and also when git cannot answer at all (not a repo,
@@ -307,32 +317,17 @@ export interface CommitCandidate {
307
317
  sha: string;
308
318
  files: string[];
309
319
  }
310
- /** Every commit newly reachable in `oldRef..newRef` (capped to the most
311
- * recent `maxCount`), with the files each one changed (rename-aware, via
312
- * commitChanges). Used to find the commit a squash-merge produced from a
313
- * set of now-orphaned source-branch commits. */
320
+ /** Commits in `oldRef..newRef` (most recent `maxCount`, oldest first), each with the
321
+ * non-deleted paths it touched. Squash-merge provenance repair reads this. A git
322
+ * failure yields []. */
314
323
  export declare function mergeRangeChanges(oldRef: string, newRef: string, cwd: string, maxCount?: number): CommitCandidate[];
315
- /** Batched existence check for many commit shas at once — one `git cat-file
316
- * --batch-check` process instead of one `rev-parse --verify` per sha. Used
317
- * by drift.ts's default commitResolvable so hunch drift/heal/doctor spawn a
318
- * single git process regardless of how many decisions carry a commit, not
319
- * one per decision. Returns the subset of `shas` that resolve to a real
320
- * commit object, or `null` if the check itself failed to run (not a git
321
- * repo, git missing, timeout) — distinct from an empty set, which means
322
- * "checked, and none of them resolve." A caller that collapsed those two
323
- * would flag every commit as unresolvable on a transient environment error
324
- * instead of failing open. */
324
+ /** Which of `shas` resolve to a commit, via one `git cat-file --batch-check`.
325
+ * Malformed values are dropped before the stdin lines are built (positional mapping).
326
+ * Returns null when git itself could not run: callers fail open. */
325
327
  export declare function commitsExist(shas: readonly string[], cwd: string): Set<string> | null;
326
328
  export type CommitRepairStatus = "orphaned" | "current" | "unresolvable" | "unknown";
327
- /** Classifies `commit` relative to `ref` for squash-merge repair eligibility.
328
- * "orphaned" (exists in this repo, but is not an ancestor of `ref`) is the
329
- * ONLY status planCommitRepair should ever act on. "unresolvable" (the
330
- * commit doesn't exist here at all) is explicitly out of repair scope —
331
- * drift.ts's commit-unresolvable kind is what flags that case, because
332
- * there's no reliable signal left to match a replacement against. "unknown"
333
- * (the git command itself failed to run — timeout, git missing) must never
334
- * be treated as repair-eligible; collapsing "false" and "error" into one
335
- * boolean is exactly the bug this type exists to prevent. */
329
+ /** Whether `commit` is reachable from `ref`. Never collapses "no" and "error": only
330
+ * "orphaned" is repair-eligible. A missing or non-directory cwd is "unresolvable". */
336
331
  export declare function commitRepairStatus(commit: string, ref: string, cwd: string): CommitRepairStatus;
337
332
  /** commitRepairStatus against several tips: "orphaned" only when NO ref
338
333
  * reaches the commit. A historical `--range old..new` must not make a commit
@@ -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
  }
@@ -2289,6 +2283,33 @@ export function currentBranch(cwd) {
2289
2283
  const b = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
2290
2284
  return b === "HEAD" ? "" : b; // detached HEAD reports "HEAD" — treat as no branch
2291
2285
  }
2286
+ /** Whether `cwd` has its repository's default branch checked out: origin/HEAD's branch
2287
+ * when the remote names one, else `main` or `master`. False on a detached HEAD or
2288
+ * outside a repository, so a caller keeps its pre-existing behavior there. */
2289
+ export function onDefaultBranch(cwd) {
2290
+ const branch = currentBranch(cwd);
2291
+ if (!branch)
2292
+ return false;
2293
+ const remoteHead = gitSafe(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], cwd);
2294
+ if (remoteHead)
2295
+ return branch === remoteHead.replace(/^origin\//, "");
2296
+ return branch === "main" || branch === "master";
2297
+ }
2298
+ /** Whether a memory commit of `hunchDir` would have anything to stage: a tracked file git
2299
+ * sees as changed (after its own line-ending normalization) or an untracked, non-ignored
2300
+ * file, skipping the derived artifacts and the per-machine local.json the commit never
2301
+ * stages. A hook's inherited GIT_DIR/GIT_WORK_TREE is stripped, so the repository is
2302
+ * resolved from `hunchDir` itself. False when git cannot answer. */
2303
+ export function hunchDirHasChanges(hunchDir) {
2304
+ const changed = [
2305
+ ...gitSafeIsolated(["diff", "--name-only", "--relative", "--no-renames", "HEAD", "--", "."], hunchDir).split("\n"),
2306
+ ...gitSafeIsolated(["ls-files", "--others", "--exclude-standard", "--", "."], hunchDir).split("\n"),
2307
+ ];
2308
+ return changed.some((raw) => {
2309
+ const path = raw.trim().replace(/\\/g, "/");
2310
+ return path !== "" && path !== "local.json" && !isDerivedStoreArtifact(path);
2311
+ });
2312
+ }
2292
2313
  /** The state files git writes while it is replaying history. `git rev-parse --git-path`
2293
2314
  * resolves each against THIS worktree's private git dir (a linked worktree keeps its own
2294
2315
  * rebase-merge/MERGE_HEAD), so a rebase in one worktree never reads as one in another. */
@@ -2653,83 +2674,70 @@ export function commitChanges(sha, cwd) {
2653
2674
  }
2654
2675
  return out;
2655
2676
  }
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();
2677
+ /** Default cap on commits the range walk inspects: insurance against an unbounded
2678
+ * background hook run when a pull brings in hundreds of commits. */
2679
+ const DEFAULT_RANGE_CAP = 500;
2680
+ /** Commits in `oldRef..newRef` (most recent `maxCount`, oldest first), each with the
2681
+ * non-deleted paths it touched. Squash-merge provenance repair reads this. A git
2682
+ * failure yields []. */
2683
+ export function mergeRangeChanges(oldRef, newRef, cwd, maxCount = DEFAULT_RANGE_CAP) {
2684
+ const raw = gitSafe(["rev-list", "--abbrev-commit", `--max-count=${maxCount}`, `${oldRef}..${newRef}`], cwd);
2685
+ if (!raw)
2686
+ return [];
2687
+ const shas = raw.split("\n").map((s) => s.trim()).filter(Boolean).reverse();
2667
2688
  return shas.map((sha) => ({
2668
2689
  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),
2690
+ files: commitChanges(sha, cwd)
2691
+ .filter((c) => c.status !== "deleted")
2692
+ .map((c) => c.after ?? c.before)
2693
+ .filter((f) => !!f),
2672
2694
  }));
2673
2695
  }
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. */
2696
+ const HEX_COMMIT_RE = /^[0-9a-f]{7,64}$/i;
2697
+ /** Which of `shas` resolve to a commit, via one `git cat-file --batch-check`.
2698
+ * Malformed values are dropped before the stdin lines are built (positional mapping).
2699
+ * Returns null when git itself could not run: callers fail open. */
2684
2700
  export function commitsExist(shas, cwd) {
2685
- const resolvable = new Set();
2686
2701
  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));
2702
+ return new Set();
2703
+ const valid = shas.filter((s) => HEX_COMMIT_RE.test(s));
2692
2704
  if (!valid.length)
2693
- return resolvable;
2694
- let raw;
2705
+ return new Set();
2706
+ let out;
2695
2707
  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,
2708
+ out = execFileSync("git", ["-C", cwd, "cat-file", "--batch-check"], {
2709
+ input: valid.map((s) => `${s}^{commit}`).join("\n") + "\n",
2710
+ encoding: "utf8", timeout: 10_000, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 16 * 1024 * 1024,
2701
2711
  });
2702
2712
  }
2703
2713
  catch {
2704
2714
  return null;
2705
2715
  }
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. */
2716
+ const lines = out.split("\n");
2717
+ const found = new Set();
2718
+ valid.forEach((sha, i) => {
2719
+ if (lines[i] !== undefined && !/\bmissing$/.test(lines[i].trim()))
2720
+ found.add(sha);
2721
+ });
2722
+ return found;
2723
+ }
2724
+ /** Whether `commit` is reachable from `ref`. Never collapses "no" and "error": only
2725
+ * "orphaned" is repair-eligible. A missing or non-directory cwd is "unresolvable". */
2722
2726
  export function commitRepairStatus(commit, ref, cwd) {
2723
- if (!/^[0-9a-f]{7,64}$/i.test(commit))
2727
+ if (!HEX_COMMIT_RE.test(commit))
2724
2728
  return "unresolvable";
2729
+ const opts = { cwd, stdio: "ignore", timeout: 5_000 };
2725
2730
  try {
2726
- execFileSync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${commit}^{commit}`], { stdio: "ignore", timeout: 5_000 });
2731
+ execFileSync("git", ["cat-file", "-e", `${commit}^{commit}`], opts);
2727
2732
  }
2728
2733
  catch (e) {
2729
- return typeof e.status === "number" ? "unresolvable" : "unknown";
2734
+ const err = e;
2735
+ if (typeof err.status === "number" || err.code === "ENOENT" || err.code === "ENOTDIR")
2736
+ return "unresolvable";
2737
+ return "unknown";
2730
2738
  }
2731
2739
  try {
2732
- execFileSync("git", ["-C", cwd, "merge-base", "--is-ancestor", commit, ref], { stdio: "ignore", timeout: 5_000 });
2740
+ execFileSync("git", ["merge-base", "--is-ancestor", commit, ref], opts);
2733
2741
  return "current";
2734
2742
  }
2735
2743
  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;