@davesheffer/hunch 1.8.0 → 1.8.2
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/README.md +56 -334
- package/dist/cli/index.js +37 -37
- package/dist/cli/invocation.js +55 -2
- package/dist/extractors/diff.js +19 -3
- package/dist/extractors/indexer.js +65 -6
- package/dist/extractors/languages.js +114 -0
- package/dist/extractors/nativeTreeSitter.js +9 -7
- package/dist/extractors/parse.js +25 -68
- package/dist/integrations/claudemd.js +2 -1
- package/dist/store/embedder.js +1 -1
- package/dist/synthesis/provider.js +271 -51
- package/dist/synthesis/synthesize.js +19 -7
- package/dist/wiki/wiki.js +2 -2
- package/package.json +2 -1
package/dist/cli/index.js
CHANGED
|
@@ -29,7 +29,7 @@ import { selectEmbedder } from "../store/embedder.js";
|
|
|
29
29
|
import { indexRepo } from "../extractors/indexer.js";
|
|
30
30
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
31
31
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
32
|
-
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, } from "../synthesis/provider.js";
|
|
32
|
+
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
|
|
33
33
|
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges } from "../extractors/git.js";
|
|
34
34
|
import { parseMemoryLog } from "../core/memorylog.js";
|
|
35
35
|
import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
|
|
@@ -85,7 +85,7 @@ import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
|
|
|
85
85
|
import { ENTITY_KINDS } from "../core/types.js";
|
|
86
86
|
import { planCompaction } from "../store/compact.js";
|
|
87
87
|
import { repairDecisionReference } from "../core/refrepair.js";
|
|
88
|
-
import { resolveInvocation } from "./invocation.js";
|
|
88
|
+
import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext } from "./invocation.js";
|
|
89
89
|
const program = new Command();
|
|
90
90
|
program.name("hunch").description("Hunch — an Engineering Memory OS: a git-native reasoning graph for your codebase.").version(HUNCH_VERSION);
|
|
91
91
|
let openStore = null;
|
|
@@ -272,8 +272,8 @@ program
|
|
|
272
272
|
.option("--since <spec>", "how far back, e.g. 90d", "90d")
|
|
273
273
|
.option("--max <n>", "max commits to process", "40")
|
|
274
274
|
.option("--concurrency <n>", "commits to synthesize in parallel (the LLM call is the bottleneck)", "4")
|
|
275
|
-
.option("--deep", "Deep Synthesis: ensemble every available
|
|
276
|
-
.option("--verify", "Critic pass: audit each draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra
|
|
275
|
+
.option("--deep", "Deep Synthesis: ensemble every available LLM provider per commit and reconcile their drafts (slower, higher-quality; advisory)")
|
|
276
|
+
.option("--verify", "Critic pass: audit each draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra provider call; advisory)")
|
|
277
277
|
.option("--samples <n>", "self-consistency depth when only one CLI is installed: sample it n times per commit and reconcile (default 2 under --deep)")
|
|
278
278
|
.action(async (opts) => {
|
|
279
279
|
const { store, root } = storeFor();
|
|
@@ -283,6 +283,15 @@ program
|
|
|
283
283
|
const commits = logSince(opts.since, root, Number(opts.max));
|
|
284
284
|
const conc = Math.max(1, Math.min(16, Number(opts.concurrency) || 4));
|
|
285
285
|
console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since} (concurrency ${conc})…`);
|
|
286
|
+
// Best-effort context-window advisory (issue #11): printed ONCE, before any
|
|
287
|
+
// commit is drafted — not per-commit, and not under --deep (an ensemble may
|
|
288
|
+
// fan out to several distinct workers, each with its own configuration).
|
|
289
|
+
if (!opts.deep && commits.length > 0) {
|
|
290
|
+
const ctxProvider = await selectProvider();
|
|
291
|
+
const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
|
|
292
|
+
if (ctxWarning)
|
|
293
|
+
console.log(ctxWarning);
|
|
294
|
+
}
|
|
286
295
|
let written = 0, skipped = 0, llm = 0, heuristic = 0;
|
|
287
296
|
// The per-commit cost is the Claude synthesis spawn; run several at once. Safe:
|
|
288
297
|
// each commit drafts independently and writes its OWN decision file atomically,
|
|
@@ -322,8 +331,8 @@ program
|
|
|
322
331
|
.option("--overlay", "alias of --private")
|
|
323
332
|
.option("--commit", "after a capture, also git add+commit the repo the decision landed in (default: follows auto-commit, ON unless opted out) — the overlay is also pushed; the public .hunch/ rides your next push")
|
|
324
333
|
.option("--no-commit", "skip the auto-commit for this capture even when auto-commit is on")
|
|
325
|
-
.option("--deep", "Deep Synthesis: ensemble every available
|
|
326
|
-
.option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra
|
|
334
|
+
.option("--deep", "Deep Synthesis: ensemble every available LLM provider and reconcile their drafts (agreement-weighted, advisory). Slower; uses configured subscriptions/local endpoint")
|
|
335
|
+
.option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra provider call; advisory)")
|
|
327
336
|
.option("--samples <n>", "self-consistency depth when only one CLI is installed: sample it n times and reconcile (default 2 under --deep)")
|
|
328
337
|
.action(async (sha, opts) => {
|
|
329
338
|
const { store, root } = storeFor();
|
|
@@ -2744,15 +2753,15 @@ program
|
|
|
2744
2753
|
const next = writeConfig(paths, { firmness: level }).firmness;
|
|
2745
2754
|
console.log(`✓ firmness set to ${next} (takes effect on the next agent edit — no restart needed).`);
|
|
2746
2755
|
});
|
|
2747
|
-
// ---- provider (per-user synthesis
|
|
2756
|
+
// ---- provider (per-user synthesis provider choice) -----------------------
|
|
2748
2757
|
program
|
|
2749
2758
|
.command("provider")
|
|
2750
|
-
.description("Show or set the local
|
|
2759
|
+
.description("Show or set the local LLM provider Hunch may use for synthesis. Never changes team config.")
|
|
2751
2760
|
.argument("[name]", `auto | ${SYNTH_PREFERENCES.filter((p) => p !== "auto").join(" | ")} (omit to inspect)`)
|
|
2752
2761
|
.action(async (value) => {
|
|
2753
2762
|
const root = findRoot();
|
|
2754
2763
|
if (value != null) {
|
|
2755
|
-
const preference = value.trim();
|
|
2764
|
+
const preference = normalizeProviderName(value.trim()) ?? value.trim();
|
|
2756
2765
|
if (!SYNTH_PREFERENCES.includes(preference)) {
|
|
2757
2766
|
return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
|
|
2758
2767
|
}
|
|
@@ -2765,7 +2774,7 @@ program
|
|
|
2765
2774
|
console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
|
|
2766
2775
|
}
|
|
2767
2776
|
const resolution = await resolveSynthesisProvider({ root });
|
|
2768
|
-
const envValue = process.env.HUNCH_SYNTH_PROVIDER?.trim();
|
|
2777
|
+
const envValue = normalizeProviderName(process.env.HUNCH_SYNTH_PROVIDER?.trim());
|
|
2769
2778
|
const local = readSynthesisPreference(root);
|
|
2770
2779
|
const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
|
|
2771
2780
|
console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
|
|
@@ -2779,7 +2788,7 @@ program
|
|
|
2779
2788
|
}
|
|
2780
2789
|
if (resolution.source === "ambiguous") {
|
|
2781
2790
|
const choices = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => `hunch provider ${s.name}`);
|
|
2782
|
-
console.log(dim("Multiple
|
|
2791
|
+
console.log(dim("Multiple LLM providers are available, so Hunch uses the free deterministic fallback rather than guessing which subscription or endpoint to use."));
|
|
2783
2792
|
console.log(`choose one: ${choices.join(" or ")}`);
|
|
2784
2793
|
}
|
|
2785
2794
|
else if (resolution.source === "unavailable-preference") {
|
|
@@ -3313,7 +3322,7 @@ program
|
|
|
3313
3322
|
console.log("✓ No drafts to auto-review.");
|
|
3314
3323
|
return;
|
|
3315
3324
|
}
|
|
3316
|
-
// Delegate relevance to the
|
|
3325
|
+
// Delegate relevance to the configured LLM provider — feature-detected.
|
|
3317
3326
|
// A dry-run may remain partial (missing verdicts are kept), but --apply is
|
|
3318
3327
|
// all-or-nothing when judgment was requested: a provider outage must never
|
|
3319
3328
|
// turn an incomplete batch into an apparently safe mutation plan.
|
|
@@ -3342,7 +3351,7 @@ program
|
|
|
3342
3351
|
}
|
|
3343
3352
|
}
|
|
3344
3353
|
else {
|
|
3345
|
-
console.log(dim("No
|
|
3354
|
+
console.log(dim("No LLM synthesis provider available — relevance judgment skipped (dedup + grounding only)."));
|
|
3346
3355
|
judgmentFailures.push(...drafts.map((d) => ({ id: d.id, error: "no subscription relevance judge available" })));
|
|
3347
3356
|
}
|
|
3348
3357
|
}
|
|
@@ -3779,7 +3788,7 @@ program
|
|
|
3779
3788
|
// ---- wiki (generated component wiki — a derived VIEW of the graph) ----------
|
|
3780
3789
|
program
|
|
3781
3790
|
.command("wiki")
|
|
3782
|
-
.description("Generate a component wiki from the graph — pages are a derived VIEW (the graph stays the source of truth), pinned with hunch:topic anchors and freshness-hashed into a wiki-manifest. Stale pages surface as wiki-stale in `hunch drift`; --heal regenerates ONLY those. Prose via
|
|
3791
|
+
.description("Generate a component wiki from the graph — pages are a derived VIEW (the graph stays the source of truth), pinned with hunch:topic anchors and freshness-hashed into a wiki-manifest. Stale pages surface as wiki-stale in `hunch drift`; --heal regenerates ONLY those. Prose via the configured LLM provider when available; deterministic template otherwise. Default: PUBLIC-store records only, written to <repo>/wiki/. With --private: the FULL graph (overlay included), written into the private overlay repo — never committed here.")
|
|
3783
3792
|
.option("--dir <dir>", "output directory (default: wiki/, or the manifest's dir once adopted)")
|
|
3784
3793
|
.option("--heal", "regenerate only new/stale pages (manifest hash mismatch) and remove orphans")
|
|
3785
3794
|
.option("--check", "report stale pages and exit non-zero (CI gate); writes nothing")
|
|
@@ -3844,8 +3853,8 @@ program
|
|
|
3844
3853
|
// otherwise: drift says "remove with --heal", --heal refuses to run).
|
|
3845
3854
|
if (!status.entries.length && !opts.heal)
|
|
3846
3855
|
return fail("no active components in the graph — run `hunch index` first.");
|
|
3847
|
-
// Prose is optional garnish on the deterministic skeleton:
|
|
3848
|
-
// only (same
|
|
3856
|
+
// Prose is optional garnish on the deterministic skeleton: configured LLM
|
|
3857
|
+
// provider only (same guards as synthesis), feature-detected, and any failure degrades
|
|
3849
3858
|
// to a template page — generation never depends on a model being present.
|
|
3850
3859
|
if (opts.proseHeal && opts.llm === false)
|
|
3851
3860
|
return fail("--prose-heal needs the LLM — drop --no-llm.");
|
|
@@ -3854,13 +3863,13 @@ program
|
|
|
3854
3863
|
if (opts.llm !== false) {
|
|
3855
3864
|
const provider = await selectProvider({ root });
|
|
3856
3865
|
if (provider.draftProse) {
|
|
3857
|
-
console.log(`Prose via ${provider.name}
|
|
3866
|
+
console.log(`Prose via ${provider.name}; the drift-bearing skeleton stays deterministic.`);
|
|
3858
3867
|
prose = (pack, excerpts) => provider.draftProse(wikiPrompt(pack, excerpts));
|
|
3859
3868
|
if (opts.proseHeal)
|
|
3860
3869
|
adoptionProse = (doc, content) => provider.draftProse(adoptProsePrompt(doc, content, status.decisions));
|
|
3861
3870
|
}
|
|
3862
3871
|
else {
|
|
3863
|
-
console.log(`No
|
|
3872
|
+
console.log(`No LLM synthesis provider available — deterministic template pages${opts.proseHeal ? " (prose-heal skipped)" : ""}.`);
|
|
3864
3873
|
}
|
|
3865
3874
|
}
|
|
3866
3875
|
const res = await generateWiki(store, root, home, {
|
|
@@ -4087,22 +4096,16 @@ program
|
|
|
4087
4096
|
const resolution = await resolveSynthesisProvider({ root });
|
|
4088
4097
|
const provider = resolution.provider;
|
|
4089
4098
|
console.log(`synthesis: ${provider.name} (${resolution.source})`);
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
console.log(
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
console.log(dim(` ↳ ${resolution.preference} was selected but is unavailable; using the offline heuristic.`));
|
|
4101
|
-
}
|
|
4102
|
-
else {
|
|
4103
|
-
console.log(dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence).`));
|
|
4104
|
-
console.log(dim(` install or log into Claude Code, Codex, or Cursor; then select one with \`hunch provider <name>\`.`));
|
|
4105
|
-
}
|
|
4099
|
+
// Synthesis uses the user's SUBSCRIPTION via a coding-assistant CLI or a
|
|
4100
|
+
// configured local/self-hosted endpoint. Public remotes require the named
|
|
4101
|
+
// metered opt-in. Surface which one — or what's missing (issue #9:
|
|
4102
|
+
// openai-compat has no `subscription` and must not fall through to the
|
|
4103
|
+
// "no assistant CLI found" branch).
|
|
4104
|
+
for (const line of synthesisStatusLines(resolution, process.env))
|
|
4105
|
+
console.log(line);
|
|
4106
|
+
const ctxWarning = await maybeWarnOllamaContext(provider.name, process.env);
|
|
4107
|
+
if (ctxWarning)
|
|
4108
|
+
console.log(ctxWarning);
|
|
4106
4109
|
const c = store.reindex().counts;
|
|
4107
4110
|
console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
|
|
4108
4111
|
try {
|
|
@@ -4199,9 +4202,6 @@ function reportClaudeConfigHeal() {
|
|
|
4199
4202
|
}
|
|
4200
4203
|
console.log(dim(` ↳ backup: ${res.backup}`));
|
|
4201
4204
|
}
|
|
4202
|
-
function dim(s) {
|
|
4203
|
-
return `\x1b[2m${s}\x1b[0m`;
|
|
4204
|
-
}
|
|
4205
4205
|
function fail(msg) {
|
|
4206
4206
|
console.error(`error: ${msg}`);
|
|
4207
4207
|
process.exitCode = 1;
|
package/dist/cli/invocation.js
CHANGED
|
@@ -1,8 +1,61 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
1
|
+
/** Side-effect-free shared CLI logic — safe for any module (including tests)
|
|
2
|
+
* to import, unlike src/cli/index.ts, which runs the whole program at
|
|
3
|
+
* import time. Holds: how to re-invoke this CLI from a git hook / .mcp.json
|
|
4
|
+
* (working both when running the built dist and in dev via tsx), plus small
|
|
5
|
+
* formatting helpers (dim(), doctor's synthesisStatusLines()) that need the
|
|
6
|
+
* same import-safety to be unit-testable. */
|
|
3
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { probeOllamaNumCtx } from "../synthesis/provider.js";
|
|
4
9
|
/** Published package name — used for OS-agnostic invocations (see below). */
|
|
5
10
|
const PKG = "@davesheffer/hunch";
|
|
11
|
+
export function dim(s) {
|
|
12
|
+
return `\x1b[2m${s}\x1b[0m`;
|
|
13
|
+
}
|
|
14
|
+
/** The doctor command's synthesis-status line(s) for a resolved provider.
|
|
15
|
+
* Exported for testing — the previous version (a bare provider-name switch,
|
|
16
|
+
* before the resolveSynthesisProvider preference system existed) had zero
|
|
17
|
+
* test coverage, which is how issue #8 (openai-compat misreported as "no
|
|
18
|
+
* assistant CLI found") shipped unnoticed through three review passes. That
|
|
19
|
+
* bug resurfaces here for the same reason: resolution.statuses carries a
|
|
20
|
+
* `subscription` field for the CLI providers but openai-compat's is null (it
|
|
21
|
+
* isn't a subscription), so it must be special-cased explicitly rather than
|
|
22
|
+
* falling through to the "no assistant CLI" branch. */
|
|
23
|
+
export function synthesisStatusLines(resolution, env) {
|
|
24
|
+
const provider = resolution.provider;
|
|
25
|
+
const selected = resolution.statuses.find((s) => s.name === provider.name);
|
|
26
|
+
if (selected?.subscription) {
|
|
27
|
+
return [` ↳ LLM synthesis uses your ${selected.subscription}; provider API credentials are not used.`];
|
|
28
|
+
}
|
|
29
|
+
if (provider.name === "openai-compat") {
|
|
30
|
+
const base = env.HUNCH_SYNTH_BASE_URL ?? "(unset)";
|
|
31
|
+
const model = env.HUNCH_SYNTH_MODEL ?? "(unset)";
|
|
32
|
+
const keyNote = env.HUNCH_SYNTH_API_KEY ? " (HUNCH_SYNTH_API_KEY set)" : " (no API key)";
|
|
33
|
+
return [` ↳ LLM synthesis via local/self-hosted endpoint ${base} (model: ${model})${keyNote}`];
|
|
34
|
+
}
|
|
35
|
+
if (resolution.source === "ambiguous") {
|
|
36
|
+
const names = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => s.name);
|
|
37
|
+
return [
|
|
38
|
+
dim(` ↳ ${names.join(", ")} are available; Hunch will not guess which provider to use.`),
|
|
39
|
+
dim(` choose one locally: ${names.map((name) => `hunch provider ${name}`).join(" or ")}`),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
if (resolution.source === "unavailable-preference") {
|
|
43
|
+
return [dim(` ↳ ${resolution.preference} was selected but is unavailable; using the offline heuristic.`)];
|
|
44
|
+
}
|
|
45
|
+
return [
|
|
46
|
+
dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence).`),
|
|
47
|
+
dim(` install or log into Claude Code, Codex, or Cursor; then select one with \`hunch provider <name>\`.`),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
/** Gate + fetch the Ollama context-window advisory (issue #11): only relevant
|
|
51
|
+
* for the openai-compat provider, so every other provider is a no-op. Kept
|
|
52
|
+
* separate from synthesisStatusLines (sync, already fully covered) because
|
|
53
|
+
* this one makes a best-effort network call. */
|
|
54
|
+
export async function maybeWarnOllamaContext(providerName, env) {
|
|
55
|
+
if (providerName !== "openai-compat")
|
|
56
|
+
return null;
|
|
57
|
+
return probeOllamaNumCtx(env.HUNCH_SYNTH_BASE_URL ?? "", env.HUNCH_SYNTH_MODEL ?? "");
|
|
58
|
+
}
|
|
6
59
|
export function resolveInvocation() {
|
|
7
60
|
const entry = fileURLToPath(import.meta.url).replace(/invocation\.(js|ts)$/, "index.$1");
|
|
8
61
|
const isDev = entry.endsWith(".ts");
|
package/dist/extractors/diff.js
CHANGED
|
@@ -16,12 +16,24 @@ const DECL_PATTERNS = [
|
|
|
16
16
|
{ kind: "type", re: /^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*[=<]/ },
|
|
17
17
|
{ kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/ },
|
|
18
18
|
{ kind: "const", re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?function/ },
|
|
19
|
+
{ kind: "function", re: /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/ },
|
|
20
|
+
// No Python-specific class pattern needed: the generic TS `class` pattern above has no
|
|
21
|
+
// trailing-syntax requirement (no `{`/`:`), so it already matches Python's
|
|
22
|
+
// `class Foo(Bar):` header too, and — since declOf() returns on the first match —
|
|
23
|
+
// always wins for Python class lines before any Python-specific pattern would run.
|
|
19
24
|
];
|
|
25
|
+
import { languageFor } from "./languages.js";
|
|
20
26
|
const IMPORT_RE = /^\s*import\s+(?:[^'"]*from\s+)?['"]([^'"]+)['"]/;
|
|
21
27
|
const CONT_IMPORT_RE = /^\s*\}?\s*from\s+['"]([^'"]+)['"]/; // multi-line: "} from 'x'"
|
|
22
28
|
const REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/;
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
// "import os" / "import a.b.c" / "import os as o" / "import os, sys" / trailing "# comment".
|
|
30
|
+
// Anchored to the END of the line (optional "as alias", comma-separated modules, comment)
|
|
31
|
+
// so it matches a COMPLETE Python import statement only — this deliberately rejects
|
|
32
|
+
// TypeScript's `import Foo = Bar.Baz;` (import-equals), which would otherwise falsely
|
|
33
|
+
// look like a Python "import Foo" prefix match.
|
|
34
|
+
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*(?:#.*)?$/;
|
|
35
|
+
const PY_FROM_IMPORT_RE = /^\s*from\s+([.\w]+)\s+import\s+/; // "from os import path" / "from . import x"
|
|
36
|
+
const isCode = (p) => !!p && languageFor(p) !== null;
|
|
25
37
|
function declOf(line) {
|
|
26
38
|
for (const { kind, re } of DECL_PATTERNS) {
|
|
27
39
|
const m = re.exec(line);
|
|
@@ -31,7 +43,11 @@ function declOf(line) {
|
|
|
31
43
|
return null;
|
|
32
44
|
}
|
|
33
45
|
function importOf(line) {
|
|
34
|
-
const m = IMPORT_RE.exec(line) ??
|
|
46
|
+
const m = IMPORT_RE.exec(line) ??
|
|
47
|
+
CONT_IMPORT_RE.exec(line) ??
|
|
48
|
+
REQUIRE_RE.exec(line) ??
|
|
49
|
+
PY_FROM_IMPORT_RE.exec(line) ??
|
|
50
|
+
PY_IMPORT_RE.exec(line);
|
|
35
51
|
return m ? m[1] : null;
|
|
36
52
|
}
|
|
37
53
|
function stripAB(p) {
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
* then runs HunchStore.reindex() to refresh the SQLite index.
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync, statSync, readdirSync } from "node:fs";
|
|
11
|
-
import { join, relative, posix } from "node:path";
|
|
11
|
+
import { join, relative, dirname, posix } from "node:path";
|
|
12
12
|
import { parseSource, attributeCalls } from "./parse.js";
|
|
13
13
|
import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
|
|
14
14
|
import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
|
|
15
15
|
import { resolveRelativeImport } from "../core/relativeImports.js";
|
|
16
16
|
import { extracted, inferred } from "../core/types.js";
|
|
17
17
|
import { isGitRepo, trackedFiles, fileGitMetrics } from "./git.js";
|
|
18
|
-
|
|
18
|
+
import { CODE_EXTENSIONS, languageFor } from "./languages.js";
|
|
19
19
|
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".hunch", "coverage", ".next", "out"]);
|
|
20
20
|
export function indexRepo(store, root, opts = {}) {
|
|
21
21
|
const files = listCodeFiles(root);
|
|
@@ -84,9 +84,19 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
84
84
|
perFileImports.push({ file: rel, imports: parsed.imports });
|
|
85
85
|
}
|
|
86
86
|
const byId = new Map(symbols.map((s) => [s.id, s]));
|
|
87
|
+
// Language-aware import resolution, shared by the call-resolution "was this
|
|
88
|
+
// name actually imported?" gate (below) and the depends_on edge derivation
|
|
89
|
+
// (pass 3): a Python cross-file call/import must resolve through the same
|
|
90
|
+
// relative/absolute Python rules as everything else, not silently fail the
|
|
91
|
+
// JS/TS resolver and look unimported.
|
|
92
|
+
const hasSrcLayout = [...fileSymbols.keys()].some((f) => f.startsWith("src/"));
|
|
93
|
+
const pyRoots = hasSrcLayout ? ["", "src"] : [""];
|
|
94
|
+
const resolveImportTarget = (file, spec) => languageFor(file)?.id === "python"
|
|
95
|
+
? resolvePythonImport(file, spec, fileSymbols, pyRoots)
|
|
96
|
+
: resolveImport(file, spec, fileSymbols);
|
|
87
97
|
const importedFiles = new Map(perFileImports.map(({ file, imports }) => [
|
|
88
98
|
file,
|
|
89
|
-
new Set(imports.map((specifier) =>
|
|
99
|
+
new Set(imports.map((specifier) => resolveImportTarget(file, specifier)).filter((target) => !!target)),
|
|
90
100
|
]));
|
|
91
101
|
// ---- pass 2: resolve calls -> symbol-level edges -------------------------
|
|
92
102
|
const edges = [];
|
|
@@ -151,7 +161,7 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
151
161
|
if (!fromCmp)
|
|
152
162
|
continue;
|
|
153
163
|
for (const spec of imports) {
|
|
154
|
-
const target =
|
|
164
|
+
const target = resolveImportTarget(file, spec);
|
|
155
165
|
if (target) {
|
|
156
166
|
const toCmp = fileToComponent.get(target);
|
|
157
167
|
if (!toCmp || toCmp === fromCmp)
|
|
@@ -213,7 +223,7 @@ function listCodeFiles(root) {
|
|
|
213
223
|
if (isGitRepo(root)) {
|
|
214
224
|
// Apply SKIP_DIRS to the git-tracked list too: a repo that (accidentally)
|
|
215
225
|
// tracks node_modules/ or dist/ must not flood the graph with vendored symbols.
|
|
216
|
-
const tracked = trackedFiles(root,
|
|
226
|
+
const tracked = trackedFiles(root, CODE_EXTENSIONS)
|
|
217
227
|
.filter((f) => !f.split(/[\\/]/).some((seg) => SKIP_DIRS.has(seg)))
|
|
218
228
|
.map((f) => join(root, f));
|
|
219
229
|
if (tracked.length > 0)
|
|
@@ -228,7 +238,7 @@ function listCodeFiles(root) {
|
|
|
228
238
|
const st = statSync(abs);
|
|
229
239
|
if (st.isDirectory())
|
|
230
240
|
walk(abs);
|
|
231
|
-
else if (
|
|
241
|
+
else if (languageFor(name) !== null)
|
|
232
242
|
out.push(abs);
|
|
233
243
|
}
|
|
234
244
|
};
|
|
@@ -255,6 +265,55 @@ function resolveName(name, file, importedFiles, nameIndex, byId) {
|
|
|
255
265
|
function resolveImport(fromFile, spec, fileSymbols) {
|
|
256
266
|
return resolveRelativeImport(fromFile, spec, fileSymbols.keys()).path;
|
|
257
267
|
}
|
|
268
|
+
/** First of `${modulePath}.py` / `${modulePath}/__init__.py` that's a tracked file,
|
|
269
|
+
* or null — the shared "module file vs. package __init__" candidate check used by
|
|
270
|
+
* both resolvePythonImport branches below. */
|
|
271
|
+
function firstExistingPyModule(modulePath, fileSymbols) {
|
|
272
|
+
const candidates = [`${modulePath}.py`, `${modulePath}/__init__.py`];
|
|
273
|
+
for (const c of candidates)
|
|
274
|
+
if (fileSymbols.has(c))
|
|
275
|
+
return c;
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
/** Resolve a Python import specifier (relative or absolute) to a concrete tracked
|
|
279
|
+
* file path. Sibling to resolveImport() — Python's leading dot means "N levels up
|
|
280
|
+
* from the importing module's own directory," not "a relative file-path fragment"
|
|
281
|
+
* the way JS/TS's `./`/`../` does. Absolute imports are resolved best-effort
|
|
282
|
+
* against `pyRoots` (repo root, plus a top-level `src/` layout if one exists) —
|
|
283
|
+
* no sys.path/PYTHONPATH emulation. A module's own package directory is always
|
|
284
|
+
* its containing directory, so relative resolution needs no repo-wide
|
|
285
|
+
* package-root search — only dot-counting from `fromFile`'s own location. */
|
|
286
|
+
function resolvePythonImport(fromFile, spec, fileSymbols, pyRoots) {
|
|
287
|
+
if (!spec.startsWith(".")) {
|
|
288
|
+
const specPath = spec.split(".").join("/");
|
|
289
|
+
for (const root of pyRoots) {
|
|
290
|
+
const modulePath = root ? `${root}/${specPath}` : specPath;
|
|
291
|
+
const found = firstExistingPyModule(modulePath, fileSymbols);
|
|
292
|
+
if (found)
|
|
293
|
+
return found;
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const level = spec.length - spec.replace(/^\.+/, "").length;
|
|
298
|
+
const tail = spec.slice(level);
|
|
299
|
+
const dir = toPosix(dirname(fromFile));
|
|
300
|
+
const segments = dir === "." ? [] : dir.split("/");
|
|
301
|
+
const pop = level - 1;
|
|
302
|
+
if (pop > segments.length)
|
|
303
|
+
return null; // import points above the repo root — don't guess
|
|
304
|
+
const baseSegments = pop > 0 ? segments.slice(0, segments.length - pop) : segments;
|
|
305
|
+
const baseDir = baseSegments.join("/");
|
|
306
|
+
if (!tail) {
|
|
307
|
+
// bare `.`/`..`/etc — `from . import x` only ever resolves to the package's
|
|
308
|
+
// own __init__.py (we track the module path, never the imported name itself,
|
|
309
|
+
// matching resolveImport()'s granularity for JS/TS named imports).
|
|
310
|
+
const initPy = baseDir ? `${baseDir}/__init__.py` : "__init__.py";
|
|
311
|
+
return fileSymbols.has(initPy) ? initPy : null;
|
|
312
|
+
}
|
|
313
|
+
const tailPath = tail.split(".").join("/");
|
|
314
|
+
const modulePath = baseDir ? `${baseDir}/${tailPath}` : tailPath;
|
|
315
|
+
return firstExistingPyModule(modulePath, fileSymbols);
|
|
316
|
+
}
|
|
258
317
|
/** Derive components from the directory layout: the directory immediately under
|
|
259
318
|
* `src/` (or the top-level dir) groups files into a module component. */
|
|
260
319
|
function deriveComponents(symbols) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language registry: one LanguageSpec per supported language, consumed by
|
|
3
|
+
* parse.ts (tree-sitter grammar/query dispatch), indexer.ts / diff.ts /
|
|
4
|
+
* synthesize.ts ("is this a code file?"). Adding a language is a new entry
|
|
5
|
+
* here (+ a new tree-sitter-* dependency), not edits scattered across those
|
|
6
|
+
* four files.
|
|
7
|
+
*/
|
|
8
|
+
import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
|
|
9
|
+
const TS_QUERY = `
|
|
10
|
+
(function_declaration name: (identifier) @fn.name) @fn.def
|
|
11
|
+
(generator_function_declaration name: (identifier) @fn.name) @fn.def
|
|
12
|
+
(method_definition name: (property_identifier) @method.name) @method.def
|
|
13
|
+
(class_declaration name: (type_identifier) @class.name) @class.def
|
|
14
|
+
(interface_declaration name: (type_identifier) @iface.name) @iface.def
|
|
15
|
+
(type_alias_declaration name: (type_identifier) @type.name) @type.def
|
|
16
|
+
(variable_declarator
|
|
17
|
+
name: (identifier) @arrow.name
|
|
18
|
+
value: [(arrow_function) (function_expression)]) @arrow.def
|
|
19
|
+
(import_statement source: (string) @import.src)
|
|
20
|
+
(call_expression function: (identifier) @call.id)
|
|
21
|
+
(call_expression function: (member_expression property: (property_identifier) @call.member))
|
|
22
|
+
`;
|
|
23
|
+
const TS_BUILTIN_METHODS = new Set([
|
|
24
|
+
"map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
|
|
25
|
+
"push", "pop", "shift", "unshift", "slice", "splice", "concat", "join", "split", "flat", "flatMap",
|
|
26
|
+
"indexOf", "lastIndexOf", "keys", "values", "entries", "sort", "reverse", "fill", "at",
|
|
27
|
+
"get", "set", "has", "add", "delete", "clear",
|
|
28
|
+
"then", "catch", "finally", "all", "race", "resolve", "reject",
|
|
29
|
+
"toString", "valueOf", "toJSON", "hasOwnProperty",
|
|
30
|
+
"replace", "replaceAll", "trim", "trimStart", "trimEnd", "padStart", "padEnd", "startsWith", "endsWith",
|
|
31
|
+
"toLowerCase", "toUpperCase", "charAt", "charCodeAt", "substring", "substr", "repeat", "match", "matchAll",
|
|
32
|
+
"call", "apply", "bind", "test", "exec", "now", "parse", "stringify", "from", "of", "isArray", "assign",
|
|
33
|
+
"log", "error", "warn", "info", "debug",
|
|
34
|
+
]);
|
|
35
|
+
const TS_SHARED = {
|
|
36
|
+
id: "typescript",
|
|
37
|
+
query: TS_QUERY,
|
|
38
|
+
defNodeTypes: new Set([
|
|
39
|
+
"function_declaration", "generator_function_declaration", "method_definition",
|
|
40
|
+
"class_declaration", "interface_declaration", "type_alias_declaration", "variable_declarator",
|
|
41
|
+
]),
|
|
42
|
+
defKindOf: {
|
|
43
|
+
"fn.def": "function", "method.def": "method", "class.def": "class",
|
|
44
|
+
"iface.def": "interface", "type.def": "type", "arrow.def": "function",
|
|
45
|
+
},
|
|
46
|
+
nameToDef: {
|
|
47
|
+
"fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def",
|
|
48
|
+
"iface.name": "iface.def", "type.name": "type.def", "arrow.name": "arrow.def",
|
|
49
|
+
},
|
|
50
|
+
builtinMethods: TS_BUILTIN_METHODS,
|
|
51
|
+
};
|
|
52
|
+
const TYPESCRIPT = {
|
|
53
|
+
...TS_SHARED,
|
|
54
|
+
extensions: [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"],
|
|
55
|
+
grammarKey: "ts",
|
|
56
|
+
loadGrammar: () => loadNativeTreeSitter().typescript,
|
|
57
|
+
};
|
|
58
|
+
/** .tsx/.jsx use the TSX grammar variant; everything else in the TS spec uses
|
|
59
|
+
* the plain typescript grammar. Both share the same query/def maps/builtins,
|
|
60
|
+
* so this is a second LanguageSpec entry with a distinct grammarKey/loadGrammar
|
|
61
|
+
* only — not a second `id` (languageFor callers only care about extension match). */
|
|
62
|
+
const TSX = {
|
|
63
|
+
...TS_SHARED,
|
|
64
|
+
extensions: [".tsx", ".jsx"],
|
|
65
|
+
grammarKey: "tsx",
|
|
66
|
+
loadGrammar: () => loadNativeTreeSitter().tsx,
|
|
67
|
+
};
|
|
68
|
+
const PY_QUERY = `
|
|
69
|
+
(class_definition
|
|
70
|
+
name: (identifier) @class.name
|
|
71
|
+
body: (block
|
|
72
|
+
[
|
|
73
|
+
(function_definition name: (identifier) @method.name) @method.def
|
|
74
|
+
(decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
|
|
75
|
+
])) @class.def
|
|
76
|
+
(function_definition name: (identifier) @fn.name) @fn.def
|
|
77
|
+
(import_statement name: (dotted_name) @import.src)
|
|
78
|
+
(import_statement name: (aliased_import name: (dotted_name) @import.src))
|
|
79
|
+
(import_from_statement module_name: (dotted_name) @import.src)
|
|
80
|
+
(import_from_statement module_name: (relative_import) @import.src)
|
|
81
|
+
(call function: (identifier) @call.id)
|
|
82
|
+
(call function: (attribute attribute: (identifier) @call.member))
|
|
83
|
+
`;
|
|
84
|
+
const PY_BUILTIN_METHODS = new Set([
|
|
85
|
+
"get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy", "clear",
|
|
86
|
+
"append", "extend", "insert", "remove", "reverse", "sort", "count", "index",
|
|
87
|
+
"add", "discard", "union", "intersection", "difference",
|
|
88
|
+
"format", "join", "split", "rsplit", "splitlines", "strip", "lstrip", "rstrip",
|
|
89
|
+
"startswith", "endswith", "replace", "find", "rfind", "lower", "upper", "title", "capitalize",
|
|
90
|
+
"encode", "decode", "isdigit", "isalpha", "isalnum", "isspace",
|
|
91
|
+
"read", "write", "close", "open", "readline", "readlines",
|
|
92
|
+
"run", "wait", "poll", "communicate",
|
|
93
|
+
]);
|
|
94
|
+
const PYTHON = {
|
|
95
|
+
id: "python",
|
|
96
|
+
extensions: [".py", ".pyi"],
|
|
97
|
+
grammarKey: "python",
|
|
98
|
+
loadGrammar: () => loadNativeTreeSitter().python,
|
|
99
|
+
query: PY_QUERY,
|
|
100
|
+
defNodeTypes: new Set(["function_definition", "class_definition"]),
|
|
101
|
+
defKindOf: { "fn.def": "function", "method.def": "method", "class.def": "class" },
|
|
102
|
+
nameToDef: { "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def" },
|
|
103
|
+
builtinMethods: PY_BUILTIN_METHODS,
|
|
104
|
+
};
|
|
105
|
+
export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON];
|
|
106
|
+
export const CODE_EXTENSIONS = [...new Set(LANGUAGES.flatMap((l) => l.extensions))];
|
|
107
|
+
export function languageFor(file) {
|
|
108
|
+
for (const lang of LANGUAGES) {
|
|
109
|
+
if (lang.extensions.some((ext) => file.endsWith(ext)))
|
|
110
|
+
return lang;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=languages.js.map
|
|
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
const runtimeRequire = createRequire(import.meta.url);
|
|
6
6
|
const COPY_PREFIX = "hunch-tree-sitter-";
|
|
7
|
-
const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript"];
|
|
7
|
+
const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python"];
|
|
8
8
|
let runtime = null;
|
|
9
9
|
function processIsAlive(pid) {
|
|
10
10
|
if (pid === process.pid)
|
|
@@ -56,14 +56,15 @@ function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
|
|
|
56
56
|
copyFileSync(source, destination);
|
|
57
57
|
return packageCopy;
|
|
58
58
|
}
|
|
59
|
-
/** Load
|
|
60
|
-
* keeps loaded `.node` files locked for the
|
|
61
|
-
* upstream loaders means npm can replace the
|
|
62
|
-
* MCP session without killing that session
|
|
59
|
+
/** Load all native tree-sitter addons (the parser runtime + every grammar) from
|
|
60
|
+
* process-owned temp copies. Windows keeps loaded `.node` files locked for the
|
|
61
|
+
* process lifetime; redirecting the upstream loaders means npm can replace the
|
|
62
|
+
* installed package during an active MCP session without killing that session
|
|
63
|
+
* or falling back to a stale binary. */
|
|
63
64
|
export function loadNativeTreeSitter() {
|
|
64
65
|
if (runtime)
|
|
65
66
|
return runtime;
|
|
66
|
-
const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /tree-sitter(?:-typescript)?\.node$/.test(path)
|
|
67
|
+
const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /tree-sitter(?:-typescript|-python)?\.node$/.test(path)
|
|
67
68
|
&& !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
|
|
68
69
|
if (preloaded.length) {
|
|
69
70
|
throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
|
|
@@ -80,7 +81,8 @@ export function loadNativeTreeSitter() {
|
|
|
80
81
|
}
|
|
81
82
|
const Parser = runtimeRequire("tree-sitter");
|
|
82
83
|
const languages = runtimeRequire("tree-sitter-typescript");
|
|
83
|
-
|
|
84
|
+
const python = runtimeRequire("tree-sitter-python");
|
|
85
|
+
runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python };
|
|
84
86
|
}
|
|
85
87
|
catch (error) {
|
|
86
88
|
try {
|