@davesheffer/hunch 1.14.0 → 1.16.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 CHANGED
@@ -14,7 +14,7 @@
14
14
  * doctor environment diagnostics
15
15
  */
16
16
  import "./preflight.js"; // MUST stay the first import — Node-version gate before node:sqlite loads
17
- import { chmodSync, existsSync, lstatSync, readFileSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
17
+ import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
18
18
  import { execFileSync, spawnSync } from "node:child_process";
19
19
  import { join, relative, dirname, basename, resolve, isAbsolute } from "node:path";
20
20
  import { tmpdir } from "node:os";
@@ -70,7 +70,9 @@ import { computeDrift } from "../core/drift.js";
70
70
  import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
71
71
  import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
72
72
  import { adoptProsePrompt } from "../wiki/adopt.js";
73
- import { topicCollisions, isInForce } from "../core/topics.js";
73
+ import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
74
+ import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
75
+ import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
74
76
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
75
77
  import { premiseEscalations } from "../core/premises.js";
76
78
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
@@ -2984,6 +2986,124 @@ program
2984
2986
  console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
2985
2987
  store.close();
2986
2988
  });
2989
+ // ---- import-adr (MADR/Nygard corpus import — the MADR bridge, import half) --
2990
+ program
2991
+ .command("import-adr")
2992
+ .description("Import an existing MADR/Nygard ADR corpus (docs/adr etc.) into the decision graph — deterministic, no LLM. Re-running updates the same records (ids derive from file paths).")
2993
+ .argument("[dir]", "ADR directory (default: probe docs/adr, docs/decisions, doc/adr, adr, docs/architecture/decisions)")
2994
+ .option("--dry-run", "parse and report what would be imported without writing")
2995
+ .option("--private", "write imported decisions into the private overlay instead of the committed store")
2996
+ .action((dirArg, opts) => {
2997
+ const { store, root } = storeFor();
2998
+ try {
2999
+ const dir = dirArg
3000
+ ? toPosixTarget(dirArg)
3001
+ : ADR_DIR_CANDIDATES.find((c) => existsSync(join(root, c)) && readdirSync(join(root, c)).some((f) => ADR_FILE_RE.test(f)));
3002
+ if (!dir || !existsSync(join(root, dir))) {
3003
+ return fail(dirArg ? `ADR directory not found: ${dirArg}` : `no ADR corpus found (probed: ${ADR_DIR_CANDIDATES.join(", ")})`);
3004
+ }
3005
+ const files = readdirSync(join(root, dir)).filter((f) => ADR_FILE_RE.test(f)).sort();
3006
+ if (!files.length)
3007
+ return fail(`no NNNN-slug.md ADR files in ${dir}`);
3008
+ const sources = files.map((f) => ({ relPath: `${dir}/${f}`, text: readFileSync(join(root, dir, f), "utf8") }));
3009
+ const { decisions, warnings } = mapAdrCorpus(sources);
3010
+ for (const w of warnings)
3011
+ console.log(` ⚠ ${w}`);
3012
+ // Fail-safe topic anchoring: an import must never create a SECOND live
3013
+ // decision on a topic the graph already anchors — drop the anchor, keep
3014
+ // the record (visible, just not drift-anchored), and say so.
3015
+ const existing = store.recs("decisions").filter((d) => !decisions.some((n) => n.id === d.id));
3016
+ for (const d of decisions) {
3017
+ if (d.status !== "accepted" || !d.topic)
3018
+ continue;
3019
+ if (liveForTopic(existing, d.topic).length) {
3020
+ console.log(` ⚠ topic ${d.topic} already has a live decision in the graph — importing ${d.id} un-anchored`);
3021
+ d.topic = null;
3022
+ }
3023
+ }
3024
+ if (opts.dryRun) {
3025
+ for (const d of decisions) {
3026
+ const window = d.valid_to ? `${d.valid_from ?? "?"} → ${d.valid_to}` : "in force";
3027
+ console.log(` ${d.id} [${d.status}] ${d.title} (${window})`);
3028
+ }
3029
+ console.log(`✓ dry run: ${decisions.length} ADR(s) parsed from ${dir}, nothing written`);
3030
+ return;
3031
+ }
3032
+ store.json.ensureDirs();
3033
+ let created = 0, updated = 0;
3034
+ for (const d of decisions) {
3035
+ if (store.getRec("decisions", d.id))
3036
+ updated++;
3037
+ else
3038
+ created++;
3039
+ store.putCapture("decisions", d, opts.private);
3040
+ }
3041
+ store.reindex();
3042
+ const home = store.captureHome(!!opts.private);
3043
+ if (home === "public" && !store.autoCommit)
3044
+ refreshExistingGrounding(root, store);
3045
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: import ${decisions.length} ADR(s) from ${dir}`);
3046
+ const live = decisions.filter((d) => d.status === "accepted").length;
3047
+ console.log(`✓ imported ${decisions.length} ADR(s) from ${dir} (${created} new, ${updated} updated; ${live} live, ${decisions.length - live} historical)${opts.private ? " [private overlay]" : ""}`);
3048
+ if (flush === "pushed")
3049
+ console.log(" ↳ private memory committed + pushed");
3050
+ }
3051
+ finally {
3052
+ store.close();
3053
+ }
3054
+ });
3055
+ // ---- export-adr (MADR projection — the MADR bridge, export half) -----------
3056
+ program
3057
+ .command("export-adr")
3058
+ .description("Project the PUBLIC decision graph as a regenerated MADR 3.x corpus (a disposable build artifact — the graph stays the source of truth). Only files carrying the hunch:generated marker are ever overwritten.")
3059
+ .argument("[dir]", "output directory (repo-relative)", "docs/adr")
3060
+ .option("--dry-run", "render and report without writing")
3061
+ .action((dirArg, opts) => {
3062
+ const { store, root } = storeFor();
3063
+ try {
3064
+ const dir = toPosixTarget(dirArg);
3065
+ // PUBLIC store only — an exported artifact is committable, so overlay
3066
+ // records must never reach it (one-way privacy boundary).
3067
+ const decisions = store.json.loadAll("decisions");
3068
+ if (!decisions.length)
3069
+ return fail("no public decisions to export");
3070
+ const { files, backstageAnnotation } = exportMadrCorpus(decisions, dir);
3071
+ const outDir = join(root, dir);
3072
+ // The projection owns its directory outright: mixing generated output into
3073
+ // a hand-written ADR corpus would duplicate numbering and corrupt every
3074
+ // MADR reader of that dir. Any non-generated ADR-shaped file → refuse whole.
3075
+ const existingGenerated = new Set();
3076
+ if (existsSync(outDir)) {
3077
+ for (const f of readdirSync(outDir)) {
3078
+ if (!f.endsWith(".md"))
3079
+ continue;
3080
+ const text = readFileSync(join(outDir, f), "utf8");
3081
+ if (isRegenerableMadr(text))
3082
+ existingGenerated.add(f);
3083
+ else if (ADR_FILE_RE.test(f)) {
3084
+ return fail(`${dir} holds a hand-written ADR corpus (${f} has no hunch:generated marker) — export to a different directory (e.g. \`hunch export-adr docs/adr-generated\`), or import it first with \`hunch import-adr ${dir}\``);
3085
+ }
3086
+ }
3087
+ }
3088
+ const stale = [...existingGenerated].filter((f) => !files.some((n) => n.name === f));
3089
+ if (opts.dryRun) {
3090
+ console.log(`✓ dry run: would write ${files.length} ADR(s) to ${dir}${stale.length ? `, remove ${stale.length} stale generated file(s)` : ""}`);
3091
+ return;
3092
+ }
3093
+ mkdirSync(outDir, { recursive: true });
3094
+ for (const f of files)
3095
+ writeFileSync(join(outDir, f.name), f.text);
3096
+ // A regeneration renumbers; previously generated files not in the new set
3097
+ // are OURS (marker-verified) and stale — remove so the corpus stays coherent.
3098
+ for (const f of stale)
3099
+ rmSync(join(outDir, f));
3100
+ console.log(`✓ exported ${files.length} ADR(s) to ${dir}${stale.length ? `; removed ${stale.length} stale generated file(s)` : ""}`);
3101
+ console.log(` ↳ Backstage: add to catalog-info.yaml metadata.annotations → ${backstageAnnotation}`);
3102
+ }
3103
+ finally {
3104
+ store.close();
3105
+ }
3106
+ });
2987
3107
  // ---- record-constraint (human-authored invariant) -------------------------
2988
3108
  program
2989
3109
  .command("record-constraint")
@@ -0,0 +1,286 @@
1
+ /**
2
+ * MADR/Nygard ADR corpus import (roadmap: MADR bridge, import half).
3
+ *
4
+ * Deterministic, no-LLM: parse an existing `docs/adr`-style corpus into Decision
5
+ * records so an ADR-practicing repo gets a populated graph on day 1 instead of
6
+ * waiting for commit backfill. Mapping contract:
7
+ * accepted -> status accepted (live)
8
+ * proposed/draft -> status proposed
9
+ * rejected -> status rejected
10
+ * superseded/deprecated -> status superseded, valid_to closed (bi-temporal)
11
+ * "Considered Options" minus the chosen one -> alternatives_rejected
12
+ * file slug -> topic `adr.<slug>` (namespaced so an import can never
13
+ * collide with a live hand-captured topic; the CLI still
14
+ * drops the anchor entirely if a collision exists)
15
+ * provenance -> source "imported:madr", the ADR file as evidence
16
+ *
17
+ * Ids derive from the ADR's repo-relative path (decisionId("madr:<relPath>")),
18
+ * so re-importing an updated corpus is an idempotent per-record update, never a
19
+ * duplicate. Parsing is pure (string in, records out) — the CLI owns all IO.
20
+ */
21
+ import { decisionId } from "../core/ids.js";
22
+ /** Directories probed (in order) when no explicit dir is given — adr-tools'
23
+ * default (doc/adr), MADR's (docs/decisions), and the common variants. */
24
+ export const ADR_DIR_CANDIDATES = [
25
+ "docs/adr",
26
+ "docs/decisions",
27
+ "doc/adr",
28
+ "adr",
29
+ "docs/architecture/decisions",
30
+ ];
31
+ /** An ADR file is NNNN-slug.md (adr-tools / MADR convention). Templates and
32
+ * indexes (adr-template.md, README.md, index.md) never match. */
33
+ export const ADR_FILE_RE = /^(\d{1,5})-([a-z0-9][a-z0-9._-]*)\.md$/i;
34
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
35
+ function frontmatterField(fm, key) {
36
+ const m = new RegExp(`^${key}:[ \\t]*(.+)$`, "mi").exec(fm);
37
+ if (!m)
38
+ return null;
39
+ return m[1].trim().replace(/^["']|["']$/g, "");
40
+ }
41
+ /** Split a markdown body into `## `-heading sections (heading -> body text).
42
+ * `###` subsections stay inside their parent's body. */
43
+ function sections(body) {
44
+ const out = new Map();
45
+ const lines = body.split(/\r?\n/);
46
+ let current = null;
47
+ let buf = [];
48
+ const flush = () => {
49
+ if (current !== null)
50
+ out.set(current.toLowerCase(), buf.join("\n").trim());
51
+ buf = [];
52
+ };
53
+ for (const line of lines) {
54
+ const h = /^##\s+(.+?)\s*$/.exec(line);
55
+ if (h && !line.startsWith("###")) {
56
+ flush();
57
+ current = h[1];
58
+ }
59
+ else if (current !== null) {
60
+ buf.push(line);
61
+ }
62
+ }
63
+ flush();
64
+ return out;
65
+ }
66
+ function sectionOf(secs, ...names) {
67
+ for (const n of names) {
68
+ const v = secs.get(n.toLowerCase());
69
+ if (v)
70
+ return v;
71
+ }
72
+ return "";
73
+ }
74
+ /** Top-level `- ` / `* ` list items of a section (MADR "Considered Options"). */
75
+ function listItems(sectionText) {
76
+ const items = [];
77
+ for (const line of sectionText.split(/\r?\n/)) {
78
+ const m = /^[-*]\s+(.+)$/.exec(line.trim());
79
+ if (m)
80
+ items.push(stripMdLinks(m[1].trim()));
81
+ }
82
+ return items;
83
+ }
84
+ /** Consequences render either as a list or prose paragraphs; normalize to items. */
85
+ function consequenceItems(sectionText) {
86
+ const items = listItems(sectionText);
87
+ if (items.length)
88
+ return items.map((i) => i.replace(/^(good|bad|neutral),\s*(because\s*)?/i, "").trim()).filter(Boolean);
89
+ const prose = sectionText.trim();
90
+ return prose ? [stripMdLinks(prose)] : [];
91
+ }
92
+ function stripMdLinks(s) {
93
+ return s.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").trim();
94
+ }
95
+ /** ADR cross-reference numbers in a status/frontmatter phrase, e.g.
96
+ * "Superseded by [ADR-0007](0007-x.md)" or "supersedes 3". */
97
+ function refNumbers(text) {
98
+ const out = [];
99
+ for (const m of text.matchAll(/(?:adr[-\s]?)?0*(\d{1,5})\b/gi))
100
+ out.push(Number(m[1]));
101
+ return out;
102
+ }
103
+ function mapStatus(raw) {
104
+ const s = raw.toLowerCase();
105
+ if (/supersed|deprecat/.test(s))
106
+ return "superseded";
107
+ if (/reject/.test(s))
108
+ return "rejected";
109
+ if (/accept|approv/.test(s))
110
+ return "accepted";
111
+ return "proposed";
112
+ }
113
+ /** Parse one ADR markdown file (MADR 3.x/4 frontmatter style or Nygard heading
114
+ * style). Returns null when the filename doesn't follow NNNN-slug.md. */
115
+ export function parseAdrMarkdown(text, relPath) {
116
+ const base = relPath.split("/").pop() ?? relPath;
117
+ const nameMatch = ADR_FILE_RE.exec(base);
118
+ if (!nameMatch)
119
+ return null;
120
+ const number = Number(nameMatch[1]);
121
+ const slug = nameMatch[2].toLowerCase();
122
+ const fmMatch = FRONTMATTER_RE.exec(text);
123
+ const fm = fmMatch ? fmMatch[1] : "";
124
+ const body = fmMatch ? text.slice(fmMatch[0].length) : text;
125
+ const titleMatch = /^#\s+(.+?)\s*$/m.exec(body);
126
+ // Nygard titles carry the number ("1. Record architecture decisions") — strip it.
127
+ const title = (titleMatch ? titleMatch[1] : slug.replace(/[-_]/g, " ")).replace(/^\d+\.\s*/, "").trim();
128
+ const secs = sections(body);
129
+ const statusSection = sectionOf(secs, "Status");
130
+ const statusRaw = frontmatterField(fm, "status") ?? statusSection.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0] ?? "";
131
+ const status = mapStatus(statusRaw);
132
+ const date = frontmatterField(fm, "date");
133
+ // Cross-links live in the status phrase(s) and frontmatter supersede fields.
134
+ const statusText = `${statusRaw}\n${statusSection}`;
135
+ const supersededByNumbers = [];
136
+ const supersedesNumbers = [];
137
+ for (const line of statusText.split(/\r?\n/)) {
138
+ if (/superseded\s+by|deprecated\s+by|replaced\s+by/i.test(line))
139
+ supersededByNumbers.push(...refNumbers(line.replace(/.*?(?:by)/i, "")));
140
+ else if (/supersedes|replaces/i.test(line))
141
+ supersedesNumbers.push(...refNumbers(line.replace(/.*?(?:supersedes|replaces)/i, "")));
142
+ }
143
+ for (const key of ["superseded-by", "superseded_by"]) {
144
+ const v = frontmatterField(fm, key);
145
+ if (v)
146
+ supersededByNumbers.push(...refNumbers(v));
147
+ }
148
+ const fmSupersedes = frontmatterField(fm, "supersedes");
149
+ if (fmSupersedes)
150
+ supersedesNumbers.push(...refNumbers(fmSupersedes));
151
+ const decisionOutcome = sectionOf(secs, "Decision Outcome", "Decision");
152
+ const chosenMatch = /chosen option:\s*["“]?([^"”\n,]+)["”]?/i.exec(decisionOutcome);
153
+ const consideredOptions = listItems(sectionOf(secs, "Considered Options", "Options"));
154
+ // "### Consequences" nests inside Decision Outcome in MADR; Nygard has it top-level.
155
+ let consequencesText = sectionOf(secs, "Consequences");
156
+ if (!consequencesText) {
157
+ const nested = /###\s+Consequences\s*\r?\n([\s\S]*?)(?=\r?\n###\s|$)/i.exec(decisionOutcome);
158
+ if (nested)
159
+ consequencesText = nested[1].trim();
160
+ }
161
+ return {
162
+ relPath,
163
+ number,
164
+ slug,
165
+ title,
166
+ statusRaw,
167
+ status,
168
+ date,
169
+ context: stripMdLinks(sectionOf(secs, "Context and Problem Statement", "Context")).trim(),
170
+ decision: stripMdLinks(decisionOutcome.replace(/###\s+Consequences[\s\S]*$/i, "")).trim(),
171
+ consequences: consequenceItems(consequencesText),
172
+ consideredOptions,
173
+ chosenOption: chosenMatch ? chosenMatch[1].trim() : null,
174
+ supersedesNumbers: [...new Set(supersedesNumbers)],
175
+ supersededByNumbers: [...new Set(supersededByNumbers)],
176
+ };
177
+ }
178
+ export function adrDecisionId(relPath) {
179
+ return decisionId(`madr:${relPath}`);
180
+ }
181
+ /** Map a parsed corpus to Decision records. Pure: cross-links resolve by ADR
182
+ * number WITHIN the given corpus only; unresolvable references become warnings,
183
+ * never guessed ids. Bi-temporal closure: a superseded ADR's valid_to is its
184
+ * successor's valid_from (falling back to the successor's date, then its own
185
+ * date) so as-of queries see the corpus's real history. */
186
+ export function mapAdrCorpus(sources) {
187
+ const warnings = [];
188
+ const parsed = [];
189
+ for (const src of sources) {
190
+ const p = parseAdrMarkdown(src.text, src.relPath);
191
+ if (!p) {
192
+ warnings.push(`${src.relPath}: filename does not follow NNNN-slug.md — skipped`);
193
+ continue;
194
+ }
195
+ parsed.push(p);
196
+ }
197
+ const byNumber = new Map();
198
+ for (const p of parsed) {
199
+ if (byNumber.has(p.number))
200
+ warnings.push(`duplicate ADR number ${p.number}: ${byNumber.get(p.number).relPath} and ${p.relPath} — cross-links resolve to the first`);
201
+ else
202
+ byNumber.set(p.number, p);
203
+ }
204
+ // Derive the missing half of each supersede link so a one-sided "Superseded by"
205
+ // still closes windows and sets both pointers.
206
+ for (const p of parsed) {
207
+ for (const n of p.supersedesNumbers) {
208
+ const target = byNumber.get(n);
209
+ if (!target) {
210
+ warnings.push(`${p.relPath}: supersedes ADR ${n}, which is not in the corpus`);
211
+ continue;
212
+ }
213
+ if (!target.supersededByNumbers.includes(p.number))
214
+ target.supersededByNumbers.push(p.number);
215
+ }
216
+ for (const n of p.supersededByNumbers) {
217
+ const successor = byNumber.get(n);
218
+ if (!successor) {
219
+ warnings.push(`${p.relPath}: superseded by ADR ${n}, which is not in the corpus`);
220
+ continue;
221
+ }
222
+ if (!successor.supersedesNumbers.includes(p.number))
223
+ successor.supersedesNumbers.push(p.number);
224
+ }
225
+ }
226
+ const decisions = parsed.map((p) => {
227
+ const successor = p.supersededByNumbers.map((n) => byNumber.get(n)).find(Boolean) ?? null;
228
+ const superseded = p.status === "superseded" || !!successor;
229
+ const validTo = superseded ? (successor?.date ?? p.date) : null;
230
+ const alternatives = p.consideredOptions.filter((o) => !p.chosenOption || o.toLowerCase() !== p.chosenOption.toLowerCase());
231
+ return {
232
+ id: adrDecisionId(p.relPath),
233
+ title: p.title,
234
+ topic: `adr.${p.slug}`,
235
+ status: superseded ? "superseded" : p.status,
236
+ context: p.context,
237
+ decision: p.decision || p.title,
238
+ consequences: p.consequences,
239
+ alternatives_rejected: alternatives,
240
+ rejected_tripwires: [],
241
+ related_components: [],
242
+ related_files: [p.relPath],
243
+ supersedes: p.supersedesNumbers.map((n) => byNumber.get(n)).filter(Boolean).map((t) => adrDecisionId(t.relPath))[0] ?? null,
244
+ superseded_by: successor ? adrDecisionId(successor.relPath) : null,
245
+ caused_by_bug: null,
246
+ commit: null,
247
+ valid_from: p.date ?? undefined,
248
+ valid_to: validTo,
249
+ retired: { symbols: [], deps: [] },
250
+ provenance: {
251
+ source: "imported:madr",
252
+ confidence: 0.75,
253
+ evidence: [p.relPath, `status: ${p.statusRaw || "(none)"}`],
254
+ },
255
+ date: p.date ?? new Date().toISOString(),
256
+ };
257
+ });
258
+ // A corpus must not import two LIVE decisions onto one topic (the store-wide
259
+ // one-live-per-topic invariant): keep the highest ADR number live, close the rest.
260
+ const liveByTopic = new Map();
261
+ for (const d of decisions) {
262
+ if (d.status === "accepted" && d.topic) {
263
+ const list = liveByTopic.get(d.topic) ?? [];
264
+ list.push(d);
265
+ liveByTopic.set(d.topic, list);
266
+ }
267
+ }
268
+ for (const [topic, list] of liveByTopic) {
269
+ if (list.length < 2)
270
+ continue;
271
+ list.sort((a, b) => adrNumberOf(a, parsed) - adrNumberOf(b, parsed));
272
+ const winner = list[list.length - 1];
273
+ for (const loser of list.slice(0, -1)) {
274
+ loser.status = "superseded";
275
+ loser.superseded_by = winner.id;
276
+ loser.valid_to = winner.valid_from ?? loser.date;
277
+ warnings.push(`topic ${topic}: two live ADRs — kept ${winner.id} live, closed ${loser.id}`);
278
+ }
279
+ }
280
+ return { decisions, warnings };
281
+ }
282
+ function adrNumberOf(d, parsed) {
283
+ const rel = d.related_files[0];
284
+ return parsed.find((p) => p.relPath === rel)?.number ?? 0;
285
+ }
286
+ //# sourceMappingURL=adrImport.js.map
@@ -8,7 +8,8 @@
8
8
  * `indexRepo` persists that exact scan into the JSON source of truth; its caller
9
9
  * then runs HunchStore.reindex() to refresh the SQLite index.
10
10
  */
11
- import { dirname, posix } from "node:path";
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join, posix } from "node:path";
12
13
  import { parseSource, attributeCalls } from "./parse.js";
13
14
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
14
15
  import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
@@ -135,9 +136,15 @@ export function scanRepo(store, root, opts = {}) {
135
136
  // JS/TS resolver and look unimported.
136
137
  const hasSrcLayout = [...fileSymbols.keys()].some((f) => f.startsWith("src/"));
137
138
  const pyRoots = hasSrcLayout ? ["", "src"] : [""];
138
- const resolveImportTarget = (file, spec) => languageFor(file)?.id === "python"
139
- ? resolvePythonImport(file, spec, fileSymbols, pyRoots)
140
- : resolveImport(file, spec, fileSymbols);
139
+ const goModule = readGoModulePath(root);
140
+ const resolveImportTarget = (file, spec) => {
141
+ const langId = languageFor(file)?.id;
142
+ if (langId === "python")
143
+ return resolvePythonImport(file, spec, fileSymbols, pyRoots);
144
+ if (langId === "go")
145
+ return resolveGoImport(spec, fileSymbols, goModule);
146
+ return resolveImport(file, spec, fileSymbols);
147
+ };
141
148
  const importedFiles = new Map(perFileImports.map(({ file, imports }) => [
142
149
  file,
143
150
  new Set(imports.map((specifier) => resolveImportTarget(file, specifier)).filter((target) => !!target)),
@@ -353,6 +360,48 @@ function resolvePythonImport(fromFile, spec, fileSymbols, pyRoots) {
353
360
  const modulePath = baseDir ? `${baseDir}/${tailPath}` : tailPath;
354
361
  return firstExistingPyModule(modulePath, fileSymbols);
355
362
  }
363
+ /** The `module` path declared in the repo's go.mod, or null. A resolution HINT
364
+ * only (it widens depends_on edge coverage); reading it best-effort from the
365
+ * filesystem never gates a scan. */
366
+ function readGoModulePath(root) {
367
+ try {
368
+ const match = /^module\s+(\S+)/m.exec(readFileSync(join(root, "go.mod"), "utf8"));
369
+ return match ? match[1] : null;
370
+ }
371
+ catch {
372
+ return null;
373
+ }
374
+ }
375
+ /** Lexicographically-first tracked .go file whose directory is exactly `dir`
376
+ * ("" = repo root) — a Go import names a PACKAGE (directory), so any file in it
377
+ * identifies the right component for a depends_on edge. */
378
+ function firstGoFileInDir(dir, fileSymbols) {
379
+ let best = null;
380
+ for (const f of fileSymbols.keys()) {
381
+ if (!f.endsWith(".go"))
382
+ continue;
383
+ const d = toPosix(dirname(f));
384
+ const matches = dir === "" ? d === "." : d === dir;
385
+ if (matches && (!best || f < best))
386
+ best = f;
387
+ }
388
+ return best;
389
+ }
390
+ /** Resolve a Go import path to a tracked file. Sibling to resolvePythonImport():
391
+ * an in-module import is the go.mod module path plus the package directory, so
392
+ * strip the declared module prefix and look the directory up exactly; with no
393
+ * go.mod, try the path as a repo-relative directory. Anything else (stdlib,
394
+ * external modules) resolves to null — no suffix guessing, a wrong depends_on
395
+ * edge is worse than a missing one. */
396
+ function resolveGoImport(spec, fileSymbols, goModule) {
397
+ if (goModule) {
398
+ if (spec === goModule)
399
+ return firstGoFileInDir("", fileSymbols);
400
+ if (spec.startsWith(`${goModule}/`))
401
+ return firstGoFileInDir(spec.slice(goModule.length + 1), fileSymbols);
402
+ }
403
+ return firstGoFileInDir(spec, fileSymbols);
404
+ }
356
405
  /** Derive components from the directory layout: the directory immediately under
357
406
  * `src/` (or the top-level dir) groups files into a module component. */
358
407
  function deriveComponents(symbols) {
@@ -125,7 +125,55 @@ const PYTHON = {
125
125
  nameToDef: { "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def" },
126
126
  builtinMethods: PY_BUILTIN_METHODS,
127
127
  };
128
- export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON];
128
+ const GO_QUERY = `
129
+ (function_declaration name: (identifier) @fn.name) @fn.def
130
+ (method_declaration name: (field_identifier) @method.name) @method.def
131
+ ;; Specific type_spec shapes FIRST: parse.ts keeps the first classification a
132
+ ;; node id receives (same mechanism the Python class patterns rely on), so a
133
+ ;; struct/interface matches its specific pattern before the generic @type.def.
134
+ (type_spec name: (type_identifier) @struct.name type: (struct_type)) @struct.def
135
+ (type_spec name: (type_identifier) @iface.name type: (interface_type)) @iface.def
136
+ (type_spec name: (type_identifier) @type.name) @type.def
137
+ ;; \`type X = Y\` is a distinct type_alias node, not a type_spec.
138
+ (type_alias name: (type_identifier) @type.name) @type.def
139
+ (import_spec path: [(interpreted_string_literal) (raw_string_literal)] @import.src)
140
+ (call_expression function: (identifier) @call.id)
141
+ (call_expression function: (selector_expression field: (field_identifier) @call.member))
142
+ `;
143
+ /** In Go every package-qualified call (fmt.Println, strings.Split, t.Errorf) is a
144
+ * selector_expression and therefore lands in @call.member — the DOMINANT call form.
145
+ * This allowlist filters the highest-frequency stdlib/testing method+function names
146
+ * so they never create false edges to same-named repo symbols; repo-specific member
147
+ * calls (s.Run(), h.Handle()) pass through and resolve conservatively like TS/PY. */
148
+ const GO_BUILTIN_METHODS = new Set([
149
+ "Error", "String", "Read", "Write", "Close", "Len", "Cap", "Reset", "Bytes", "Text",
150
+ "Scan", "Next", "Err", "Lock", "Unlock", "RLock", "RUnlock", "Done", "Add", "Wait",
151
+ "Print", "Printf", "Println", "Sprintf", "Fprintf", "Errorf", "Fatal", "Fatalf", "Fatalln",
152
+ "Log", "Logf", "Helper", "Run", "Parallel", "Skip", "Skipf", "Cleanup",
153
+ "Get", "Set", "Delete", "Load", "Store", "Range", "Value", "Context", "Deadline",
154
+ "Marshal", "Unmarshal", "Encode", "Decode", "Parse", "Format", "Sub", "Before", "After",
155
+ "Join", "Split", "Contains", "Replace", "ReplaceAll", "TrimSpace", "ToLower", "ToUpper",
156
+ "HasPrefix", "HasSuffix", "WriteString", "ReadString", "ReadAll", "Copy", "New", "Now",
157
+ "Since", "Sleep", "Unix", "Exec", "Query", "QueryRow", "Begin", "Commit", "Rollback",
158
+ ]);
159
+ const GO = {
160
+ id: "go",
161
+ extensions: [".go"],
162
+ grammarKey: "go",
163
+ loadGrammar: () => loadNativeTreeSitter().go,
164
+ query: GO_QUERY,
165
+ defNodeTypes: new Set(["function_declaration", "method_declaration", "type_spec", "type_alias"]),
166
+ defKindOf: {
167
+ "fn.def": "function", "method.def": "method", "struct.def": "class",
168
+ "iface.def": "interface", "type.def": "type",
169
+ },
170
+ nameToDef: {
171
+ "fn.name": "fn.def", "method.name": "method.def", "struct.name": "struct.def",
172
+ "iface.name": "iface.def", "type.name": "type.def",
173
+ },
174
+ builtinMethods: GO_BUILTIN_METHODS,
175
+ };
176
+ export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON, GO];
129
177
  export const CODE_EXTENSIONS = [...new Set(LANGUAGES.flatMap((l) => l.extensions))];
130
178
  export function languageFor(file) {
131
179
  for (const lang of LANGUAGES) {
@@ -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", "tree-sitter-python"];
7
+ const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python", "tree-sitter-go"];
8
8
  let runtime = null;
9
9
  function processIsAlive(pid) {
10
10
  if (pid === process.pid)
@@ -69,7 +69,7 @@ export function loadNativeTreeSitter() {
69
69
  // underscores (tree_sitter_runtime_binding.node, tree_sitter_python_binding.node,
70
70
  // …). Missing the underscore names let an already-loaded source-built addon slip
71
71
  // past this guard and defeat the file-lock isolation entirely (issue #52).
72
- const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
72
+ const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python|-go)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
73
73
  && !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
74
74
  if (preloaded.length) {
75
75
  throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
@@ -87,7 +87,8 @@ export function loadNativeTreeSitter() {
87
87
  const Parser = runtimeRequire("tree-sitter");
88
88
  const languages = runtimeRequire("tree-sitter-typescript");
89
89
  const python = runtimeRequire("tree-sitter-python");
90
- runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python };
90
+ const go = runtimeRequire("tree-sitter-go");
91
+ runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python, go };
91
92
  }
92
93
  catch (error) {
93
94
  try {
@@ -0,0 +1,109 @@
1
+ export const MADR_EXPORT_MARKER = "<!-- hunch:generated madr-export — regenerated by `hunch export-adr`; edits will be overwritten -->";
2
+ function slugify(title) {
3
+ const s = title
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, "-")
6
+ .replace(/^-+|-+$/g, "")
7
+ .slice(0, 60)
8
+ .replace(/-+$/, "");
9
+ return s || "decision";
10
+ }
11
+ function statusLine(d, nameOf) {
12
+ if (d.status === "superseded") {
13
+ const successor = d.superseded_by ? nameOf.get(d.superseded_by) : null;
14
+ return successor ? `superseded by [${successor}](${successor})` : "superseded";
15
+ }
16
+ return d.status;
17
+ }
18
+ /** Render one decision as a MADR 3.x document. Deterministic: content depends
19
+ * only on the decision record and the corpus's name map. */
20
+ export function renderMadr(d, nameOf) {
21
+ const lines = [];
22
+ lines.push("---");
23
+ lines.push(`status: ${statusLine(d, nameOf)}`);
24
+ const date = (d.valid_from ?? d.date).slice(0, 10);
25
+ lines.push(`date: ${date}`);
26
+ lines.push("---");
27
+ lines.push("");
28
+ lines.push(MADR_EXPORT_MARKER);
29
+ lines.push("");
30
+ lines.push(`# ${d.title}`);
31
+ if (d.context) {
32
+ lines.push("");
33
+ lines.push("## Context and Problem Statement");
34
+ lines.push("");
35
+ lines.push(d.context);
36
+ }
37
+ if (d.alternatives_rejected.length) {
38
+ lines.push("");
39
+ lines.push("## Considered Options");
40
+ lines.push("");
41
+ lines.push(`- ${d.title}`);
42
+ for (const alt of d.alternatives_rejected)
43
+ lines.push(`- ${alt}`);
44
+ }
45
+ lines.push("");
46
+ lines.push("## Decision Outcome");
47
+ lines.push("");
48
+ // Canonical MADR phrasing so the projection round-trips through import-adr:
49
+ // the "Chosen option:" line is what marks the winner among Considered Options.
50
+ if (d.alternatives_rejected.length) {
51
+ lines.push(`Chosen option: "${d.title}", because of the following.`);
52
+ if (d.decision) {
53
+ lines.push("");
54
+ lines.push(d.decision);
55
+ }
56
+ }
57
+ else {
58
+ lines.push(d.decision || d.title);
59
+ }
60
+ if (d.supersedes && nameOf.get(d.supersedes)) {
61
+ lines.push("");
62
+ lines.push(`Supersedes [${nameOf.get(d.supersedes)}](${nameOf.get(d.supersedes)}).`);
63
+ }
64
+ if (d.consequences.length) {
65
+ lines.push("");
66
+ lines.push("### Consequences");
67
+ lines.push("");
68
+ for (const c of d.consequences)
69
+ lines.push(`- ${c}`);
70
+ }
71
+ lines.push("");
72
+ lines.push(`<!-- source: ${d.id}${d.commit ? ` @ ${d.commit}` : ""} -->`);
73
+ lines.push("");
74
+ return lines.join("\n");
75
+ }
76
+ /** Project a PUBLIC decision list into a MADR corpus. Numbering is date order
77
+ * (valid_from, then date, then id for determinism) and assigned per export —
78
+ * the projection is disposable, links inside it are internally consistent. */
79
+ export function exportMadrCorpus(decisions, dirForAnnotation) {
80
+ const sorted = [...decisions].sort((a, b) => {
81
+ const ka = `${a.valid_from ?? a.valid_to ?? a.date} ${a.valid_to ? 0 : 1} ${a.id}`;
82
+ const kb = `${b.valid_from ?? b.valid_to ?? b.date} ${b.valid_to ? 0 : 1} ${b.id}`;
83
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
84
+ });
85
+ const nameOf = new Map();
86
+ const taken = new Set();
87
+ sorted.forEach((d, i) => {
88
+ let slug = slugify(d.title);
89
+ while (taken.has(slug))
90
+ slug = `${slug}-${d.id.slice(4, 10)}`;
91
+ taken.add(slug);
92
+ nameOf.set(d.id, `${String(i + 1).padStart(4, "0")}-${slug}.md`);
93
+ });
94
+ const files = sorted.map((d) => ({
95
+ name: nameOf.get(d.id),
96
+ text: renderMadr(d, nameOf),
97
+ decisionId: d.id,
98
+ }));
99
+ return {
100
+ files,
101
+ backstageAnnotation: `backstage.io/adr-location: ${dirForAnnotation}`,
102
+ };
103
+ }
104
+ /** True when an existing file may be overwritten by the export: only our own
105
+ * generated output ever qualifies. */
106
+ export function isRegenerableMadr(existingText) {
107
+ return existingText.includes(MADR_EXPORT_MARKER);
108
+ }
109
+ //# sourceMappingURL=madrExport.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
@@ -74,6 +74,7 @@
74
74
  "@modelcontextprotocol/sdk": "^1.29.0",
75
75
  "commander": "^15.0.0",
76
76
  "tree-sitter": "0.21.1",
77
+ "tree-sitter-go": "^0.23.4",
77
78
  "tree-sitter-python": "^0.23.2",
78
79
  "tree-sitter-typescript": "^0.23.2",
79
80
  "zod": "^4.4.3"
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://hunch-pi.vercel.app",
10
- "version": "1.14.0",
10
+ "version": "1.16.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.14.0",
16
+ "version": "1.16.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {