@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.
- package/dist/cli/index.js +139 -32
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/extractors/git.js +32 -31
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.js +47 -8
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +8 -5
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/extractors/git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
], {
|
|
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
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
"
|
|
1399
|
-
|
|
1400
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
1518
|
+
boundedExecFileSync("git", args, { stdout: "ignore", env, timeout: timeoutMs });
|
|
1515
1519
|
return null;
|
|
1516
1520
|
}
|
|
1517
1521
|
try {
|
|
1518
|
-
|
|
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 =
|
|
1567
|
-
env,
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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,
|
|
751
|
-
return resolveRelativeImport(fromFile, spec,
|
|
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. */
|
package/dist/extractors/parse.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 = !!
|
|
207
|
-
if (
|
|
227
|
+
const hooksAbsent = !!hooksFile && !existsSync(join(root, hooksFile));
|
|
228
|
+
if (hooksFile && !hooksAbsent) {
|
|
208
229
|
try {
|
|
209
|
-
const config = object(parseJsonc(readFileSync(join(root,
|
|
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(
|
|
234
|
+
recordPins(hooksFile, commands);
|
|
214
235
|
if (commands.some(misroutedHook))
|
|
215
236
|
report.issues.push({
|
|
216
|
-
file:
|
|
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:
|
|
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 ${
|
|
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:
|
|
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
|
-
|
|
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;
|