@davesheffer/hunch 1.45.2 → 1.46.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 (53) hide show
  1. package/dist/cli/index.js +139 -32
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/experiment.d.ts +1 -1
  7. package/dist/core/agenthook.js +26 -6
  8. package/dist/core/boundedSpawn.d.ts +57 -0
  9. package/dist/core/boundedSpawn.js +321 -0
  10. package/dist/core/config.d.ts +6 -0
  11. package/dist/core/config.js +1 -0
  12. package/dist/core/delivery.d.ts +6 -1
  13. package/dist/core/delivery.js +71 -5
  14. package/dist/core/migrate.d.ts +10 -1
  15. package/dist/core/migrate.js +17 -6
  16. package/dist/core/projectDna.js +11 -1
  17. package/dist/core/relativeImports.js +6 -2
  18. package/dist/core/taskSelection.js +48 -6
  19. package/dist/core/testSelection.d.ts +122 -0
  20. package/dist/core/testSelection.js +588 -0
  21. package/dist/extractors/git.js +32 -31
  22. package/dist/extractors/indexer.js +42 -6
  23. package/dist/extractors/languages.d.ts +9 -0
  24. package/dist/extractors/languages.js +28 -0
  25. package/dist/extractors/parse.d.ts +20 -0
  26. package/dist/extractors/parse.js +81 -5
  27. package/dist/extractors/workspaces.js +3 -1
  28. package/dist/integrations/health.d.ts +6 -0
  29. package/dist/integrations/health.js +33 -12
  30. package/dist/integrations/hooks.d.ts +4 -2
  31. package/dist/integrations/hooks.js +12 -2
  32. package/dist/integrations/mergeDriver.js +5 -3
  33. package/dist/integrations/optout.d.ts +3 -0
  34. package/dist/integrations/optout.js +17 -0
  35. package/dist/integrations/providers.d.ts +6 -2
  36. package/dist/integrations/providers.js +19 -2
  37. package/dist/integrations/team.js +4 -2
  38. package/dist/integrations/uninstall.d.ts +10 -4
  39. package/dist/integrations/uninstall.js +22 -18
  40. package/dist/mcp/server.js +47 -8
  41. package/dist/serve/app.js +9 -1
  42. package/dist/serve/config.js +5 -0
  43. package/dist/serve/writelock.d.ts +3 -1
  44. package/dist/serve/writelock.js +17 -3
  45. package/dist/store/hunchStore.d.ts +2 -1
  46. package/dist/store/hunchStore.js +8 -5
  47. package/dist/store/jsonStore.d.ts +13 -3
  48. package/dist/store/jsonStore.js +37 -13
  49. package/dist/store/stateBinding.js +17 -4
  50. package/dist/synthesis/provider.js +26 -78
  51. package/dist/synthesis/synthesize.js +1 -1
  52. package/package.json +2 -2
  53. package/server.json +2 -2
@@ -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
@@ -1193,16 +1196,13 @@ function contractRemoteHeads(hunchDir, contract, direction, env, timeoutMs) {
1193
1196
  if (!hooksDir)
1194
1197
  return null;
1195
1198
  try {
1196
- const out = execFileSync("git", [
1199
+ // Network git runs under boundedExecFileSync: execFileSync's timeout kills only
1200
+ // the root, orphaning git-remote-https.exe on Windows (issue #498).
1201
+ const out = boundedExecFileSync("git", [
1197
1202
  "-C", hunchDir,
1198
1203
  "-c", `core.hooksPath=${hooksDir}`,
1199
1204
  "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
- });
1205
+ ], { env: boundedTeamEnv(env), timeout: timeoutMs });
1206
1206
  if (!contractReady(contract))
1207
1207
  return null;
1208
1208
  const heads = [];
@@ -1226,12 +1226,12 @@ function fetchContractRef(hunchDir, contract, env, timeoutMs) {
1226
1226
  if (!hooksDir)
1227
1227
  return "";
1228
1228
  try {
1229
- execFileSync("git", [
1229
+ boundedExecFileSync("git", [
1230
1230
  "-C", hunchDir,
1231
1231
  "-c", `core.hooksPath=${hooksDir}`,
1232
1232
  "fetch", "--no-tags", "--no-write-fetch-head", "--upload-pack=git-upload-pack",
1233
1233
  contract.fetchUrl, `+${contract.ref}:${TEAM_FETCH_REF}`,
1234
- ], { stdio: ["ignore", "ignore", "pipe"], env: boundedTeamEnv(env), timeout: timeoutMs });
1234
+ ], { stdout: "ignore", env: boundedTeamEnv(env), timeout: timeoutMs });
1235
1235
  if (!contractReady(contract))
1236
1236
  return "";
1237
1237
  const oid = execFileSync("git", ["-C", hunchDir, "rev-parse", "--verify", TEAM_FETCH_REF], {
@@ -1391,17 +1391,21 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
1391
1391
  const tryGit = (args, timeout = timeoutMs) => {
1392
1392
  const startedAt = Date.now();
1393
1393
  lastPullGitError = undefined;
1394
+ const argv = [
1395
+ "-C", hunchDir,
1396
+ "-c", `core.hooksPath=${hooksDir}`,
1397
+ "-c", `core.attributesFile=${gitNullDevice()}`,
1398
+ "-c", "core.autocrlf=false",
1399
+ "-c", "commit.gpgsign=false",
1400
+ ...args,
1401
+ ];
1394
1402
  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
- });
1403
+ // Only the network call pays for the tree-killing supervisor (#498); the
1404
+ // merge of an already-fetched sha and rev-parse/abort stay local.
1405
+ if (args[0] === "fetch")
1406
+ boundedExecFileSync("git", argv, { stdout: "ignore", env, timeout });
1407
+ else
1408
+ execFileSync("git", argv, { stdio: ["ignore", "ignore", "pipe"], env, timeout });
1405
1409
  return true;
1406
1410
  }
1407
1411
  catch (error) {
@@ -1496,7 +1500,7 @@ function upstreamSha(hunchDir, env) {
1496
1500
  * and discard oversized/unavailable output without changing its exit result. */
1497
1501
  function runObservedPush(args, env, timeoutMs, observe) {
1498
1502
  if (!observe) {
1499
- execFileSync("git", args, { stdio: "ignore", env, timeout: timeoutMs });
1503
+ boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
1500
1504
  return null;
1501
1505
  }
1502
1506
  let directory, descriptor;
@@ -1511,11 +1515,11 @@ function runObservedPush(args, env, timeoutMs, observe) {
1511
1515
  }
1512
1516
  catch { }
1513
1517
  }
1514
- execFileSync("git", args, { stdio: "ignore", env, timeout: timeoutMs });
1518
+ boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
1515
1519
  return null;
1516
1520
  }
1517
1521
  try {
1518
- execFileSync("git", args, { stdio: ["ignore", descriptor, "ignore"], env, timeout: timeoutMs });
1522
+ boundedExecFileSync("git", args, { stdout: descriptor, env, timeout: timeoutMs });
1519
1523
  try {
1520
1524
  const buffer = Buffer.alloc(1_000_001);
1521
1525
  const bytes = readSync(descriptor, buffer, 0, buffer.length, 0);
@@ -1563,8 +1567,8 @@ function observePushResult(hunchDir, env, output, observer) {
1563
1567
  if (!destination || /[\x00-\x1f\x7f]/.test(destination) || Date.now() >= deadline)
1564
1568
  continue;
1565
1569
  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,
1570
+ const result = boundedExecFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", "--exit-code", "--", destination, ref], {
1571
+ env, timeout: Math.max(1, deadline - Date.now()), maxBuffer: 16_384,
1568
1572
  }).trim();
1569
1573
  const rows = result.split("\n").filter(row => row.split("\t")[1] === ref);
1570
1574
  const commitSha = rows.length === 1 ? rows[0].split("\t")[0] : "";
@@ -1656,12 +1660,7 @@ function establishEmptyRemoteUpstream(hunchDir, env, protectedRepoRoot, timeoutM
1656
1660
  if (!before)
1657
1661
  return false;
1658
1662
  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();
1663
+ const refs = boundedExecFileSync("git", ["-C", hunchDir, "ls-remote", "--refs", remote], { env, timeout: timeoutMs }).trim();
1665
1664
  if (refs)
1666
1665
  return false;
1667
1666
  }
@@ -2332,7 +2331,9 @@ export function memoryMoveDiff(sha, root) {
2332
2331
  * false when there is no upstream / offline / not a repo. */
2333
2332
  export function pushCurrentBranch(root) {
2334
2333
  try {
2335
- execFileSync("git", ["-C", root, "push"], { stdio: "ignore" });
2334
+ // Bounded so an unreachable remote can't hang the caller, and a timeout
2335
+ // kills git's whole tree, not just the root (#498).
2336
+ boundedExecFileSync("git", ["-C", root, "push"], { stdout: "ignore", timeout: BRANCH_PUSH_TIMEOUT_MS });
2336
2337
  return true;
2337
2338
  }
2338
2339
  catch {
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { readFileSync } from "node:fs";
12
12
  import { dirname, join, posix } from "node:path";
13
- import { parseSource, attributeCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
13
+ import { parseSource, attributeCalls, attributeQualifiedCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
14
14
  import { isParserLoadError } from "./nativeTreeSitter.js";
15
15
  import { extractHelmDirectives } from "./helm.js";
16
16
  import { extractK8sManifest, extractKustomization, isKustomizationFile, kustomizeNamespaces, namespacesCompatible } from "./k8sManifest.js";
@@ -332,7 +332,7 @@ export function scanRepo(store, root, opts = {}) {
332
332
  }
333
333
  fileSymbols.set(rel, idsInFile);
334
334
  fileSymbolIndexId.set(rel, symbolIndexId);
335
- perFileCalls.push({ file: rel, bySym: attributeCalls(parsed) });
335
+ perFileCalls.push({ file: rel, bySym: attributeCalls(parsed), qualified: attributeQualifiedCalls(parsed) });
336
336
  perFileImports.push({ file: rel, imports: parsed.imports });
337
337
  perFileRelations.push({ file: rel, bySym: attributeRelations(parsed) });
338
338
  if (languageFor(rel)?.id === "php") {
@@ -359,7 +359,7 @@ export function scanRepo(store, root, opts = {}) {
359
359
  if (langId === "php") {
360
360
  return resolvePhpImportTargets(file, spec, phpNamespaces.get(file) ?? null, phpUseDeclarations.get(file) ?? [], phpPsr4, trackedFiles);
361
361
  }
362
- return [resolveImport(file, spec, fileSymbols)].filter((target) => !!target);
362
+ return [resolveImport(file, spec, trackedFiles)].filter((target) => !!target);
363
363
  };
364
364
  const importedFiles = new Map(perFileImports.map(({ file, imports }) => {
365
365
  const targets = new Set(imports.flatMap((specifier) => resolveImportTargets(file, specifier)));
@@ -381,7 +381,7 @@ export function scanRepo(store, root, opts = {}) {
381
381
  edgeSeen.add(e.id);
382
382
  edges.push(e);
383
383
  };
384
- for (const { file, bySym } of perFileCalls) {
384
+ for (const { file, bySym, qualified } of perFileCalls) {
385
385
  // Most languages leave this unset and get "calls" — YAML's alias->anchor
386
386
  // references aren't function calls, so its LanguageSpec declares "references".
387
387
  const edgeType = languageFor(file)?.referenceEdgeType ?? "calls";
@@ -423,6 +423,42 @@ export function scanRepo(store, root, opts = {}) {
423
423
  });
424
424
  }
425
425
  }
426
+ // Calls pinned to one imported module (`ns.fn()`, aliased `g()`): the member
427
+ // gate above drops them, so resolve the name ONLY inside that module's files —
428
+ // a unique match there is the binding; zero or several -> no edge.
429
+ // A module binding exposes top-level exports, so `U.helper()` prefers a function
430
+ // `helper` over a same-named class method `Foo.helper`; a sole method match still
431
+ // binds, since `module.exports = { helper() {} }` parses as a method too.
432
+ const moduleFiles = new Map(); // resolve each module once per file
433
+ for (const [callerIndex, targets] of qualified) {
434
+ const callerId = indexToId.get(callerIndex);
435
+ if (!callerId)
436
+ continue;
437
+ const callerName = byId.get(callerId)?.name ?? "?";
438
+ for (const { callee, module } of targets) {
439
+ let files = moduleFiles.get(module);
440
+ if (!files)
441
+ moduleFiles.set(module, files = new Set(resolveImportTargets(file, module)));
442
+ const inModule = (nameIndex.get(callee) ?? []).filter((id) => {
443
+ const sym = byId.get(id);
444
+ return !!sym && files.has(sym.file);
445
+ });
446
+ const nonMethods = inModule.filter((id) => byId.get(id).kind !== "method");
447
+ const matches = nonMethods.length > 0 ? nonMethods : inModule;
448
+ if (matches.length !== 1 || matches[0] === callerId)
449
+ continue;
450
+ const calleeId = matches[0];
451
+ addEdge({
452
+ schema: "hunch.edge/1",
453
+ id: edgeId(callerId, calleeId, edgeType),
454
+ from: callerId, to: calleeId, type: edgeType,
455
+ reason: `${callerName} ${edgeType} ${callee}`, strength: 0.8,
456
+ provenance: extracted(0.8, [file]),
457
+ environment: null,
458
+ metadata: {},
459
+ });
460
+ }
461
+ }
426
462
  }
427
463
  // ---- K8s manifest cross-resource references (Phase 1: name-keyed) --------
428
464
  // Own resolver, not resolveName(): resolveName() indexes by bare symbol name
@@ -747,8 +783,8 @@ function resolveName(name, file, importedFiles, nameIndex, byId) {
747
783
  return imported.length === 1 ? imported[0] : null;
748
784
  }
749
785
  /** Resolve a relative import specifier to a concrete tracked file path. */
750
- function resolveImport(fromFile, spec, fileSymbols) {
751
- return resolveRelativeImport(fromFile, spec, fileSymbols.keys()).path;
786
+ function resolveImport(fromFile, spec, trackedFiles) {
787
+ return resolveRelativeImport(fromFile, spec, trackedFiles).path;
752
788
  }
753
789
  /** First of `${modulePath}.py` / `${modulePath}/__init__.py` that's a tracked file,
754
790
  * or null — the shared "module file vs. package __init__" candidate check used by
@@ -17,6 +17,15 @@ export interface LanguageSpec {
17
17
  defKindOf: Record<string, ParsedSymbolKind>;
18
18
  /** Query capture-name (ending in ".name") -> the matching ".def" capture-name. */
19
19
  nameToDef: Record<string, string>;
20
+ /** Optional query binding a local name to an imported module, run with
21
+ * matches() so captures pair up: @bind.src (module specifier), @bind.local
22
+ * (local name), @bind.name (an imported member, `from X import name`). Feeds
23
+ * qualified calls (`ns.fn()`), resolved only inside the bound module's files. */
24
+ bindingQuery?: string;
25
+ /** Local name an import binds when @bind.local is absent (Go `"a/geo"` -> geo). */
26
+ defaultBindingLocal?(src: string): string | null;
27
+ /** Module path of `from <src> import <name>` when name is itself a submodule. */
28
+ joinSubmodule?(src: string, name: string): string;
20
29
  /** Optional semantic relationship captures. The generic parser/indexer emit
21
30
  * these through the normal graph path; languages only describe syntax. */
22
31
  relationKindOf?: Record<string, {
@@ -17,7 +17,14 @@ const TS_QUERY = `
17
17
  (variable_declarator
18
18
  name: (identifier) @arrow.name
19
19
  value: [(arrow_function) (function_expression)]) @arrow.def
20
+ ;; Class-field arrows (\`handler = () => {}\`) are methods in every way that matters.
21
+ (public_field_definition
22
+ name: (property_identifier) @method.name
23
+ value: [(arrow_function) (function_expression)]) @method.def
20
24
  (import_statement source: (string) @import.src)
25
+ ;; require("./x") and dynamic import("./x") are imports too, when the specifier is literal.
26
+ (call_expression function: (identifier) @_require arguments: (arguments . (string) @import.src .) (#eq? @_require "require"))
27
+ (call_expression function: (import) arguments: (arguments . (string) @import.src .))
21
28
  (call_expression function: (identifier) @call.id)
22
29
  (call_expression function: (member_expression property: (property_identifier) @call.member))
23
30
  ;; Construction IS a call. Without these, \`new Foo()\` produced no edge at all, so
@@ -27,6 +34,24 @@ const TS_QUERY = `
27
34
  (new_expression constructor: (identifier) @call.id)
28
35
  (new_expression constructor: (member_expression property: (property_identifier) @call.member))
29
36
  `;
37
+ /** Rendering a component IS a call; lowercase tags are intrinsic elements, not symbols. */
38
+ const TSX_QUERY = `${TS_QUERY}
39
+ (jsx_opening_element name: (identifier) @call.id (#match? @call.id "^[A-Z]"))
40
+ (jsx_self_closing_element name: (identifier) @call.id (#match? @call.id "^[A-Z]"))
41
+ (jsx_opening_element name: (member_expression property: (property_identifier) @call.member))
42
+ (jsx_self_closing_element name: (member_expression property: (property_identifier) @call.member))
43
+ `;
44
+ /** Module-object bindings: \`import * as ns\`, \`const m = require()\`, \`const m = await import()\`. */
45
+ const TS_BINDING_QUERY = `
46
+ (import_statement (import_clause (namespace_import (identifier) @bind.local)) source: (string) @bind.src)
47
+ (variable_declarator
48
+ name: (identifier) @bind.local
49
+ value: (call_expression function: (identifier) @_require arguments: (arguments . (string) @bind.src .))
50
+ (#eq? @_require "require"))
51
+ (variable_declarator
52
+ name: (identifier) @bind.local
53
+ value: (await_expression (call_expression function: (import) arguments: (arguments . (string) @bind.src .))))
54
+ `;
30
55
  const TS_BUILTIN_METHODS = new Set([
31
56
  "map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
32
57
  "push", "pop", "shift", "unshift", "slice", "splice", "concat", "join", "split", "flat", "flatMap",
@@ -45,6 +70,7 @@ const TS_SHARED = {
45
70
  defNodeTypes: new Set([
46
71
  "function_declaration", "generator_function_declaration", "method_definition",
47
72
  "class_declaration", "interface_declaration", "type_alias_declaration", "variable_declarator",
73
+ "public_field_definition",
48
74
  ]),
49
75
  defKindOf: {
50
76
  "fn.def": "function", "method.def": "method", "class.def": "class",
@@ -55,6 +81,7 @@ const TS_SHARED = {
55
81
  "iface.name": "iface.def", "type.name": "type.def", "arrow.name": "arrow.def",
56
82
  },
57
83
  builtinMethods: TS_BUILTIN_METHODS,
84
+ bindingQuery: TS_BINDING_QUERY,
58
85
  // ES2018 relaxed template literals: an INVALID escape (`\x` with no hex, `\u`
59
86
  // short, `\u{` unterminated) is legal inside a TAGGED template — the cooked
60
87
  // value is undefined and the raw text survives, which is the entire point of
@@ -81,6 +108,7 @@ const TSX = {
81
108
  extensions: [".tsx", ".jsx"],
82
109
  grammarKey: "tsx",
83
110
  loadGrammar: () => loadNativeTreeSitter().tsx,
111
+ query: TSX_QUERY,
84
112
  // tree-sitter-javascript rejects a bare ampersand in JSX text even though JSX
85
113
  // accepts it (tree-sitter-javascript#366). The ERROR is a direct jsx_element
86
114
  // child and its text begins at the ampersand. Require both facts plus text that
@@ -15,6 +15,20 @@ export interface ParsedCall {
15
15
  endByte: number;
16
16
  /** true for `x.foo()` (property access), false for a direct `foo()` call. */
17
17
  member: boolean;
18
+ /** For a member call on a plain identifier (`x.foo()`), that identifier. */
19
+ qualifier?: string;
20
+ }
21
+ /** A local name bound by an import: a module object (`imported` null — `import *
22
+ * as ns`, `require()`, Go package alias) or one aliased member of a module. */
23
+ export interface ParsedBinding {
24
+ local: string;
25
+ module: string;
26
+ imported: string | null;
27
+ }
28
+ /** A call whose target is pinned to one imported module (`ns.fn()`, aliased `g()`). */
29
+ export interface QualifiedCall {
30
+ callee: string;
31
+ module: string;
18
32
  }
19
33
  export interface ParsedRelation {
20
34
  target: string;
@@ -28,6 +42,7 @@ export interface ParsedFile {
28
42
  imports: string[];
29
43
  calls: ParsedCall[];
30
44
  relations: ParsedRelation[];
45
+ bindings: ParsedBinding[];
31
46
  namespace: string | null;
32
47
  parseable: boolean;
33
48
  }
@@ -50,6 +65,11 @@ export declare function parseSource(file: string, source: string, opts?: {
50
65
  * `memberOnly` (true iff every occurrence was a `x.foo()` member call, never
51
66
  * a direct `foo()`), so the indexer can resolve member calls conservatively. */
52
67
  export declare function attributeCalls(parsed: ParsedFile): Map<number, Map<string, boolean>>;
68
+ /** Map each call bound to an imported module — `ns.fn()` through a module-object
69
+ * binding, or `g()` through an aliased member import — to the innermost symbol
70
+ * containing it (same index identity as attributeCalls). A local bound to two
71
+ * different modules is ambiguous and dropped: never guess a binding. */
72
+ export declare function attributeQualifiedCalls(parsed: ParsedFile): Map<number, QualifiedCall[]>;
53
73
  /** Map static semantic relationships (extends/implements/trait use) to the
54
74
  * innermost enclosing declaration using the same stable symbol-index identity
55
75
  * as call attribution. */
@@ -14,7 +14,8 @@ function bundleFor(spec) {
14
14
  const grammar = spec.loadGrammar();
15
15
  parser.setLanguage(grammar);
16
16
  const query = new Parser.Query(grammar, spec.query);
17
- b = { parser, query };
17
+ const bindingQuery = spec.bindingQuery ? new Parser.Query(grammar, spec.bindingQuery) : null;
18
+ b = { parser, query, bindingQuery };
18
19
  cache.set(spec.grammarKey, b);
19
20
  }
20
21
  return b;
@@ -41,7 +42,7 @@ export function parseSource(file, source, opts = {}) {
41
42
  // statefulness here even if it forgets to keep its pattern flag-free.
42
43
  const templated = (spec.alwaysTemplatedExtensions?.some((ext) => file.endsWith(ext)) ?? false)
43
44
  || (spec.templatingMarkers?.some((marker) => source.search(marker) !== -1) ?? false);
44
- const { parser, query } = bundleFor(spec);
45
+ const { parser, query, bindingQuery } = bundleFor(spec);
45
46
  // The native binding caps its scratch buffer at 32 KB unless bufferSize is
46
47
  // given — without this, any source >= 32768 bytes throws "Invalid argument"
47
48
  // and would abort the whole index run. Guard with try/catch as a backstop.
@@ -81,6 +82,7 @@ export function parseSource(file, source, opts = {}) {
81
82
  const imports = [];
82
83
  const calls = [];
83
84
  const relations = [];
85
+ const bindings = [];
84
86
  let namespace = null;
85
87
  const originalText = (node) => usingRecoveredTree
86
88
  ? source.slice(node.startIndex, node.endIndex)
@@ -125,8 +127,11 @@ export function parseSource(file, source, opts = {}) {
125
127
  else if (cname === "call.member") {
126
128
  // skip builtin method names to avoid false edges to similarly-named symbols
127
129
  const text = originalText(node);
128
- if (!spec.builtinMethods.has(text))
129
- calls.push({ callee: text, atByte: node.startIndex, endByte: node.endIndex, member: true });
130
+ if (!spec.builtinMethods.has(text)) {
131
+ const object = node.parent?.childForFieldName("object") ?? node.parent?.childForFieldName("operand");
132
+ const qualifier = object?.type === "identifier" ? originalText(object) : undefined;
133
+ calls.push({ callee: text, atByte: node.startIndex, endByte: node.endIndex, member: true, qualifier });
134
+ }
130
135
  }
131
136
  else if (spec.relationKindOf?.[cname]) {
132
137
  const relation = spec.relationKindOf[cname];
@@ -139,6 +144,29 @@ export function parseSource(file, source, opts = {}) {
139
144
  });
140
145
  }
141
146
  }
147
+ for (const match of bindingQuery?.matches(tree.rootNode) ?? []) {
148
+ const text = (capture) => {
149
+ const node = match.captures.find((c) => c.name === capture)?.node;
150
+ return node ? originalText(node) : null;
151
+ };
152
+ const src = text("bind.src")?.replace(STR_QUOTES, "");
153
+ if (!src)
154
+ continue;
155
+ const imported = text("bind.name");
156
+ if (imported) {
157
+ const local = text("bind.local") ?? imported;
158
+ // `from X import Y [as Z]`: Y may be a submodule or a member; record both
159
+ // readings — only the one that resolves to a tracked file/symbol yields an edge.
160
+ if (spec.joinSubmodule)
161
+ bindings.push({ local, module: spec.joinSubmodule(src, imported), imported: null });
162
+ bindings.push({ local, module: src, imported });
163
+ }
164
+ else {
165
+ const local = text("bind.local") ?? spec.defaultBindingLocal?.(src) ?? null;
166
+ if (local)
167
+ bindings.push({ local, module: src, imported: null });
168
+ }
169
+ }
142
170
  for (const { kind, def, name } of pendingDefs.values()) {
143
171
  const resolvedName = name ?? spec.fallbackDefName?.(file);
144
172
  if (!resolvedName)
@@ -167,7 +195,7 @@ export function parseSource(file, source, opts = {}) {
167
195
  });
168
196
  }
169
197
  symbols.sort((a, b) => a.startByte - b.startByte);
170
- return { symbols, imports, calls, relations, namespace, parseable: templated || parseable };
198
+ return { symbols, imports, calls, relations, bindings, namespace, parseable: templated || parseable };
171
199
  }
172
200
  /** True when every ERROR/MISSING node in the tree sits in an ancestor shape this
173
201
  * language declares as a known grammar limitation (LanguageSpec.toleratedErrorScopes).
@@ -259,6 +287,54 @@ export function attributeCalls(parsed) {
259
287
  }
260
288
  return out;
261
289
  }
290
+ /** Map each call bound to an imported module — `ns.fn()` through a module-object
291
+ * binding, or `g()` through an aliased member import — to the innermost symbol
292
+ * containing it (same index identity as attributeCalls). A local bound to two
293
+ * different modules is ambiguous and dropped: never guess a binding. */
294
+ export function attributeQualifiedCalls(parsed) {
295
+ const unique = (entries) => {
296
+ const out = new Map();
297
+ for (const [local, target] of entries) {
298
+ const prev = out.get(local);
299
+ out.set(local, prev === undefined || (prev && prev.module === target.module && prev.callee === target.callee) ? target : null);
300
+ }
301
+ return out;
302
+ };
303
+ const modules = unique(parsed.bindings.filter((b) => b.imported === null).map((b) => [b.local, { callee: "", module: b.module }]));
304
+ const aliases = unique(parsed.bindings.filter((b) => b.imported !== null).map((b) => [b.local, { callee: b.imported, module: b.module }]));
305
+ const out = new Map();
306
+ if (modules.size === 0 && aliases.size === 0)
307
+ return out;
308
+ for (const call of parsed.calls) {
309
+ let target = null;
310
+ if (call.member && call.qualifier) {
311
+ const binding = modules.get(call.qualifier);
312
+ if (binding)
313
+ target = { callee: call.callee, module: binding.module };
314
+ }
315
+ else if (!call.member) {
316
+ target = aliases.get(call.callee) ?? null;
317
+ }
318
+ if (!target)
319
+ continue;
320
+ let best = null;
321
+ let bestIndex = -1;
322
+ for (let i = 0; i < parsed.symbols.length; i++) {
323
+ const s = parsed.symbols[i];
324
+ if (call.atByte >= s.startByte && call.atByte < s.endByte && (!best || s.endByte - s.startByte < best.endByte - best.startByte)) {
325
+ best = s;
326
+ bestIndex = i;
327
+ }
328
+ }
329
+ if (bestIndex < 0)
330
+ continue;
331
+ const list = out.get(bestIndex) ?? [];
332
+ if (!list.some((t) => t.callee === target.callee && t.module === target.module))
333
+ list.push(target);
334
+ out.set(bestIndex, list);
335
+ }
336
+ return out;
337
+ }
262
338
  /** Map static semantic relationships (extends/implements/trait use) to the
263
339
  * innermost enclosing declaration using the same stable symbol-index identity
264
340
  * as call attribution. */
@@ -12,6 +12,7 @@ import { execFileSync, spawnSync } from "node:child_process";
12
12
  import { existsSync, realpathSync, statSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { foreignRepoEnv, gitCommonDir, mainWorktreeRoot, stableRepositoryName } from "./git.js";
15
+ import { boundedSpawnSync } from "../core/boundedSpawn.js";
15
16
  import { extracted } from "../core/types.js";
16
17
  import { CONTROL_CHARS, MAX_BRANCHES, MAX_WORKTREES, WORKSPACE_SCHEMA_VERSION, WorkspaceSchema, isSafeBranchName, workspaceId, worktreeId, } from "../core/workspace.js";
17
18
  const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
@@ -353,8 +354,9 @@ function fetchedAt(root) {
353
354
  export function snapshotWorkspace(root, opts) {
354
355
  const now = opts.now ?? new Date();
355
356
  const main = mainWorktreeRoot(root);
357
+ // Network fetch: bounded so a timeout kills git's whole tree, not just the root (#498).
356
358
  if (opts.fetch)
357
- run(main, ["fetch", "--prune", "--quiet"], 120_000);
359
+ boundedSpawnSync("git", ["fetch", "--prune", "--quiet"], { cwd: main, env: env(), stdout: "ignore", timeout: 120_000 });
358
360
  const def = defaultBranch(main);
359
361
  const searched = opts.squashSearchCommits ?? DEFAULT_SQUASH_SEARCH_COMMITS;
360
362
  let patchIds = null;
@@ -40,6 +40,12 @@ export declare const HARNESSES: {
40
40
  };
41
41
  };
42
42
  export type Harness = keyof typeof HARNESSES;
43
+ /** Devin Desktop (formerly Windsurf) reads workspace hooks from .devin/hooks.json
44
+ * and falls back to the legacy .windsurf/hooks.json only when that file is absent
45
+ * or defines no hooks (docs.devin.ai/desktop/cascade/hooks, #494). */
46
+ export declare const DEVIN_HOOKS = ".devin/hooks.json";
47
+ /** The hooks file the host actually reads, so doctor inspects the active one. */
48
+ export declare function activeHooksFile(root: string, harness: Harness): string;
43
49
  export interface HealthIssue {
44
50
  file: string;
45
51
  code: string;
@@ -29,6 +29,26 @@ export const HARNESSES = {
29
29
  windsurf: { mcp: ".windsurf/mcp_config.json", hooks: ".windsurf/hooks.json", key: "mcpServers", events: ["", "pre_write_code", "post_run_command", ""] },
30
30
  antigravity: { mcp: ".agents/mcp_config.json", hooks: ".agents/hooks.json", key: "mcpServers", events: ["PreInvocation", "PreToolUse", "", ""] },
31
31
  };
32
+ /** Devin Desktop (formerly Windsurf) reads workspace hooks from .devin/hooks.json
33
+ * and falls back to the legacy .windsurf/hooks.json only when that file is absent
34
+ * or defines no hooks (docs.devin.ai/desktop/cascade/hooks, #494). */
35
+ export const DEVIN_HOOKS = ".devin/hooks.json";
36
+ /** The hooks file the host actually reads, so doctor inspects the active one. */
37
+ export function activeHooksFile(root, harness) {
38
+ const legacy = HARNESSES[harness].hooks;
39
+ if (harness !== "windsurf" || !existsSync(join(root, DEVIN_HOOKS)))
40
+ return legacy;
41
+ try {
42
+ const hooks = parseJsonc(readFileSync(join(root, DEVIN_HOOKS), "utf8"))?.hooks;
43
+ if (hooks && typeof hooks === "object" && Object.values(hooks).some(v => Array.isArray(v) && v.length > 0))
44
+ return DEVIN_HOOKS;
45
+ return legacy;
46
+ }
47
+ catch {
48
+ // An unparseable .devin/hooks.json is reported against itself, not masked.
49
+ return DEVIN_HOOKS;
50
+ }
51
+ }
32
52
  const exactVersion = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
33
53
  const pinPattern = /@davesheffer\/hunch@([^\s"'\],;]+)/g;
34
54
  const object = (v) => {
@@ -160,12 +180,13 @@ export function inspectIntegrations(root, selected) {
160
180
  }
161
181
  for (const harness of selected ? [selected] : Object.keys(HARNESSES)) {
162
182
  const spec = HARNESSES[harness];
163
- if (!selected && !existsSync(join(root, spec.mcp)) && (!spec.hooks || !existsSync(join(root, spec.hooks))))
183
+ const hooksFile = activeHooksFile(root, harness);
184
+ if (!selected && !existsSync(join(root, spec.mcp)) && (!hooksFile || !existsSync(join(root, hooksFile))))
164
185
  continue;
165
186
  const capabilities = Object.fromEntries(CAPABILITIES.map(c => [c, { status: "untested", detail: "No runtime evidence" }]));
166
187
  report.harnesses.push({ harness, capabilities });
167
188
  const mcpPath = join(root, spec.mcp);
168
- const hooksFileExists = Boolean(spec.hooks && existsSync(join(root, spec.hooks)));
189
+ const hooksFileExists = Boolean(hooksFile && existsSync(join(root, hooksFile)));
169
190
  let mcpEntryAbsent = false;
170
191
  try {
171
192
  lstatSync(mcpPath);
@@ -203,22 +224,22 @@ export function inspectIntegrations(root, selected) {
203
224
  // A hooks file that was never written is a coverage gap (the adapter is
204
225
  // not installed), not configuration drift: report it, never fail on it.
205
226
  // `--require` still refuses, because nothing unverified counts.
206
- const hooksAbsent = !!spec.hooks && !existsSync(join(root, spec.hooks));
207
- if (spec.hooks && !hooksAbsent) {
227
+ const hooksAbsent = !!hooksFile && !existsSync(join(root, hooksFile));
228
+ if (hooksFile && !hooksAbsent) {
208
229
  try {
209
- const config = object(parseJsonc(readFileSync(join(root, spec.hooks), "utf8")));
230
+ const config = object(parseJsonc(readFileSync(join(root, hooksFile), "utf8")));
210
231
  disabled = config.disableAllHooks === true;
211
232
  events = object(harness === "antigravity" ? config.hunch : config.hooks);
212
233
  const commands = Object.values(events).flatMap(v => hookCommands(v));
213
- recordPins(spec.hooks, commands);
234
+ recordPins(hooksFile, commands);
214
235
  if (commands.some(misroutedHook))
215
236
  report.issues.push({
216
- file: spec.hooks, code: "hook-command",
237
+ file: hooksFile, code: "hook-command",
217
238
  detail: "Hunch hooks invoke the MCP subcommand instead of the hook handler; run hunch integrations repair-pins",
218
239
  });
219
240
  }
220
241
  catch (e) {
221
- report.issues.push({ file: spec.hooks, code: "hook-config", detail: e.message });
242
+ report.issues.push({ file: hooksFile, code: "hook-config", detail: e.message });
222
243
  }
223
244
  }
224
245
  for (const [i, capability] of ["context", "edit-blocking", "failure-capture", "compaction"].entries()) {
@@ -235,7 +256,7 @@ export function inspectIntegrations(root, selected) {
235
256
  status.detail = capability === "context" ? "Hunch relies on instructions and voluntary MCP calls on this adapter" : "No Hunch lifecycle adapter for this capability";
236
257
  }
237
258
  else if (hooksAbsent) {
238
- status.detail = `No ${spec.hooks}; run hunch init to install this host's lifecycle hooks`;
259
+ status.detail = `No ${hooksFile}; run hunch init to install this host's lifecycle hooks`;
239
260
  }
240
261
  else if (disabled || firmness === "off" || ((capability === "failure-capture") && process.env.HUNCH_PIPELINE === "0")) {
241
262
  status.status = "unsupported";
@@ -248,7 +269,7 @@ export function inspectIntegrations(root, selected) {
248
269
  }
249
270
  else {
250
271
  status.detail = `Missing Hunch ${event} handler`;
251
- report.issues.push({ file: spec.hooks, code: "missing-hook", detail: status.detail });
272
+ report.issues.push({ file: hooksFile, code: "missing-hook", detail: status.detail });
252
273
  }
253
274
  }
254
275
  else if (capability === "edit-blocking" && firmness !== "strict") {
@@ -302,7 +323,7 @@ export function repairIntegrationPins(root, opts = {}) {
302
323
  const version = expectedVersion(root);
303
324
  const pending = [];
304
325
  for (const [name, spec] of Object.entries(HARNESSES)) {
305
- for (const file of [spec.mcp, spec.hooks].filter(Boolean)) {
326
+ for (const file of [spec.mcp, spec.hooks, ...(name === "windsurf" ? [DEVIN_HOOKS] : [])].filter(Boolean)) {
306
327
  const path = join(root, file);
307
328
  if (!existsSync(path) || opts.skip?.(file))
308
329
  continue;
@@ -342,7 +363,7 @@ export function repairIntegrationPins(root, opts = {}) {
342
363
  const replacements = new Map(values.map(v => {
343
364
  // Every recognized npm token is shell-safe. Bare tokens also repair
344
365
  // the legacy quoted executable, which PowerShell treats as a string.
345
- const published = file === spec.hooks ? publishedHookCommand(v) : undefined;
366
+ const published = file === spec.hooks || file === DEVIN_HOOKS ? publishedHookCommand(v) : undefined;
346
367
  const repaired = published?.replace(" hunch mcp hook", " hunch hook") ?? v;
347
368
  return [v, replace(repaired)];
348
369
  }).filter(([a, b]) => a !== b));
@@ -16,8 +16,10 @@ export interface HookInstall {
16
16
  * managed-elsewhere: a hook manager owns the hook — nothing was written.
17
17
  * unreachable: the existing hook ends in exec/exit before our block, or the
18
18
  * block in it has no closing marker and so cannot be replaced in place —
19
- * nothing was written. */
20
- action: "created" | "appended" | "updated" | "unchanged" | "kept-shared" | "managed-elsewhere" | "unreachable";
19
+ * nothing was written.
20
+ * opted-out: the main checkout opted out (#488) and git hooks live in the
21
+ * common dir every worktree shares — nothing was written, on purpose. */
22
+ action: "created" | "appended" | "updated" | "unchanged" | "kept-shared" | "managed-elsewhere" | "unreachable" | "opted-out";
21
23
  manager?: HookManagerKind;
22
24
  /** Why nothing was written (non-writing actions only). */
23
25
  reason?: string;