@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/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 subscription CLI 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 subscription call; advisory)")
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 subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
326
- .option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
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 subscription choice) -------------------
2756
+ // ---- provider (per-user synthesis provider choice) -----------------------
2748
2757
  program
2749
2758
  .command("provider")
2750
- .description("Show or set the local coding-assistant subscription Hunch may use for synthesis. Never changes team config.")
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 subscription CLIs are available, so Hunch uses the free deterministic fallback rather than guessing which plan to spend."));
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 harness (subscription CLI) — feature-detected.
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 subscription CLI available — relevance judgment skipped (dedup + grounding only)."));
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 a subscription CLI 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.")
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: subscription CLI
3848
- // only (same rule as synthesis), feature-detected, and any failure degrades
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} (subscription); the drift-bearing skeleton stays deterministic.`);
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 subscription CLI available — deterministic template pages${opts.proseHeal ? " (prose-heal skipped)" : ""}.`);
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
- const selected = resolution.statuses.find((s) => s.name === provider.name);
4091
- if (selected?.subscription) {
4092
- console.log(` ↳ LLM synthesis uses your ${selected.subscription}; provider API credentials are not used.`);
4093
- }
4094
- else if (resolution.source === "ambiguous") {
4095
- const names = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => s.name);
4096
- console.log(dim(` ↳ ${names.join(", ")} are available; Hunch will not guess which subscription to spend.`));
4097
- console.log(dim(` choose one locally: ${names.map((name) => `hunch provider ${name}`).join(" or ")}`));
4098
- }
4099
- else if (resolution.source === "unavailable-preference") {
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;
@@ -1,8 +1,61 @@
1
- /** Figures out how to re-invoke this CLI from a git hook / .mcp.json, working
2
- * both when running the built dist (plain node) and in dev via tsx. */
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");
@@ -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
- const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
24
- const isCode = (p) => !!p && CODE_EXT.test(p);
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) ?? CONT_IMPORT_RE.exec(line) ?? REQUIRE_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
- const CODE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
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) => resolveImport(file, specifier, fileSymbols)).filter((target) => !!target)),
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 = resolveImport(file, spec, fileSymbols);
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, CODE_EXTS)
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 (CODE_EXTS.some((e) => name.endsWith(e)))
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 both native tree-sitter addons from process-owned temp copies. Windows
60
- * keeps loaded `.node` files locked for the process lifetime; redirecting the
61
- * upstream loaders means npm can replace the installed package during an active
62
- * MCP session without killing that session or falling back to a stale binary. */
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
- runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx };
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 {