@davesheffer/hunch 1.45.2 → 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 (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. package/server.json +2 -2
@@ -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
  }
@@ -307,32 +307,17 @@ export interface CommitCandidate {
307
307
  sha: string;
308
308
  files: string[];
309
309
  }
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. */
310
+ /** Commits in `oldRef..newRef` (most recent `maxCount`, oldest first), each with the
311
+ * non-deleted paths it touched. Squash-merge provenance repair reads this. A git
312
+ * failure yields []. */
314
313
  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. */
314
+ /** Which of `shas` resolve to a commit, via one `git cat-file --batch-check`.
315
+ * Malformed values are dropped before the stdin lines are built (positional mapping).
316
+ * Returns null when git itself could not run: callers fail open. */
325
317
  export declare function commitsExist(shas: readonly string[], cwd: string): Set<string> | null;
326
318
  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. */
319
+ /** Whether `commit` is reachable from `ref`. Never collapses "no" and "error": only
320
+ * "orphaned" is repair-eligible. A missing or non-directory cwd is "unresolvable". */
336
321
  export declare function commitRepairStatus(commit: string, ref: string, cwd: string): CommitRepairStatus;
337
322
  /** commitRepairStatus against several tips: "orphaned" only when NO ref
338
323
  * reaches the commit. A historical `--range old..new` must not make a commit
@@ -7,6 +7,7 @@ import { isAbsolute, resolve, join, basename, dirname, relative, sep, posix } fr
7
7
  import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync, existsSync } from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { MEMLOG_FORMAT } from "../core/memorylog.js";
10
+ import { boundedExecFileSync } from "../core/boundedSpawn.js";
10
11
  import { writeFileAtomic } from "../core/io.js";
11
12
  import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
12
13
  import { createRepoFileReader } from "../core/safeRepoFile.js";
@@ -580,6 +581,8 @@ function observeGit(observer, event) {
580
581
  catch { /* observation cannot change a Git outcome */ }
581
582
  }
582
583
  const CAPTURE_REMOTE_TIMEOUT_MS = 15_000;
584
+ /** A user's whole branch can be large, so `hunch push` gets far longer than a memory capture push. */
585
+ const BRANCH_PUSH_TIMEOUT_MS = 300_000;
583
586
  export const DEFAULT_READ_REMOTE_TIMEOUT_MS = 5_000;
584
587
  // A capture can spend roughly 90s in commit + bounded merge/push/retry seams.
585
588
  // A contending writer waits beyond that proven ceiling, then takes the lock and
@@ -858,9 +861,6 @@ export function headFileContent(root, rel) {
858
861
  encoding: "utf8",
859
862
  env: foreignRepoEnv(process.env),
860
863
  maxBuffer: 16 * 1024 * 1024,
861
- // An untracked/absent-at-HEAD path is an expected, silently-handled case
862
- // (falls through to the catch below) — don't let git's "fatal: path ...
863
- // does not exist in 'HEAD'" leak onto the caller's stderr for it.
864
864
  stdio: ["ignore", "pipe", "ignore"],
865
865
  });
866
866
  }
@@ -937,10 +937,7 @@ function isDerivedStoreArtifact(relativeName) {
937
937
  // staged alongside the record; treating it as a violation made every served write skip
938
938
  // the commit quietly and report durability "local" forever (1.26.0/1.26.1).
939
939
  || relativeName === "write.lock"
940
- // The post-merge hook's detected-but-unconfirmed repair queue and its
941
- // rejected-match tombstones (repairqueue.ts): clone-local scratch, never a
942
- // memory record — must never ride a public flush's `git add .` or an
943
- // 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.
944
941
  || relativeName === "pending-commit-repairs.json"
945
942
  || relativeName === "dropped-commit-repairs.json";
946
943
  }
@@ -1193,16 +1190,13 @@ function contractRemoteHeads(hunchDir, contract, direction, env, timeoutMs) {
1193
1190
  if (!hooksDir)
1194
1191
  return null;
1195
1192
  try {
1196
- const out = execFileSync("git", [
1193
+ // Network git runs under boundedExecFileSync: execFileSync's timeout kills only
1194
+ // the root, orphaning git-remote-https.exe on Windows (issue #498).
1195
+ const out = boundedExecFileSync("git", [
1197
1196
  "-C", hunchDir,
1198
1197
  "-c", `core.hooksPath=${hooksDir}`,
1199
1198
  "ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", url,
1200
- ], {
1201
- encoding: "utf8",
1202
- stdio: ["ignore", "pipe", "pipe"],
1203
- env: boundedTeamEnv(env),
1204
- timeout: timeoutMs,
1205
- });
1199
+ ], { env: boundedTeamEnv(env), timeout: timeoutMs });
1206
1200
  if (!contractReady(contract))
1207
1201
  return null;
1208
1202
  const heads = [];
@@ -1226,12 +1220,12 @@ function fetchContractRef(hunchDir, contract, env, timeoutMs) {
1226
1220
  if (!hooksDir)
1227
1221
  return "";
1228
1222
  try {
1229
- execFileSync("git", [
1223
+ boundedExecFileSync("git", [
1230
1224
  "-C", hunchDir,
1231
1225
  "-c", `core.hooksPath=${hooksDir}`,
1232
1226
  "fetch", "--no-tags", "--no-write-fetch-head", "--upload-pack=git-upload-pack",
1233
1227
  contract.fetchUrl, `+${contract.ref}:${TEAM_FETCH_REF}`,
1234
- ], { stdio: ["ignore", "ignore", "pipe"], env: boundedTeamEnv(env), timeout: timeoutMs });
1228
+ ], { stdout: "ignore", env: boundedTeamEnv(env), timeout: timeoutMs });
1235
1229
  if (!contractReady(contract))
1236
1230
  return "";
1237
1231
  const oid = execFileSync("git", ["-C", hunchDir, "rev-parse", "--verify", TEAM_FETCH_REF], {
@@ -1391,17 +1385,21 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
1391
1385
  const tryGit = (args, timeout = timeoutMs) => {
1392
1386
  const startedAt = Date.now();
1393
1387
  lastPullGitError = undefined;
1388
+ const argv = [
1389
+ "-C", hunchDir,
1390
+ "-c", `core.hooksPath=${hooksDir}`,
1391
+ "-c", `core.attributesFile=${gitNullDevice()}`,
1392
+ "-c", "core.autocrlf=false",
1393
+ "-c", "commit.gpgsign=false",
1394
+ ...args,
1395
+ ];
1394
1396
  try {
1395
- execFileSync("git", [
1396
- "-C", hunchDir,
1397
- "-c", `core.hooksPath=${hooksDir}`,
1398
- "-c", `core.attributesFile=${gitNullDevice()}`,
1399
- "-c", "core.autocrlf=false",
1400
- "-c", "commit.gpgsign=false",
1401
- ...args,
1402
- ], {
1403
- stdio: ["ignore", "ignore", "pipe"], env, timeout,
1404
- });
1397
+ // Only the network call pays for the tree-killing supervisor (#498); the
1398
+ // merge of an already-fetched sha and rev-parse/abort stay local.
1399
+ if (args[0] === "fetch")
1400
+ boundedExecFileSync("git", argv, { stdout: "ignore", env, timeout });
1401
+ else
1402
+ execFileSync("git", argv, { stdio: ["ignore", "ignore", "pipe"], env, timeout });
1405
1403
  return true;
1406
1404
  }
1407
1405
  catch (error) {
@@ -1496,7 +1494,7 @@ function upstreamSha(hunchDir, env) {
1496
1494
  * and discard oversized/unavailable output without changing its exit result. */
1497
1495
  function runObservedPush(args, env, timeoutMs, observe) {
1498
1496
  if (!observe) {
1499
- execFileSync("git", args, { stdio: "ignore", env, timeout: timeoutMs });
1497
+ boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
1500
1498
  return null;
1501
1499
  }
1502
1500
  let directory, descriptor;
@@ -1511,11 +1509,11 @@ function runObservedPush(args, env, timeoutMs, observe) {
1511
1509
  }
1512
1510
  catch { }
1513
1511
  }
1514
- execFileSync("git", args, { stdio: "ignore", env, timeout: timeoutMs });
1512
+ boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
1515
1513
  return null;
1516
1514
  }
1517
1515
  try {
1518
- execFileSync("git", args, { stdio: ["ignore", descriptor, "ignore"], env, timeout: timeoutMs });
1516
+ boundedExecFileSync("git", args, { stdout: descriptor, env, timeout: timeoutMs });
1519
1517
  try {
1520
1518
  const buffer = Buffer.alloc(1_000_001);
1521
1519
  const bytes = readSync(descriptor, buffer, 0, buffer.length, 0);
@@ -1563,8 +1561,8 @@ function observePushResult(hunchDir, env, output, observer) {
1563
1561
  if (!destination || /[\x00-\x1f\x7f]/.test(destination) || Date.now() >= deadline)
1564
1562
  continue;
1565
1563
  try {
1566
- const result = execFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", "--exit-code", "--", destination, ref], {
1567
- env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: Math.max(1, deadline - Date.now()), maxBuffer: 16_384,
1564
+ const result = boundedExecFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", "--exit-code", "--", destination, ref], {
1565
+ env, timeout: Math.max(1, deadline - Date.now()), maxBuffer: 16_384,
1568
1566
  }).trim();
1569
1567
  const rows = result.split("\n").filter(row => row.split("\t")[1] === ref);
1570
1568
  const commitSha = rows.length === 1 ? rows[0].split("\t")[0] : "";
@@ -1656,12 +1654,7 @@ function establishEmptyRemoteUpstream(hunchDir, env, protectedRepoRoot, timeoutM
1656
1654
  if (!before)
1657
1655
  return false;
1658
1656
  try {
1659
- const refs = execFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", remote], {
1660
- encoding: "utf8",
1661
- stdio: ["ignore", "pipe", "ignore"],
1662
- env,
1663
- timeout: timeoutMs,
1664
- }).trim();
1657
+ const refs = boundedExecFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", remote], { env, timeout: timeoutMs }).trim();
1665
1658
  if (refs)
1666
1659
  return false;
1667
1660
  }
@@ -2332,7 +2325,9 @@ export function memoryMoveDiff(sha, root) {
2332
2325
  * false when there is no upstream / offline / not a repo. */
2333
2326
  export function pushCurrentBranch(root) {
2334
2327
  try {
2335
- execFileSync("git", ["-C", root, "push"], { stdio: "ignore" });
2328
+ // Bounded so an unreachable remote can't hang the caller, and a timeout
2329
+ // kills git's whole tree, not just the root (#498).
2330
+ boundedExecFileSync("git", ["-C", root, "push"], { stdout: "ignore", timeout: BRANCH_PUSH_TIMEOUT_MS });
2336
2331
  return true;
2337
2332
  }
2338
2333
  catch {
@@ -2652,83 +2647,70 @@ export function commitChanges(sha, cwd) {
2652
2647
  }
2653
2648
  return out;
2654
2649
  }
2655
- /** Default cap on how many commits mergeRangeChanges will inspect. A `git
2656
- * pull` bringing in an unusually large number of commits (hundreds) would
2657
- * otherwise spawn one `diff-tree` per commit with no bound — cheap insurance
2658
- * against an unbounded background hook run, not a hard product limit. */
2659
- const DEFAULT_MERGE_RANGE_CAP = 500;
2660
- /** Every commit newly reachable in `oldRef..newRef` (capped to the most
2661
- * recent `maxCount`), with the files each one changed (rename-aware, via
2662
- * commitChanges). Used to find the commit a squash-merge produced from a
2663
- * set of now-orphaned source-branch commits. */
2664
- export function mergeRangeChanges(oldRef, newRef, cwd, maxCount = DEFAULT_MERGE_RANGE_CAP) {
2665
- 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();
2666
2661
  return shas.map((sha) => ({
2667
2662
  sha,
2668
- // A deletion is never a sensible repair target — a decision's provenance
2669
- // should never resolve to the commit that removed the file it's about.
2670
- 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),
2671
2667
  }));
2672
2668
  }
2673
- /** Batched existence check for many commit shas at once — one `git cat-file
2674
- * --batch-check` process instead of one `rev-parse --verify` per sha. Used
2675
- * by drift.ts's default commitResolvable so hunch drift/heal/doctor spawn a
2676
- * single git process regardless of how many decisions carry a commit, not
2677
- * one per decision. Returns the subset of `shas` that resolve to a real
2678
- * commit object, or `null` if the check itself failed to run (not a git
2679
- * repo, git missing, timeout) — distinct from an empty set, which means
2680
- * "checked, and none of them resolve." A caller that collapsed those two
2681
- * would flag every commit as unresolvable on a transient environment error
2682
- * 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. */
2683
2673
  export function commitsExist(shas, cwd) {
2684
- const resolvable = new Set();
2685
2674
  if (!shas.length)
2686
- return resolvable;
2687
- // Same shape guard as commitRepairStatus. Without it, a malformed value
2688
- // embedding its own newline would count as more input lines than entries
2689
- // in `shas`, shifting every later sha's output line and misclassifying it.
2690
- 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));
2691
2677
  if (!valid.length)
2692
- return resolvable;
2693
- let raw;
2678
+ return new Set();
2679
+ let out;
2694
2680
  try {
2695
- raw = execFileSync("git", ["-C", cwd, "cat-file", "--batch-check"], {
2696
- encoding: "utf8",
2697
- input: valid.map((sha) => `${sha}^{commit}\n`).join(""),
2698
- stdio: ["pipe", "pipe", "ignore"],
2699
- 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,
2700
2684
  });
2701
2685
  }
2702
2686
  catch {
2703
2687
  return null;
2704
2688
  }
2705
- const lines = raw.split("\n").filter(Boolean);
2706
- for (let i = 0; i < valid.length && i < lines.length; i++) {
2707
- if (!lines[i].endsWith(" missing"))
2708
- resolvable.add(valid[i]);
2709
- }
2710
- return resolvable;
2711
- }
2712
- /** Classifies `commit` relative to `ref` for squash-merge repair eligibility.
2713
- * "orphaned" (exists in this repo, but is not an ancestor of `ref`) is the
2714
- * ONLY status planCommitRepair should ever act on. "unresolvable" (the
2715
- * commit doesn't exist here at all) is explicitly out of repair scope —
2716
- * drift.ts's commit-unresolvable kind is what flags that case, because
2717
- * there's no reliable signal left to match a replacement against. "unknown"
2718
- * (the git command itself failed to run — timeout, git missing) must never
2719
- * be treated as repair-eligible; collapsing "false" and "error" into one
2720
- * 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". */
2721
2699
  export function commitRepairStatus(commit, ref, cwd) {
2722
- if (!/^[0-9a-f]{7,64}$/i.test(commit))
2700
+ if (!HEX_COMMIT_RE.test(commit))
2723
2701
  return "unresolvable";
2702
+ const opts = { cwd, stdio: "ignore", timeout: 5_000 };
2724
2703
  try {
2725
- execFileSync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${commit}^{commit}`], { stdio: "ignore", timeout: 5_000 });
2704
+ execFileSync("git", ["cat-file", "-e", `${commit}^{commit}`], opts);
2726
2705
  }
2727
2706
  catch (e) {
2728
- 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";
2729
2711
  }
2730
2712
  try {
2731
- 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);
2732
2714
  return "current";
2733
2715
  }
2734
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;