@davesheffer/hunch 1.10.1 → 1.10.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 +54 -24
- package/dist/core/conformance.js +19 -5
- package/dist/core/constraintmatch.js +11 -3
- package/dist/core/docscan.js +10 -0
- package/dist/extractors/diff.js +13 -5
- package/dist/extractors/git.js +16 -0
- package/dist/extractors/languages.js +41 -28
- package/dist/integrations/claudemd.js +11 -0
- package/dist/integrations/providers.js +39 -11
- package/dist/mcp/server.js +9 -0
- package/dist/wiki/wiki.js +89 -9
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -3259,26 +3259,29 @@ program
|
|
|
3259
3259
|
console.log(" Strict mode fails closed because an omitted source file could hide a semantic violation.");
|
|
3260
3260
|
}
|
|
3261
3261
|
}
|
|
3262
|
-
if (!files.length) {
|
|
3263
|
-
console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
|
|
3264
|
-
if (teamFreshnessFailure)
|
|
3265
|
-
fail(teamFreshnessFailure);
|
|
3266
|
-
if (opts.strict && semanticIssues.length)
|
|
3267
|
-
process.exitCode = 1;
|
|
3268
|
-
store.close();
|
|
3269
|
-
return;
|
|
3270
|
-
}
|
|
3271
3262
|
// DIRECT (scope match) + NEAR (blast radius) + REGRESSION (re-added retired
|
|
3272
3263
|
// code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
|
|
3273
3264
|
// hardened strict gate + causal `why` citations — all assembled by the shared
|
|
3274
3265
|
// store.buildCheckReport (also used by the hunch_merge_verdict tool).
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3266
|
+
//
|
|
3267
|
+
// A ZERO-FILE diff must NOT short-circuit the run: the per-file report has nothing
|
|
3268
|
+
// to say, but the GRAPH-WIDE gates below (Architectural Conformance and Constitution
|
|
3269
|
+
// policy) ask "does the code, right now, still satisfy recorded intent?" — a question
|
|
3270
|
+
// independent of what this diff touched. Returning early here made a delete-only PR a
|
|
3271
|
+
// vacuous green (deletions are excluded by the enumerators' --diff-filter=ACMR, so
|
|
3272
|
+
// such a PR enumerates zero files) — including a deletion of the very symbol a
|
|
3273
|
+
// blocking conformance predicate or an active policy guards.
|
|
3274
|
+
const diff = files.length
|
|
3275
|
+
? (exactCommit ? commitDiff(exactCommit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root))
|
|
3276
|
+
: "";
|
|
3277
|
+
const report = files.length
|
|
3278
|
+
? store.buildCheckReport(files, diff, {
|
|
3279
|
+
strict: !!opts.strict,
|
|
3280
|
+
lastChange: (f) => lastChangeDate(f, root),
|
|
3281
|
+
publicOnly: !!opts.publicOnly,
|
|
3282
|
+
})
|
|
3283
|
+
: emptyReport;
|
|
3284
|
+
if (opts.blast && !markdown && files.length) {
|
|
3282
3285
|
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
3283
3286
|
for (const f of files) {
|
|
3284
3287
|
const b = store.blastRadiusFiles(f);
|
|
@@ -3287,7 +3290,9 @@ program
|
|
|
3287
3290
|
}
|
|
3288
3291
|
console.log("");
|
|
3289
3292
|
}
|
|
3290
|
-
console.log(
|
|
3293
|
+
console.log(files.length
|
|
3294
|
+
? (markdown ? renderMarkdown(report) : renderText(report))
|
|
3295
|
+
: (markdown ? renderMarkdown(emptyReport) : "No changed files to check."));
|
|
3291
3296
|
// ARCHITECTURAL CONFORMANCE: does the RESULTING code still satisfy every recorded
|
|
3292
3297
|
// architectural invariant? This is graph-reachability, not a diff — so it catches semantic
|
|
3293
3298
|
// violations a pattern-matcher / SAST can't express (a controller that now reaches the DB
|
|
@@ -3741,19 +3746,29 @@ program
|
|
|
3741
3746
|
// When the prompt reads like a correction ("no / that's wrong / never X"),
|
|
3742
3747
|
// nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
|
|
3743
3748
|
// not just obey it this once and forget it next session.
|
|
3744
|
-
|
|
3749
|
+
const isCorrection = looksLikeCorrection(evt.prompt);
|
|
3750
|
+
let text = isCorrection ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
|
|
3751
|
+
// Payloads that must NEVER be deduped away. The dedup key hashes CONTENT, and
|
|
3752
|
+
// this content is assembled from constants — so two back-to-back corrections
|
|
3753
|
+
// produce byte-identical text and the second (usually the escalating one) was
|
|
3754
|
+
// silently swallowed, never becoming an enforced rule. Same for the unverified
|
|
3755
|
+
// nag, which is documented as the one nag that must repeat but rode the same
|
|
3756
|
+
// deduped payload and so fired once per streak.
|
|
3757
|
+
let mustDeliver = isCorrection;
|
|
3745
3758
|
// Pipeline turn bookkeeping (fresh block budget) + the one nag that must
|
|
3746
3759
|
// repeat: edits from an earlier turn still unverified.
|
|
3747
3760
|
if (evt.session_id && pipelineEnabled()) {
|
|
3748
3761
|
const st = onPrompt(loadPipelineState(evt.session_id));
|
|
3749
3762
|
savePipelineState(evt.session_id, st);
|
|
3750
|
-
if (!st.verifyAfterEdit)
|
|
3763
|
+
if (!st.verifyAfterEdit) {
|
|
3751
3764
|
text += `\n\n${UNVERIFIED_NAG}`;
|
|
3765
|
+
mustDeliver = true;
|
|
3766
|
+
}
|
|
3752
3767
|
}
|
|
3753
|
-
// Once per session is enough for the availability reminder — repeating it
|
|
3754
|
-
// every prompt burns context for zero information.
|
|
3755
|
-
//
|
|
3756
|
-
if (injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
|
|
3768
|
+
// Once per session is enough for the bare availability reminder — repeating it
|
|
3769
|
+
// every prompt burns context for zero information (dec_244397d920). Only that
|
|
3770
|
+
// ambient case is deduped.
|
|
3771
|
+
if (!mustDeliver && injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
|
|
3757
3772
|
return;
|
|
3758
3773
|
emitContext(provider, "UserPromptSubmit", text);
|
|
3759
3774
|
return;
|
|
@@ -4687,6 +4702,7 @@ program
|
|
|
4687
4702
|
.option("--no-llm", "skip LLM prose; deterministic template pages only")
|
|
4688
4703
|
.option("--prose-heal", "also LLM-rewrite each adopted copy's reconciled overview (subscription; the deterministic corrections always remain)")
|
|
4689
4704
|
.option("--private", "render the FULL graph (private overlay included) and write the wiki into the OVERLAY repo — nothing lands in this repo")
|
|
4705
|
+
.option("--private-prose", "opt in to LLM prose for a PRIVATE-split overlay wiki — this SENDS overlay records (decision text, private constraints, private bug root causes) to the configured provider. Off by default.")
|
|
4690
4706
|
.action(async (opts) => {
|
|
4691
4707
|
const { store, root } = storeFor();
|
|
4692
4708
|
try {
|
|
@@ -4752,7 +4768,21 @@ program
|
|
|
4752
4768
|
return fail("--prose-heal needs the LLM — drop --no-llm.");
|
|
4753
4769
|
let prose;
|
|
4754
4770
|
let adoptionProse;
|
|
4755
|
-
|
|
4771
|
+
// A PRIVATE-SPLIT overlay's packs carry the full union (source: "all") — overlay
|
|
4772
|
+
// decision context/rationale/rejected alternatives, private constraint statements,
|
|
4773
|
+
// private bug root causes. Sending that to an external subscription CLI would
|
|
4774
|
+
// silently break the storage-private promise every other path here enforces
|
|
4775
|
+
// (public-only CI comments, public-only grounding, public-only wiki manifests), in
|
|
4776
|
+
// the very command documented as the way to build the private wiki. So prose is OFF
|
|
4777
|
+
// by default for that home and needs an explicit --private-prose. A SHARED overlay
|
|
4778
|
+
// is deliberately excluded: there the team already routes captures through the
|
|
4779
|
+
// configured provider by recorded policy.
|
|
4780
|
+
const privateSplit = home.kind === "private" && store.mode === "private";
|
|
4781
|
+
const proseBlockedForPrivacy = privateSplit && !opts.privateProse;
|
|
4782
|
+
if (opts.llm !== false && proseBlockedForPrivacy) {
|
|
4783
|
+
console.log("Private overlay: LLM prose is OFF (pages would send overlay records to the configured provider). Deterministic template pages; pass --private-prose to opt in.");
|
|
4784
|
+
}
|
|
4785
|
+
if (opts.llm !== false && !proseBlockedForPrivacy) {
|
|
4756
4786
|
const provider = await selectProvider({ root });
|
|
4757
4787
|
if (provider.draftProse) {
|
|
4758
4788
|
console.log(`Prose via ${provider.name}; the drift-bearing skeleton stays deterministic.`);
|
package/dist/core/conformance.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { externalImportNodeId } from "./externalImports.js";
|
|
1
2
|
function resolveSymbols(graph, ref) {
|
|
2
3
|
const syms = graph.symbols;
|
|
3
4
|
if (ref.startsWith("sym_"))
|
|
@@ -48,17 +49,30 @@ function evalPredicate(graph, d, p) {
|
|
|
48
49
|
return { ...base, satisfied: false, detail: `subject "${p.subject}" not found in the graph — intent's subject is gone` };
|
|
49
50
|
const wantReach = p.assert === "calls" || p.assert === "imports";
|
|
50
51
|
const objects = p.object ? resolveSymbols(graph, p.object) : [];
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// An external package is a VIRTUAL graph node (`ext_<hash>`, see core/externalImports.ts),
|
|
53
|
+
// never a Symbol record — so resolveSymbols can NEVER find one. Without this, every
|
|
54
|
+
// `not-imports <symbol> <package>` predicate fell straight into the "target not found ⇒
|
|
55
|
+
// a forbidden relation trivially holds" branch below and reported ✅ forever, while the
|
|
56
|
+
// graph right beside it recorded the violating `imports` edge. Resolve the package from
|
|
57
|
+
// the edge set instead, which is where the indexer actually puts it.
|
|
58
|
+
const externalId = p.object ? externalImportNodeId(p.object) : null;
|
|
59
|
+
const objectIds = objects.length
|
|
60
|
+
? objects.map((o) => o.id)
|
|
61
|
+
: externalId && graph.edges.some((e) => e.to === externalId)
|
|
62
|
+
? [externalId]
|
|
63
|
+
: [];
|
|
64
|
+
if (!objectIds.length) {
|
|
65
|
+
// a required target gone ⇒ the link can't hold (violated); a forbidden one trivially
|
|
66
|
+
// holds — nothing can reach a node the graph does not contain.
|
|
53
67
|
return { ...base, satisfied: !wantReach, detail: `target "${p.object ?? ""}" not found in the graph` };
|
|
54
68
|
}
|
|
55
69
|
// A required relation cannot guess which same-name symbol carries the intent.
|
|
56
70
|
// Force qualification instead of accidentally proving a different binding.
|
|
57
|
-
if (wantReach && (subjects.length !== 1 ||
|
|
71
|
+
if (wantReach && (subjects.length !== 1 || objectIds.length !== 1)) {
|
|
58
72
|
return {
|
|
59
73
|
...base,
|
|
60
74
|
satisfied: false,
|
|
61
|
-
detail: `ambiguous required binding (${subjects.length} subject, ${
|
|
75
|
+
detail: `ambiguous required binding (${subjects.length} subject, ${objectIds.length} target matches) — qualify as file:symbol; intent VIOLATED`,
|
|
62
76
|
};
|
|
63
77
|
}
|
|
64
78
|
// A forbidden relation is conservative in the other direction: ANY matching
|
|
@@ -66,7 +80,7 @@ function evalPredicate(graph, d, p) {
|
|
|
66
80
|
// only the first target lets a duplicate symbol hide a violation.
|
|
67
81
|
const linked = subjects.some((subject) => {
|
|
68
82
|
const reached = reaches(graph, subject.id, p.transitive);
|
|
69
|
-
return
|
|
83
|
+
return objectIds.some((id) => reached.has(id));
|
|
70
84
|
});
|
|
71
85
|
const satisfied = wantReach ? linked : !linked;
|
|
72
86
|
const via = p.transitive ? " (transitively)" : "";
|
|
@@ -25,9 +25,17 @@ export function matchForbids(f, addedDeps, scopedAdded) {
|
|
|
25
25
|
// tripping an in-scope edit.
|
|
26
26
|
const hitDeps = [];
|
|
27
27
|
for (const dep of f.deps) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
// EVERY added specifier that satisfies the forbid, not just the first: one commit
|
|
29
|
+
// can add `lodash` in an out-of-scope file and `lodash/groupBy` in the scoped one.
|
|
30
|
+
// Taking only `.find`'s first match meant the out-of-scope spelling shadowed the
|
|
31
|
+
// real in-scope violation and the blocking rule failed OPEN — with the outcome
|
|
32
|
+
// depending on diff order, i.e. on an unrelated file's name.
|
|
33
|
+
for (const added of addedDeps) {
|
|
34
|
+
if (added !== dep && !added.startsWith(`${dep}/`))
|
|
35
|
+
continue;
|
|
36
|
+
if (codeLines.some((l) => importsDep(l, added)))
|
|
37
|
+
hitDeps.push(added);
|
|
38
|
+
}
|
|
31
39
|
}
|
|
32
40
|
if (hitDeps.length)
|
|
33
41
|
return { tier: "dep", evidence: hitDeps.map((d) => `+import ${d}`) };
|
package/dist/core/docscan.js
CHANGED
|
@@ -89,6 +89,16 @@ export function scanRepoDocs(decisions, root) {
|
|
|
89
89
|
else if (superseded && current && current.id !== a.pin) {
|
|
90
90
|
issues.push(`line ${a.line}: pinned to superseded ${a.pin}; current for "${a.topic}" is ${current.id}`);
|
|
91
91
|
}
|
|
92
|
+
else if (pinned.status === "rejected") {
|
|
93
|
+
// The ledger tells readers to "Trust ✅". A doc pinned to an approach the team
|
|
94
|
+
// explicitly REJECTED is the opposite of grounded — and the pre-edit hook, which
|
|
95
|
+
// only injects live decisions, already disagrees with the ✅ this used to award.
|
|
96
|
+
issues.push(`line ${a.line}: pinned to ${a.pin}, a REJECTED decision (topic "${a.topic}")${current ? `; current is ${current.id}` : ""}`);
|
|
97
|
+
}
|
|
98
|
+
else if (pinned.status === "proposed") {
|
|
99
|
+
// Roadmap intent, not an in-force answer: it neither grounds the doc nor makes
|
|
100
|
+
// it stale, so the doc falls to the honest "unverified" tier.
|
|
101
|
+
}
|
|
92
102
|
else if (!superseded) {
|
|
93
103
|
groundedPins++;
|
|
94
104
|
}
|
package/dist/extractors/diff.js
CHANGED
|
@@ -131,18 +131,26 @@ export function analyzeDiff(diff) {
|
|
|
131
131
|
}
|
|
132
132
|
// ---- inside a hunk: content lines ----
|
|
133
133
|
if (raw.startsWith("+")) {
|
|
134
|
-
if (!isCode(curFile))
|
|
135
|
-
continue;
|
|
136
|
-
addedLines++;
|
|
137
|
-
if (!curAdded && !curDeleted)
|
|
138
|
-
filesModified.add(curFile);
|
|
139
134
|
const body = raw.slice(1);
|
|
135
|
+
// Raw added lines are captured for EVERY file, before the code-only gate:
|
|
136
|
+
// content-matched constraints and Veto tripwires are not code-only rules
|
|
137
|
+
// (a blocking invariant legitimately scopes .github/workflows/**, *.sql,
|
|
138
|
+
// Dockerfile). Skipping them here left `scopedAdded` empty, which
|
|
139
|
+
// buildCheckReport reads as "cannot prove a violation ⇒ complies" — so the
|
|
140
|
+
// pre-edit hook denied the edit while `hunch check --strict` passed the
|
|
141
|
+
// very commit that landed it. Symbol/import extraction and the churn
|
|
142
|
+
// counters below stay code-only, unchanged.
|
|
140
143
|
let lines = addedLinesBy.get(curFile);
|
|
141
144
|
if (!lines) {
|
|
142
145
|
lines = [];
|
|
143
146
|
addedLinesBy.set(curFile, lines);
|
|
144
147
|
}
|
|
145
148
|
lines.push(body);
|
|
149
|
+
if (!isCode(curFile))
|
|
150
|
+
continue;
|
|
151
|
+
addedLines++;
|
|
152
|
+
if (!curAdded && !curDeleted)
|
|
153
|
+
filesModified.add(curFile);
|
|
146
154
|
const d = declOf(body);
|
|
147
155
|
if (d)
|
|
148
156
|
declsFor(curFile)?.added.set(d.name, d);
|
package/dist/extractors/git.js
CHANGED
|
@@ -711,6 +711,22 @@ export function isGitCleanPath(root, rel) {
|
|
|
711
711
|
return false;
|
|
712
712
|
}
|
|
713
713
|
}
|
|
714
|
+
/** The committed (HEAD) content of a tracked file, or null when the path is
|
|
715
|
+
* untracked/absent at HEAD or git is unavailable. Used to decide whether a
|
|
716
|
+
* dirty grounding doc differs from HEAD ONLY inside its generated section
|
|
717
|
+
* (the stranded-grounding heal, fnd_b269d5c422). */
|
|
718
|
+
export function headFileContent(root, rel) {
|
|
719
|
+
try {
|
|
720
|
+
return execFileSync("git", ["-C", root, "show", `HEAD:${rel.replace(/\\/g, "/")}`], {
|
|
721
|
+
encoding: "utf8",
|
|
722
|
+
env: foreignRepoEnv(process.env),
|
|
723
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
714
730
|
/** Is the staged set a clean, MEMORY-ONLY change — only JSON record adds/updates, nothing else?
|
|
715
731
|
* The overlay store is entirely JSON (decisions/, bugs/, …, manifest.json). A real memory sync
|
|
716
732
|
* is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
|
|
@@ -6,19 +6,25 @@
|
|
|
6
6
|
* four files.
|
|
7
7
|
*/
|
|
8
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))
|
|
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
|
+
;; Construction IS a call. Without these, \`new Foo()\` produced no edge at all, so
|
|
23
|
+
;; every class in a TS/JS repo had fan_in 0: blast radius before a constructor
|
|
24
|
+
;; change came back empty, and a \`not-calls\` conformance predicate over a class
|
|
25
|
+
;; could never see its own counterexample.
|
|
26
|
+
(new_expression constructor: (identifier) @call.id)
|
|
27
|
+
(new_expression constructor: (member_expression property: (property_identifier) @call.member))
|
|
22
28
|
`;
|
|
23
29
|
const TS_BUILTIN_METHODS = new Set([
|
|
24
30
|
"map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
|
|
@@ -65,21 +71,28 @@ const TSX = {
|
|
|
65
71
|
grammarKey: "tsx",
|
|
66
72
|
loadGrammar: () => loadNativeTreeSitter().tsx,
|
|
67
73
|
};
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
(
|
|
74
|
+
const PY_QUERY = `
|
|
75
|
+
(class_definition
|
|
76
|
+
name: (identifier) @class.name
|
|
77
|
+
body: (block
|
|
78
|
+
[
|
|
79
|
+
(function_definition name: (identifier) @method.name) @method.def
|
|
80
|
+
(decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
|
|
81
|
+
])) @class.def
|
|
82
|
+
;; Every class, including one with no directly-nested def: dataclasses, Exception
|
|
83
|
+
;; subclasses, Enums, TypedDicts and pydantic models are method-less by design and
|
|
84
|
+
;; were invisible to the entire graph (no symbol, no component, no edges), so
|
|
85
|
+
;; \`hunch why\` and blast radius came back empty for exactly the classes a refactor
|
|
86
|
+
;; breaks. parse.ts keys pendingDefs by node id and keeps the first classification,
|
|
87
|
+
;; so a class that ALSO matches the method-bearing pattern above is not duplicated.
|
|
88
|
+
(class_definition name: (identifier) @class.name) @class.def
|
|
89
|
+
(function_definition name: (identifier) @fn.name) @fn.def
|
|
90
|
+
(import_statement name: (dotted_name) @import.src)
|
|
91
|
+
(import_statement name: (aliased_import name: (dotted_name) @import.src))
|
|
92
|
+
(import_from_statement module_name: (dotted_name) @import.src)
|
|
93
|
+
(import_from_statement module_name: (relative_import) @import.src)
|
|
94
|
+
(call function: (identifier) @call.id)
|
|
95
|
+
(call function: (attribute attribute: (identifier) @call.member))
|
|
83
96
|
`;
|
|
84
97
|
const PY_BUILTIN_METHODS = new Set([
|
|
85
98
|
"get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy", "clear",
|
|
@@ -10,6 +10,17 @@ import { wikiSummary } from "../wiki/wiki.js";
|
|
|
10
10
|
import { PolicyRepository } from "../constitution/repository.js";
|
|
11
11
|
const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
|
|
12
12
|
const END = "<!-- HUNCH:END -->";
|
|
13
|
+
/** Remove the managed HUNCH section (markers inclusive), leaving only the
|
|
14
|
+
* user-authored surroundings. Lets a caller decide whether two versions of a
|
|
15
|
+
* doc differ ONLY in generated content (the stranded-grounding heal,
|
|
16
|
+
* fnd_b269d5c422): equal outside the block ⇒ regenerating cannot lose prose. */
|
|
17
|
+
export function stripManagedSection(text) {
|
|
18
|
+
const iStart = text.indexOf(START);
|
|
19
|
+
const iEnd = text.indexOf(END);
|
|
20
|
+
if (iStart < 0 || iEnd <= iStart)
|
|
21
|
+
return text;
|
|
22
|
+
return text.slice(0, iStart) + text.slice(iEnd + END.length);
|
|
23
|
+
}
|
|
13
24
|
export function renderHunchSection(store, root) {
|
|
14
25
|
const constraints = store.json
|
|
15
26
|
.loadAll("constraints")
|
|
@@ -21,8 +21,8 @@ import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
|
21
21
|
import { writeFileAtomic } from "../core/io.js";
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { join, dirname } from "node:path";
|
|
24
|
-
import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
|
|
25
|
-
import { isGitCleanPath } from "../extractors/git.js";
|
|
24
|
+
import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd } from "./claudemd.js";
|
|
25
|
+
import { headFileContent, isGitCleanPath } from "../extractors/git.js";
|
|
26
26
|
/** Strip // line and block comments + trailing commas (JSONC → JSON). String-aware
|
|
27
27
|
* (double-quoted, with escapes) so a // inside a value isn't mangled. VS Code's
|
|
28
28
|
* .vscode/mcp.json is JSONC, so we must tolerate comments. */
|
|
@@ -444,22 +444,50 @@ export function refreshExistingGrounding(root, store) {
|
|
|
444
444
|
}
|
|
445
445
|
return changed;
|
|
446
446
|
}
|
|
447
|
-
/**
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
447
|
+
/** Wholly-Hunch-owned grounding docs (namespaced rule files the generators emit
|
|
448
|
+
* in full). Any dirt in these is generated dirt by contract — there is no user
|
|
449
|
+
* prose to protect, so a stale copy is always safe to regenerate and stage. */
|
|
450
|
+
const WHOLLY_OWNED_GROUNDING = new Set([
|
|
451
|
+
join(".cursor", "rules", "hunch.mdc"),
|
|
452
|
+
join(".windsurf", "rules", "hunch.md"),
|
|
453
|
+
]);
|
|
454
|
+
/** Is a DIRTY grounding doc's divergence from HEAD confined to generated content?
|
|
455
|
+
* Marker-managed docs (CLAUDE.md, AGENTS.md, copilot-instructions): compare
|
|
456
|
+
* worktree vs HEAD with the managed section stripped from both — equal outside
|
|
457
|
+
* the block means regenerating cannot lose user prose. Wholly-owned rule files
|
|
458
|
+
* need no comparison. Untracked files return false: a doc the user hasn't
|
|
459
|
+
* committed is theirs to stage. */
|
|
460
|
+
function generatedDirtOnly(root, rel, current) {
|
|
461
|
+
if (WHOLLY_OWNED_GROUNDING.has(rel))
|
|
462
|
+
return headFileContent(root, rel) !== null;
|
|
463
|
+
const head = headFileContent(root, rel);
|
|
464
|
+
if (head === null)
|
|
465
|
+
return false;
|
|
466
|
+
return stripManagedSection(head) === stripManagedSection(current);
|
|
467
|
+
}
|
|
468
|
+
/** Capture-commit refresh: rewrite grounding docs that are git-clean OR whose only
|
|
469
|
+
* divergence from HEAD is generated content, and return the absolute paths to fold
|
|
470
|
+
* into the memory commit (commitAndPushHunch alsoStage). This keeps committed record
|
|
471
|
+
* counts permanently true — every capture used to bump the count and re-stale the
|
|
472
|
+
* committed docs, failing the release gate's clean-tree check on the next CI index
|
|
473
|
+
* (the refresh-counts treadmill). The generated-dirt branch closes the second half
|
|
474
|
+
* (fnd_b269d5c422): once a doc went stale-dirty, the clean-only rule skipped it on
|
|
475
|
+
* every later flush FOREVER, and each release needed a manual chore commit. A doc
|
|
476
|
+
* whose USER PROSE differs from HEAD is still left completely untouched. */
|
|
454
477
|
export function refreshCommittableGrounding(root, store) {
|
|
455
478
|
const changed = [];
|
|
456
479
|
for (const [rel, write] of groundingTargets(root, store)) {
|
|
457
480
|
const file = join(root, rel);
|
|
458
|
-
if (!existsSync(file)
|
|
481
|
+
if (!existsSync(file))
|
|
459
482
|
continue;
|
|
460
483
|
const before = readFileSync(file, "utf8");
|
|
484
|
+
const clean = isGitCleanPath(root, rel);
|
|
485
|
+
if (!clean && !generatedDirtOnly(root, rel, before))
|
|
486
|
+
continue;
|
|
461
487
|
write();
|
|
462
|
-
|
|
488
|
+
// A doc that was stale-DIRTY must be staged even when the regeneration is a
|
|
489
|
+
// byte no-op — the commit is what re-syncs HEAD with the worktree.
|
|
490
|
+
if (readFileSync(file, "utf8") !== before || !clean)
|
|
463
491
|
changed.push(file);
|
|
464
492
|
}
|
|
465
493
|
return changed;
|
package/dist/mcp/server.js
CHANGED
|
@@ -1527,6 +1527,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1527
1527
|
server,
|
|
1528
1528
|
getRoot: () => root,
|
|
1529
1529
|
setRoot,
|
|
1530
|
+
cancelPendingRoot: () => { pendingRoot = null; },
|
|
1530
1531
|
};
|
|
1531
1532
|
}
|
|
1532
1533
|
/** Back-compatible server construction for tests and callers that do not need
|
|
@@ -1555,6 +1556,14 @@ export function wireClientRoots(control, fallback) {
|
|
|
1555
1556
|
return;
|
|
1556
1557
|
const next = resolveActiveRoot((response?.roots ?? []).map((root) => root.uri), fallback);
|
|
1557
1558
|
if (!next) {
|
|
1559
|
+
// Cancel a swap parked by an EARLIER generation. setRoot defers when a tool
|
|
1560
|
+
// request is in flight; if the client's advertised set has since become
|
|
1561
|
+
// ambiguous, applying that stale swap once the request drains would re-home
|
|
1562
|
+
// to a repo the client no longer unambiguously advertises — writing (and
|
|
1563
|
+
// auto-committing) a capture into the wrong repository, which is precisely
|
|
1564
|
+
// what this refusal exists to prevent. Without this the message below was
|
|
1565
|
+
// also a lie: the root did NOT stay put.
|
|
1566
|
+
control.cancelPendingRoot();
|
|
1558
1567
|
console.error("[hunch-mcp] multiple client roots are equally plausible; keeping the current Hunch root");
|
|
1559
1568
|
return;
|
|
1560
1569
|
}
|
package/dist/wiki/wiki.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { createHash } from "node:crypto";
|
|
30
30
|
import { existsSync, readFileSync, mkdirSync, rmSync } from "node:fs";
|
|
31
|
-
import { join, dirname } from "node:path";
|
|
31
|
+
import { join, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
32
32
|
import { writeFileAtomic } from "../core/io.js";
|
|
33
33
|
import { compareCodeUnits } from "../core/canonicalOrder.js";
|
|
34
34
|
import { hunchPaths, toPosixTarget } from "../core/paths.js";
|
|
@@ -36,15 +36,61 @@ import { isLive } from "../core/topics.js";
|
|
|
36
36
|
import { scanRepoDocs } from "../core/docscan.js";
|
|
37
37
|
import { adoptedSlug, adoptionHash, renderAdoptedDoc } from "./adopt.js";
|
|
38
38
|
import { assembleGraphData, renderGraphPage } from "./graph.js";
|
|
39
|
+
/** A wiki directory that is safe to join against the pages root: relative, POSIX,
|
|
40
|
+
* no trailing slash, and free of any "." / ".." / empty segment. `.` would alias
|
|
41
|
+
* the pages root itself (clobbering tracked files like README.md) and `..` writes
|
|
42
|
+
* the rendered graph — which for the private home is the FULL overlay union —
|
|
43
|
+
* outside the repository entirely. Returns undefined for anything unsafe. */
|
|
44
|
+
function validDir(d) {
|
|
45
|
+
if (!d)
|
|
46
|
+
return undefined;
|
|
47
|
+
const v = toPosixTarget(d).replace(/\/+$/, "");
|
|
48
|
+
if (!v || isAbsolute(v) || /^[a-zA-Z]:/.test(v))
|
|
49
|
+
return undefined;
|
|
50
|
+
if (v.split("/").some((seg) => seg === "" || seg === "." || seg === ".."))
|
|
51
|
+
return undefined;
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
39
54
|
/** Normalize a --dir override: POSIX separators, no trailing slash — the dir is
|
|
40
|
-
* a committed manifest key prefix, so it must hash identically on every OS.
|
|
41
|
-
|
|
55
|
+
* a committed manifest key prefix, so it must hash identically on every OS.
|
|
56
|
+
* An unsafe override THROWS rather than silently falling back, so a rejected flag
|
|
57
|
+
* can never quietly write somewhere else. */
|
|
58
|
+
const normDir = (d) => {
|
|
59
|
+
if (d === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
const v = validDir(d);
|
|
62
|
+
if (!v) {
|
|
63
|
+
throw new Error(`refusing --dir ${JSON.stringify(d)}: the wiki directory must be a relative path inside the repository (no "." or ".." segments, not absolute).`);
|
|
64
|
+
}
|
|
65
|
+
return v;
|
|
66
|
+
};
|
|
67
|
+
/** Resolve a manifest page key to an absolute path INSIDE this home's page
|
|
68
|
+
* directory, or null when it escapes.
|
|
69
|
+
*
|
|
70
|
+
* Both inputs that reach the join are untrusted. `.hunch/wiki-manifest.json` is a
|
|
71
|
+
* COMMITTED file (so CI can gate on it), which makes every page KEY inside it
|
|
72
|
+
* PR- and merge-influenceable — and `wikiStatus` classifies any key no current
|
|
73
|
+
* artifact claims as an orphan to be deleted. Without this check a key like
|
|
74
|
+
* "../../id_rsa" turned a plain `hunch wiki` into arbitrary file deletion, with
|
|
75
|
+
* `hunch drift` politely instructing the maintainer to run it. Re-checked at BOTH
|
|
76
|
+
* the write and the delete site, so a manifest written before this guard existed
|
|
77
|
+
* still cannot escape. */
|
|
78
|
+
function containedPagePath(home, page) {
|
|
79
|
+
if (!page || page.includes("\0") || isAbsolute(page) || /^[a-zA-Z]:/.test(page))
|
|
80
|
+
return null;
|
|
81
|
+
const baseDir = resolve(join(home.pagesRoot, home.dir));
|
|
82
|
+
const abs = resolve(join(home.pagesRoot, ...page.split("/")));
|
|
83
|
+
const rel = relative(baseDir, abs);
|
|
84
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
85
|
+
return null;
|
|
86
|
+
return abs;
|
|
87
|
+
}
|
|
42
88
|
export function publicHome(root, dirOverride) {
|
|
43
89
|
const manifestPath = join(hunchPaths(root).hunch, "wiki-manifest.json");
|
|
44
90
|
return {
|
|
45
91
|
kind: "public",
|
|
46
92
|
pagesRoot: root,
|
|
47
|
-
dir: normDir(dirOverride) ?? readWikiManifestAt(manifestPath)?.dir ?? "wiki",
|
|
93
|
+
dir: normDir(dirOverride) ?? validDir(readWikiManifestAt(manifestPath)?.dir) ?? "wiki",
|
|
48
94
|
manifestPath,
|
|
49
95
|
source: "public",
|
|
50
96
|
};
|
|
@@ -60,7 +106,7 @@ export function privateHome(store, dirOverride) {
|
|
|
60
106
|
return {
|
|
61
107
|
kind: "private",
|
|
62
108
|
pagesRoot: dirname(store.privateDir),
|
|
63
|
-
dir: normDir(dirOverride) ?? readWikiManifestAt(manifestPath)?.dir ?? "wiki",
|
|
109
|
+
dir: normDir(dirOverride) ?? validDir(readWikiManifestAt(manifestPath)?.dir) ?? "wiki",
|
|
64
110
|
manifestPath,
|
|
65
111
|
source: "all",
|
|
66
112
|
};
|
|
@@ -515,6 +561,11 @@ export function wikiStatus(store, home, srcRoot) {
|
|
|
515
561
|
for (const [page, p] of Object.entries(manifest?.pages ?? {})) {
|
|
516
562
|
if (expected.has(page))
|
|
517
563
|
continue;
|
|
564
|
+
// The manifest is committed, so a key is attacker-influenceable through an
|
|
565
|
+
// ordinary PR — and reaching this list means "delete this file". Only a key
|
|
566
|
+
// that resolves inside this home's page directory may be classified at all.
|
|
567
|
+
if (!containedPagePath(home, page))
|
|
568
|
+
continue;
|
|
518
569
|
(p.component.startsWith(ADOPTED_PREFIX) ? adoptionOrphans : orphans).push(page);
|
|
519
570
|
}
|
|
520
571
|
return { home, entries, docs, adoptions, adoptionOrphans, decisions, specs, index, now, graph, repoWide, orphans };
|
|
@@ -556,7 +607,10 @@ export async function generateWiki(store, srcRoot, home, opts) {
|
|
|
556
607
|
/** Written-bytes ledger — the hand-edit tripwire recorded per page. */
|
|
557
608
|
const bytesByPage = new Map();
|
|
558
609
|
const put = (page, content) => {
|
|
559
|
-
|
|
610
|
+
const abs = containedPagePath(home, page);
|
|
611
|
+
if (!abs)
|
|
612
|
+
throw new Error(`refusing to write wiki page ${JSON.stringify(page)}: it resolves outside ${join(home.pagesRoot, home.dir)}`);
|
|
613
|
+
writeFileAtomic(abs, content);
|
|
560
614
|
bytesByPage.set(page, sha16(content));
|
|
561
615
|
written.push(page);
|
|
562
616
|
};
|
|
@@ -614,8 +668,16 @@ export async function generateWiki(store, srcRoot, home, opts) {
|
|
|
614
668
|
const removed = [];
|
|
615
669
|
for (const [pages, why] of [[status.orphans, "component gone"], [status.adoptionOrphans, "original healed or removed — copy retired"]]) {
|
|
616
670
|
for (const page of pages) {
|
|
671
|
+
// Re-assert containment at the DELETE site too: wikiStatus already filters
|
|
672
|
+
// escaping manifest keys, but this is the primitive that removes files, so it
|
|
673
|
+
// must not depend on an upstream filter having run.
|
|
674
|
+
const abs = containedPagePath(home, page);
|
|
675
|
+
if (!abs) {
|
|
676
|
+
log(` ⚠ ${page} — refusing to remove a manifest entry that resolves outside ${join(home.pagesRoot, home.dir)}`);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
617
679
|
try {
|
|
618
|
-
rmSync(
|
|
680
|
+
rmSync(abs, { force: true });
|
|
619
681
|
}
|
|
620
682
|
catch {
|
|
621
683
|
/* best effort */
|
|
@@ -627,8 +689,26 @@ export async function generateWiki(store, srcRoot, home, opts) {
|
|
|
627
689
|
if (written.length || removed.length) {
|
|
628
690
|
const pages = {};
|
|
629
691
|
const entry = (page, component, hash, state) => {
|
|
630
|
-
const
|
|
631
|
-
|
|
692
|
+
const prev = prior?.pages[page];
|
|
693
|
+
const justWritten = bytesByPage.get(page);
|
|
694
|
+
// Carry the prior entry forward ONLY for a fresh page this run did not rewrite.
|
|
695
|
+
// A full regen (`only: "all"`) rewrites every page, so reusing the prior entry
|
|
696
|
+
// there recorded the OLD bytes against freshly written content — making the
|
|
697
|
+
// hand-edit tripwire fire on Hunch's own output, so `hunch wiki --check` exited
|
|
698
|
+
// 1 immediately after a successful `hunch wiki` and the tripwire's signal for a
|
|
699
|
+
// REAL hand edit was destroyed.
|
|
700
|
+
if (state === "fresh" && justWritten === undefined && prev) {
|
|
701
|
+
pages[page] = prev;
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
pages[page] = {
|
|
705
|
+
component,
|
|
706
|
+
hash,
|
|
707
|
+
// Keep the original stamp for an unchanged-but-rewritten page so a full regen
|
|
708
|
+
// stays byte-idempotent in git.
|
|
709
|
+
generated: state === "fresh" && prev ? prev.generated : opts.now,
|
|
710
|
+
bytes: justWritten ?? prev?.bytes,
|
|
711
|
+
};
|
|
632
712
|
};
|
|
633
713
|
for (const e of status.entries)
|
|
634
714
|
entry(e.page, e.pack.component.id, e.hash, e.state);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",
|