@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 +122 -2
- package/dist/extractors/adrImport.js +286 -0
- package/dist/extractors/indexer.js +53 -4
- package/dist/extractors/languages.js +49 -1
- package/dist/extractors/nativeTreeSitter.js +4 -3
- package/dist/integrations/madrExport.js +109 -0
- package/package.json +2 -1
- package/server.json +2 -2
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 {
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
16
|
+
"version": "1.16.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|