@davesheffer/hunch 1.12.1 → 1.12.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/core/drift.js +11 -1
- package/dist/core/publication.js +196 -0
- package/dist/extractors/languages.js +51 -41
- package/dist/extractors/parse.js +43 -1
- package/dist/integrations/providers.js +31 -11
- package/dist/mcp/server.js +21 -6
- package/dist/synthesis/synthesize.js +10 -2
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/core/drift.js
CHANGED
|
@@ -29,6 +29,16 @@ export function computeDrift(store, root) {
|
|
|
29
29
|
// anchor-stale. Keeps the doc≠graph gate's false-positive rate ~zero: a routine
|
|
30
30
|
// narrowing supersession (successor lists fewer files) never flags files still governed.
|
|
31
31
|
const liveFiles = new Set(decisions.filter(isLive).flatMap((d) => (d.related_files ?? []).map(toPosixTarget)));
|
|
32
|
+
// A related_files entry may name a DIRECTORY ("vscode-extension/"), which governs every
|
|
33
|
+
// file beneath it. Exact Set.has cannot see that, so a live directory-scoped decision
|
|
34
|
+
// failed to suppress anchor-stale for files it plainly covers — a false positive that
|
|
35
|
+
// only appeared on a public-only store, because an overlay decision happened to claim
|
|
36
|
+
// the same file by exact path and masked it locally.
|
|
37
|
+
const liveDirs = [...liveFiles].filter((f) => f.endsWith("/"));
|
|
38
|
+
const governedByLiveDecision = (file) => {
|
|
39
|
+
const p = toPosixTarget(file);
|
|
40
|
+
return liveFiles.has(p) || liveDirs.some((dir) => p.startsWith(dir));
|
|
41
|
+
};
|
|
32
42
|
const premiseEnv = { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) };
|
|
33
43
|
for (const d of decisions) {
|
|
34
44
|
// 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
|
|
@@ -66,7 +76,7 @@ export function computeDrift(store, root) {
|
|
|
66
76
|
const current = currentForTopic(decisions, d.topic);
|
|
67
77
|
if (current && current.id !== d.id) {
|
|
68
78
|
for (const f of d.related_files ?? []) {
|
|
69
|
-
if (!f || f.includes("*") ||
|
|
79
|
+
if (!f || f.includes("*") || governedByLiveDecision(f))
|
|
70
80
|
continue;
|
|
71
81
|
if (!referenceExists(store, root, d.id, f))
|
|
72
82
|
continue; // missing file is history → dead-ref's job
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/** Publication safety — what a record would EXPOSE if it lands in the committed store.
|
|
2
|
+
*
|
|
3
|
+
* Context (2026-08-09, 2026-08-11): a capture defaults to the PUBLIC store, and for a
|
|
4
|
+
* default `hunch init` user `.hunch/*.json` is git-tracked, so an unflagged
|
|
5
|
+
* `hunch_record_*` publishes on the next push. Two leaks reached the public tree that
|
|
6
|
+
* way. Nothing inspected what the records SAID.
|
|
7
|
+
*
|
|
8
|
+
* Two tiers, deliberately unequal:
|
|
9
|
+
*
|
|
10
|
+
* - STRUCTURAL hits (machine paths, overlay paths, secret material) are
|
|
11
|
+
* domain-independent — a Windows home directory is a leak in any repository, in any
|
|
12
|
+
* industry. These are safe to enforce in a package other people install.
|
|
13
|
+
* - VOCABULARY hits are corpus-tuned and ship EMPTY. A term list built from one
|
|
14
|
+
* project's strategy prose fires on another project's ordinary engineering writing
|
|
15
|
+
* ("revenue" is a domain noun in a billing system). A repo opts in through
|
|
16
|
+
* `.hunch/publication.json`; the package never presumes.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here throws or blocks. `con_03a0b94b2e` holds the lifecycle hook to
|
|
19
|
+
* fail-open, and a privacy heuristic is a smoke detector, not a proof — it earns a
|
|
20
|
+
* visible line, not a veto. */
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
/** Structural kinds are the only ones a package may enforce for a stranger's repo. */
|
|
24
|
+
const STRUCTURAL = new Set([
|
|
25
|
+
"machine-path",
|
|
26
|
+
"private-overlay-path",
|
|
27
|
+
"secret-material",
|
|
28
|
+
]);
|
|
29
|
+
export function isStructural(hit) {
|
|
30
|
+
return STRUCTURAL.has(hit.kind);
|
|
31
|
+
}
|
|
32
|
+
/** Placeholder home directories that documentation and tests use on purpose.
|
|
33
|
+
* `/Users/me/repo` appears in src/integrations/claudeConfig.ts and its test as an
|
|
34
|
+
* illustration; flagging those would train everyone to ignore the scanner. */
|
|
35
|
+
const PLACEHOLDER_USER = /^(me|you|user|username|<[^>]+>|\$\{[^}]+\}|example|test|foo|bar)$/i;
|
|
36
|
+
const MACHINE_PATH = [
|
|
37
|
+
/[A-Za-z]:[\\/]Users[\\/]([^\\/"'\s,)\]]+)/g,
|
|
38
|
+
/(?:^|[\s"'(])\/(?:Users|home)\/([^/"'\s,)\]]+)/g,
|
|
39
|
+
];
|
|
40
|
+
/** A path INTO the overlay (dir + file), not a bare mention of the feature. The
|
|
41
|
+
* gitignore entry and the CLAUDE.md description name `.hunch-private` legitimately;
|
|
42
|
+
* `.hunch-private/exp03-bank/cases-hunch-draft.json` names private CONTENT. */
|
|
43
|
+
const OVERLAY_PATH = /\.hunch-private[\\/][A-Za-z0-9._-]+[\\/][A-Za-z0-9._-]+/g;
|
|
44
|
+
/** Live credential shapes only. A bare `VSCE_PAT` / `AUTH_TOKEN` identifier is a
|
|
45
|
+
* variable name, not a secret, and flagging it produced a false positive on
|
|
46
|
+
* dec_cd37bf2d9a during tuning. */
|
|
47
|
+
const SECRET_MATERIAL = /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b/g;
|
|
48
|
+
const clip = (s, max = 120) => {
|
|
49
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
50
|
+
return flat.length > max ? flat.slice(0, max) + "…" : flat;
|
|
51
|
+
};
|
|
52
|
+
/** Fields that hold human prose. Vocabulary is scanned here only — scanning raw JSON
|
|
53
|
+
* would match ids, file paths and evidence lines and drown the signal. */
|
|
54
|
+
const PROSE_KEYS = new Set([
|
|
55
|
+
"title", "context", "decision", "rationale", "statement", "rule", "observation",
|
|
56
|
+
"summary", "description", "symptom", "root_cause", "resolution", "notes",
|
|
57
|
+
"consequences", "alternatives_rejected", "steps", "why",
|
|
58
|
+
]);
|
|
59
|
+
function proseOf(record) {
|
|
60
|
+
const out = [];
|
|
61
|
+
if (!record || typeof record !== "object")
|
|
62
|
+
return out;
|
|
63
|
+
for (const [k, v] of Object.entries(record)) {
|
|
64
|
+
if (!PROSE_KEYS.has(k))
|
|
65
|
+
continue;
|
|
66
|
+
if (typeof v === "string")
|
|
67
|
+
out.push({ field: k, text: v });
|
|
68
|
+
else if (Array.isArray(v)) {
|
|
69
|
+
v.forEach((item, i) => {
|
|
70
|
+
if (typeof item === "string")
|
|
71
|
+
out.push({ field: `${k}[${i}]`, text: item });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
function readPatterns(file) {
|
|
78
|
+
let parsed;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const raw = parsed?.vocabulary;
|
|
86
|
+
if (!Array.isArray(raw))
|
|
87
|
+
return [];
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const pattern of raw) {
|
|
90
|
+
if (typeof pattern !== "string")
|
|
91
|
+
continue;
|
|
92
|
+
try {
|
|
93
|
+
out.push(new RegExp(pattern, "gi"));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// One bad pattern must not disable the rest of a user's list.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** Read a repo's opt-in term list, merged from two layers:
|
|
102
|
+
* .hunch/publication.json committed, shareable patterns
|
|
103
|
+
* .hunch/publication.local.json gitignored, per-machine patterns
|
|
104
|
+
*
|
|
105
|
+
* The local layer exists because the list itself can be sensitive. A rule that
|
|
106
|
+
* catches a competitor teardown has to NAME competitors, and committing that
|
|
107
|
+
* publishes a watchlist — the exact class of content the scanner is meant to keep
|
|
108
|
+
* out of a public repo. Patterns that would embarrass you if read belong in the
|
|
109
|
+
* local layer; generic ones can ship.
|
|
110
|
+
*
|
|
111
|
+
* Absent file, malformed JSON, or an invalid pattern all degrade to "no vocabulary".
|
|
112
|
+
* A privacy heuristic that crashes a capture is worse than one that stays quiet, and
|
|
113
|
+
* the structural tier — the part that works for everyone — never depends on this. */
|
|
114
|
+
export function loadVocabulary(hunchDir) {
|
|
115
|
+
return [
|
|
116
|
+
...readPatterns(join(hunchDir, "publication.json")),
|
|
117
|
+
...readPatterns(join(hunchDir, "publication.local.json")),
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
/** Every string VALUE in the record, with the field path that carried it.
|
|
121
|
+
* Deliberately not `JSON.stringify` + one regex pass: the JSON encoding doubles
|
|
122
|
+
* backslashes, so `C:\Users\x` becomes `C:\\Users\\x` and a path rule written for
|
|
123
|
+
* real text silently stops matching on Windows — which is exactly how the first
|
|
124
|
+
* version of this scanner passed its macOS cases and failed its Windows ones. */
|
|
125
|
+
function stringValues(node, path, seen, out) {
|
|
126
|
+
if (typeof node === "string") {
|
|
127
|
+
out.push({ field: path, text: node });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!node || typeof node !== "object")
|
|
131
|
+
return;
|
|
132
|
+
if (seen.has(node))
|
|
133
|
+
return; // cycles are input we tolerate, never throw on
|
|
134
|
+
seen.add(node);
|
|
135
|
+
if (Array.isArray(node)) {
|
|
136
|
+
node.forEach((v, i) => stringValues(v, `${path}[${i}]`, seen, out));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const [k, v] of Object.entries(node)) {
|
|
140
|
+
stringValues(v, path === "$" ? k : `${path}.${k}`, seen, out);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Pure. No IO, no throw. Returns every sensitivity signal in one record. */
|
|
144
|
+
export function scanRecord(record, opts = {}) {
|
|
145
|
+
const hits = [];
|
|
146
|
+
if (!record || typeof record !== "object")
|
|
147
|
+
return hits;
|
|
148
|
+
const values = [];
|
|
149
|
+
stringValues(record, "$", new WeakSet(), values);
|
|
150
|
+
for (const { field, text } of values) {
|
|
151
|
+
for (const re of MACHINE_PATH) {
|
|
152
|
+
for (const m of text.matchAll(re)) {
|
|
153
|
+
const who = m[1] ?? "";
|
|
154
|
+
if (PLACEHOLDER_USER.test(who))
|
|
155
|
+
continue;
|
|
156
|
+
hits.push({ kind: "machine-path", field, excerpt: clip(m[0]) });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const m of text.matchAll(OVERLAY_PATH)) {
|
|
160
|
+
hits.push({ kind: "private-overlay-path", field, excerpt: clip(m[0]) });
|
|
161
|
+
}
|
|
162
|
+
for (const m of text.matchAll(SECRET_MATERIAL)) {
|
|
163
|
+
// Never echo a live credential back into a log or a tool result.
|
|
164
|
+
hits.push({ kind: "secret-material", field, excerpt: `${m[0].slice(0, 6)}… (redacted)` });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const vocab = opts.vocabulary ?? [];
|
|
168
|
+
if (vocab.length) {
|
|
169
|
+
for (const { field, text } of proseOf(record)) {
|
|
170
|
+
for (const re of vocab) {
|
|
171
|
+
const probe = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
|
|
172
|
+
for (const m of text.matchAll(probe)) {
|
|
173
|
+
const at = m.index ?? 0;
|
|
174
|
+
hits.push({
|
|
175
|
+
kind: "market-vocabulary",
|
|
176
|
+
field,
|
|
177
|
+
excerpt: clip(text.slice(Math.max(0, at - 40), at + m[0].length + 40)),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return hits;
|
|
184
|
+
}
|
|
185
|
+
/** One line naming what was matched and the exact remedy. The predecessor of this
|
|
186
|
+
* message was a generic "for sensitive content use private:true" nudge, which was
|
|
187
|
+
* present and ignored during both leaks — a warning that does not quote the offending
|
|
188
|
+
* text reads as boilerplate. */
|
|
189
|
+
export function publicationWarning(hits) {
|
|
190
|
+
if (!hits.length)
|
|
191
|
+
return "";
|
|
192
|
+
const shown = hits.slice(0, 3).map((h) => `${h.kind}${h.field === "$" ? "" : ` in ${h.field}`}: "${h.excerpt}"`);
|
|
193
|
+
const more = hits.length > shown.length ? ` (+${hits.length - shown.length} more)` : "";
|
|
194
|
+
return `\n⚠ PUBLICATION RISK — this record would publish with the repo:\n ${shown.join("\n ")}${more}\n Re-record with private:true to route it to the overlay instead.`;
|
|
195
|
+
}
|
|
196
|
+
//# sourceMappingURL=publication.js.map
|
|
@@ -6,25 +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))
|
|
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))
|
|
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))
|
|
28
28
|
`;
|
|
29
29
|
const TS_BUILTIN_METHODS = new Set([
|
|
30
30
|
"map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
|
|
@@ -54,6 +54,16 @@ const TS_SHARED = {
|
|
|
54
54
|
"iface.name": "iface.def", "type.name": "type.def", "arrow.name": "arrow.def",
|
|
55
55
|
},
|
|
56
56
|
builtinMethods: TS_BUILTIN_METHODS,
|
|
57
|
+
// ES2018 relaxed template literals: an INVALID escape (`\x` with no hex, `\u`
|
|
58
|
+
// short, `\u{` unterminated) is legal inside a TAGGED template — the cooked
|
|
59
|
+
// value is undefined and the raw text survives, which is the entire point of
|
|
60
|
+
// String.raw`C:\Users\x`. tree-sitter-javascript never implemented that
|
|
61
|
+
// relaxation and is identical through 0.25.0, so it emits an ERROR node inside
|
|
62
|
+
// the template_string. In an UNTAGGED template the same escape IS a syntax
|
|
63
|
+
// error, and the grammar models the two differently — a tagged template's
|
|
64
|
+
// template_string hangs off a call_expression, an untagged one off whatever
|
|
65
|
+
// consumes the value — so requiring that pair keeps genuine errors failing.
|
|
66
|
+
toleratedErrorScopes: [{ node: "template_string", parentIs: "call_expression" }],
|
|
57
67
|
};
|
|
58
68
|
const TYPESCRIPT = {
|
|
59
69
|
...TS_SHARED,
|
|
@@ -71,28 +81,28 @@ const TSX = {
|
|
|
71
81
|
grammarKey: "tsx",
|
|
72
82
|
loadGrammar: () => loadNativeTreeSitter().tsx,
|
|
73
83
|
};
|
|
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))
|
|
84
|
+
const PY_QUERY = `
|
|
85
|
+
(class_definition
|
|
86
|
+
name: (identifier) @class.name
|
|
87
|
+
body: (block
|
|
88
|
+
[
|
|
89
|
+
(function_definition name: (identifier) @method.name) @method.def
|
|
90
|
+
(decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
|
|
91
|
+
])) @class.def
|
|
92
|
+
;; Every class, including one with no directly-nested def: dataclasses, Exception
|
|
93
|
+
;; subclasses, Enums, TypedDicts and pydantic models are method-less by design and
|
|
94
|
+
;; were invisible to the entire graph (no symbol, no component, no edges), so
|
|
95
|
+
;; \`hunch why\` and blast radius came back empty for exactly the classes a refactor
|
|
96
|
+
;; breaks. parse.ts keys pendingDefs by node id and keeps the first classification,
|
|
97
|
+
;; so a class that ALSO matches the method-bearing pattern above is not duplicated.
|
|
98
|
+
(class_definition name: (identifier) @class.name) @class.def
|
|
99
|
+
(function_definition name: (identifier) @fn.name) @fn.def
|
|
100
|
+
(import_statement name: (dotted_name) @import.src)
|
|
101
|
+
(import_statement name: (aliased_import name: (dotted_name) @import.src))
|
|
102
|
+
(import_from_statement module_name: (dotted_name) @import.src)
|
|
103
|
+
(import_from_statement module_name: (relative_import) @import.src)
|
|
104
|
+
(call function: (identifier) @call.id)
|
|
105
|
+
(call function: (attribute attribute: (identifier) @call.member))
|
|
96
106
|
`;
|
|
97
107
|
const PY_BUILTIN_METHODS = new Set([
|
|
98
108
|
"get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy", "clear",
|
package/dist/extractors/parse.js
CHANGED
|
@@ -82,7 +82,49 @@ export function parseSource(file, source) {
|
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
84
|
symbols.sort((a, b) => a.startByte - b.startByte);
|
|
85
|
-
return { symbols, imports, calls, parseable:
|
|
85
|
+
return { symbols, imports, calls, parseable: isParseable(tree.rootNode, spec) };
|
|
86
|
+
}
|
|
87
|
+
/** True when every ERROR/MISSING node in the tree sits in an ancestor shape this
|
|
88
|
+
* language declares as a known grammar limitation (LanguageSpec.toleratedErrorScopes).
|
|
89
|
+
*
|
|
90
|
+
* This matters because `conform` is fail-CLOSED on scan completeness: one file
|
|
91
|
+
* reporting parseable:false rejects the WHOLE architectural-conformance scan, so a
|
|
92
|
+
* grammar false positive takes down the gate for the entire repo. Scoping the
|
|
93
|
+
* tolerance to a declared ancestor pair — rather than downgrading unparseable files
|
|
94
|
+
* to a warning — keeps the completeness guarantee intact for real syntax errors.
|
|
95
|
+
*
|
|
96
|
+
* A tolerated ERROR's children are not visited: tree-sitter reports the same span
|
|
97
|
+
* again as a nested ERROR child, and the raw text inside a template literal cannot
|
|
98
|
+
* contain an independent error to hide. */
|
|
99
|
+
function isParseable(root, spec) {
|
|
100
|
+
if (!root.hasError)
|
|
101
|
+
return true; // covers ERROR and MISSING; no walk needed
|
|
102
|
+
const scopes = spec.toleratedErrorScopes ?? [];
|
|
103
|
+
if (scopes.length === 0)
|
|
104
|
+
return false;
|
|
105
|
+
let ok = true;
|
|
106
|
+
const visit = (node) => {
|
|
107
|
+
if (!ok)
|
|
108
|
+
return;
|
|
109
|
+
if (node.type === "ERROR" || node.isMissing) {
|
|
110
|
+
if (!inToleratedScope(node, scopes))
|
|
111
|
+
ok = false;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (let i = 0; i < node.childCount; i++)
|
|
115
|
+
visit(node.child(i));
|
|
116
|
+
};
|
|
117
|
+
visit(root);
|
|
118
|
+
return ok;
|
|
119
|
+
}
|
|
120
|
+
function inToleratedScope(node, scopes) {
|
|
121
|
+
for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
|
|
122
|
+
for (const scope of scopes) {
|
|
123
|
+
if (ancestor.type === scope.node && ancestor.parent?.type === scope.parentIs)
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
86
128
|
}
|
|
87
129
|
/** Walk up to the nearest node whose type is a definition this language recognizes. */
|
|
88
130
|
function ascendToDef(node, defNodeTypes) {
|
|
@@ -141,24 +141,44 @@ function writeJson(file, obj) {
|
|
|
141
141
|
writeFileAtomic(file, JSON.stringify(obj, null, 2) + "\n");
|
|
142
142
|
return file;
|
|
143
143
|
}
|
|
144
|
+
/** Quote one argv token only when it needs quoting. These commands are run by
|
|
145
|
+
* whatever shell the host assistant uses, which on Windows is PowerShell — and
|
|
146
|
+
* PowerShell parses a QUOTED first token as a string expression, not a command,
|
|
147
|
+
* so the old quote-everything form died before the hook ever ran:
|
|
148
|
+
*
|
|
149
|
+
* "npx" "-y" "--package=…" "hunch" "hook" "--provider" "vscode"
|
|
150
|
+
* → Unexpected token '"-y"' in expression or statement.
|
|
151
|
+
*
|
|
152
|
+
* A token of safe characters is a command/word in PowerShell, cmd AND POSIX sh,
|
|
153
|
+
* so quote-only-when-needed is the one shape all three accept. Backslash is not
|
|
154
|
+
* safe bare (sh eats it as an escape), so Windows paths still get quoted — those
|
|
155
|
+
* appear only in per-machine source-checkout installs, never in the published
|
|
156
|
+
* npx invocation that `hunch init` writes into tracked config. */
|
|
157
|
+
function shellToken(part) {
|
|
158
|
+
return /^[A-Za-z0-9_@:=+.,/-]+$/.test(part) ? part : JSON.stringify(part);
|
|
159
|
+
}
|
|
144
160
|
/** Provider hook commands live in tracked config files, so use the structured
|
|
145
161
|
* invocation (the same portable npx package reference as MCP) rather than a
|
|
146
|
-
* machine-local CLI path.
|
|
147
|
-
* paths with spaces intact for source/dev installs. */
|
|
162
|
+
* machine-local CLI path. */
|
|
148
163
|
function hookCommand(inv, provider) {
|
|
149
|
-
return [
|
|
164
|
+
return [inv.command, ...inv.args, "hook", "--provider", provider].map(shellToken).join(" ");
|
|
150
165
|
}
|
|
151
166
|
function isHunchProviderHook(entry) {
|
|
152
167
|
const e = entry && typeof entry === "object" ? entry : null;
|
|
153
168
|
const command = typeof e?.command === "string" ? e.command : "";
|
|
154
|
-
// Anchored to the
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
// Anchored to the shapes hookCommand() writes — a Hunch launcher (the pinned
|
|
170
|
+
// npm package spec, or a …/index.js|ts path for source installs) plus a tail
|
|
171
|
+
// of `hook --provider <name>`, tokens quoted or bare. The old unanchored
|
|
172
|
+
// /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries like
|
|
173
|
+
// `node ./hook/index.js` as ours and silently deleted them, violating the
|
|
174
|
+
// leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41), so
|
|
175
|
+
// the bare tail must still carry --provider to match; only the LEGACY
|
|
176
|
+
// fully-quoted form (written before this quoting fix, and by hunch versions
|
|
177
|
+
// that predate --provider) may omit it, and its quotes keep it unambiguous.
|
|
178
|
+
const launcher = /@davesheffer\/hunch|[\\/]index\.(?:js|ts)(?=["\s]|$)/.test(command);
|
|
179
|
+
const legacyTail = /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
|
|
180
|
+
const tail = /\s"?hook"?\s+"?--provider"?\s+"?[a-z]+"?\s*$/.test(command);
|
|
181
|
+
return launcher && (legacyTail || tail);
|
|
162
182
|
}
|
|
163
183
|
/** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
|
|
164
184
|
* We replace only old Hunch commands and leave every foreign hook in place. */
|
package/dist/mcp/server.js
CHANGED
|
@@ -33,6 +33,7 @@ import { HUNCH_VERSION } from "../core/version.js";
|
|
|
33
33
|
import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
|
|
34
34
|
import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
|
|
35
35
|
import { pendingEscalations, policyEscalations } from "../core/escalations.js";
|
|
36
|
+
import { scanRecord, publicationWarning, loadVocabulary } from "../core/publication.js";
|
|
36
37
|
import { premiseEscalations } from "../core/premises.js";
|
|
37
38
|
import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
|
|
38
39
|
import { randomUUID } from "node:crypto";
|
|
@@ -53,9 +54,23 @@ const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pus
|
|
|
53
54
|
* lands in the committed store publishes on the next push, and an agent writing
|
|
54
55
|
* strategy/competitive content there is a leak nobody notices until it ships
|
|
55
56
|
* (2026-08-09: 15 roadmap records caught pre-push only by a release sweep). */
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
/** Repo-local term list, read once per server process. The package ships none;
|
|
58
|
+
* `.hunch/publication.json` is how a repo opts in (see src/core/publication.ts). */
|
|
59
|
+
let vocabularyCache = null;
|
|
60
|
+
const publicationVocabulary = (hunchDir) => (vocabularyCache ??= loadVocabulary(hunchDir));
|
|
61
|
+
const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
|
|
62
|
+
if (home !== "public")
|
|
63
|
+
return "";
|
|
64
|
+
// The generic nudge below was already present during BOTH leaks and was ignored,
|
|
65
|
+
// because a warning that cannot quote the offending text reads as boilerplate.
|
|
66
|
+
// scanRecord adds the specific line: what matched, in which field.
|
|
67
|
+
const risk = record === undefined
|
|
68
|
+
? ""
|
|
69
|
+
: publicationWarning(scanRecord(record, { vocabulary: hunchDir ? publicationVocabulary(hunchDir) : [] }));
|
|
70
|
+
if (!hasPrivate)
|
|
71
|
+
return risk;
|
|
72
|
+
return "\nℹ Landed in the COMMITTED PUBLIC store (publishes with the repo). For sensitive/strategy content, re-record with private:true — the overlay store." + risk;
|
|
73
|
+
};
|
|
59
74
|
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
60
75
|
// session, so an uncapped list pollutes the context window. Cap each list to its
|
|
61
76
|
// highest-signal head (records are pre-sorted by severity/confidence) and tell the
|
|
@@ -910,7 +925,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
910
925
|
// record commits+pushes its overlay repo; a public one commits .hunch/ in THIS repo
|
|
911
926
|
// (commit only — it rides the user's next push, never auto-pushing their code branch).
|
|
912
927
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
|
|
913
|
-
const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate);
|
|
928
|
+
const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch);
|
|
914
929
|
// Capture-session gate (staged deprecation, §9.3): the token was consumed
|
|
915
930
|
// above (it also decides the provenance tier). No token still writes
|
|
916
931
|
// (non-breaking) but lands as agent_recorded with a nudge toward /capture.
|
|
@@ -987,7 +1002,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
987
1002
|
if (home === "public" && !store.autoCommit)
|
|
988
1003
|
refreshExistingGrounding(root, store); // overlay rules never render into committed grounding
|
|
989
1004
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`, startupTeamRoute ?? undefined);
|
|
990
|
-
const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate);
|
|
1005
|
+
const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch);
|
|
991
1006
|
const enforce = rec.severity === "blocking"
|
|
992
1007
|
? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
|
|
993
1008
|
: "flags violating edits and PRs (advisory)";
|
|
@@ -1063,7 +1078,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1063
1078
|
store.putCapture("findings", rec, !!finding.private);
|
|
1064
1079
|
store.reindex();
|
|
1065
1080
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!finding.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
|
|
1066
|
-
const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate);
|
|
1081
|
+
const flushed = flushNote(flush, home, store.mode) + publicHomeNote(home, store.hasPrivate, rec, hunchPaths(root).hunch);
|
|
1067
1082
|
const where = finding.private
|
|
1068
1083
|
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
1069
1084
|
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
@@ -201,8 +201,16 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
201
201
|
},
|
|
202
202
|
date: meta.date, // the commit date
|
|
203
203
|
};
|
|
204
|
-
// Route to the
|
|
205
|
-
// ("shared") mode; else the public store.
|
|
204
|
+
// Route to the resolved home: the overlay when asked (--private / opts.home) or in
|
|
205
|
+
// unified ("shared") mode; else the public store.
|
|
206
|
+
//
|
|
207
|
+
// DELIBERATELY home-scoped, NOT putCapture. Synthesis keeps the public and private
|
|
208
|
+
// spines separate: a public failure must never reach through and rewrite a same-id
|
|
209
|
+
// private record, which putCapture's cross-home collision guard would either throw on
|
|
210
|
+
// or (via putWhereItLives) silently redirect. test/private-capture.test.ts pins that
|
|
211
|
+
// behaviour — "public post-promotion rewrite cannot overwrite the private collision".
|
|
212
|
+
// An earlier comment here claimed the "same contract as every other capture path",
|
|
213
|
+
// which read as an accidental bypass and invited exactly that wrong fix.
|
|
206
214
|
if (home === "private")
|
|
207
215
|
store.putPrivate("decisions", decision);
|
|
208
216
|
else
|
package/package.json
CHANGED
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.12.
|
|
10
|
+
"version": "1.12.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.12.
|
|
16
|
+
"version": "1.12.2",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|