@davesheffer/hunch 0.35.1 → 0.36.1
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/README.md +7 -5
- package/dist/cli/index.js +20 -4
- package/dist/core/constraintmatch.js +125 -25
- package/dist/core/correction.js +5 -0
- package/dist/core/hookpolicy.js +7 -5
- package/dist/core/types.js +13 -0
- package/dist/store/hunchStore.js +16 -51
- package/dist/synthesis/synthesize.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -172,11 +172,13 @@ Plus the **Regression Guard** (re-adding deliberately-retired code) and the
|
|
|
172
172
|
**[CI Constraint Guard](https://hunch-pi.vercel.app/docs#ci)** (`hunch ci` — a PR gate that
|
|
173
173
|
comments the affected `con_`/`dec_` ids and fails on a blocking one).
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
175
|
+
Name the actual violation — `record-constraint "…" --scope "src/**" --severity blocking
|
|
176
|
+
--forbid-dep "lodash"` — and it blocks the *real* change across the file's whole life instead
|
|
177
|
+
of relaxing to advisory after the file is edited again. The dep matcher reads the **parsed
|
|
178
|
+
import**, so a comment or string naming the module can't false-positive and a submodule
|
|
179
|
+
(`lodash/groupBy`) is still caught; a correction your assistant records gets the same matcher
|
|
180
|
+
automatically. (`--match <regex>` remains a lint-grade textual fallback.) None of these are a
|
|
181
|
+
bypass-proof boundary — deliberate indirection can still route around any rule.
|
|
180
182
|
|
|
181
183
|
## Working as a team
|
|
182
184
|
|
package/dist/cli/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import { parseTestReport } from "../extractors/testreport.js";
|
|
|
30
30
|
import { selectProvider } from "../synthesis/provider.js";
|
|
31
31
|
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree } from "../extractors/git.js";
|
|
32
32
|
import { runbookId, decisionId } from "../core/ids.js";
|
|
33
|
+
import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
33
34
|
import { extractInlineIntent } from "../extractors/comments.js";
|
|
34
35
|
import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
|
|
35
36
|
import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
@@ -715,7 +716,7 @@ program
|
|
|
715
716
|
id, type: "correctness", statement: it.text, scope: [it.file],
|
|
716
717
|
// Advisory by default — an inline rule never auto-blocks a build; raise severity
|
|
717
718
|
// deliberately if you want enforcement. Keeps day-one zero false-positive rage.
|
|
718
|
-
severity: "warning", enforcement: "advisory_v1", match: null,
|
|
719
|
+
severity: "warning", enforcement: "advisory_v1", match: null, forbids: null,
|
|
719
720
|
rationale: `Captured from an inline hunch-rule comment (${it.file}:${it.line}).`,
|
|
720
721
|
source_decision: null, violations: [], status: "active",
|
|
721
722
|
valid_from: prev?.valid_from ?? now, valid_to: null,
|
|
@@ -928,7 +929,9 @@ program
|
|
|
928
929
|
.option("--rationale <text>", "why it must hold", "")
|
|
929
930
|
.option("--source-decision <id>", "decision id this derives from")
|
|
930
931
|
.option("--enforcement <e>", "advisory_v1 | ci | manual", "advisory_v1")
|
|
931
|
-
.option("--
|
|
932
|
+
.option("--forbid-dep <names>", "comma-sep imports that BREAK the rule (parsed-import precise; e.g. lodash) — blocks the real violation, immune to staleness")
|
|
933
|
+
.option("--forbid-symbol <names>", "comma-sep identifier names that break the rule")
|
|
934
|
+
.option("--match <regex>", "textual line regex (lint-grade last resort; prefer --forbid-dep/--forbid-symbol)")
|
|
932
935
|
.action((statement, opts) => {
|
|
933
936
|
const SEV = ["advisory", "warning", "blocking"];
|
|
934
937
|
if (!SEV.includes(opts.severity))
|
|
@@ -936,6 +939,16 @@ program
|
|
|
936
939
|
const { store, root } = storeFor();
|
|
937
940
|
store.json.ensureDirs();
|
|
938
941
|
const scope = opts.scope.split(",").map((s) => toPosixTarget(s.trim())).filter(Boolean);
|
|
942
|
+
const csv = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : []);
|
|
943
|
+
const deps = csv(opts.forbidDep), symbols = csv(opts.forbidSymbol);
|
|
944
|
+
// Explicit matcher if given; else best-effort derive a dep from the statement so the
|
|
945
|
+
// common "never import X" rule is precise + staleness-immune by default, not scope-only.
|
|
946
|
+
let forbids = deps.length || symbols.length ? { deps, symbols, patterns: [] } : null;
|
|
947
|
+
let derived = false;
|
|
948
|
+
if (!forbids && !opts.match) {
|
|
949
|
+
forbids = deriveForbids(statement);
|
|
950
|
+
derived = !!forbids;
|
|
951
|
+
}
|
|
939
952
|
const c = store.json.put("constraints", {
|
|
940
953
|
id: constraintId(statement),
|
|
941
954
|
type: opts.type,
|
|
@@ -944,6 +957,7 @@ program
|
|
|
944
957
|
severity: opts.severity,
|
|
945
958
|
enforcement: opts.enforcement,
|
|
946
959
|
match: opts.match ?? null,
|
|
960
|
+
forbids,
|
|
947
961
|
rationale: opts.rationale,
|
|
948
962
|
source_decision: opts.sourceDecision ?? null,
|
|
949
963
|
violations: [],
|
|
@@ -955,11 +969,13 @@ program
|
|
|
955
969
|
store.reindex();
|
|
956
970
|
updateClaudeMd(root, store);
|
|
957
971
|
console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})`);
|
|
958
|
-
if (
|
|
972
|
+
if (derived && c.forbids?.deps.length)
|
|
973
|
+
console.log(` ↳ matcher: forbids import of ${c.forbids.deps.join(", ")} (precise, immune to staleness)`);
|
|
974
|
+
if (c.severity === "blocking" && !effectiveForbids(c)) {
|
|
959
975
|
// The default path's sharp edge: a scope-only blocking rule fails OPEN once any
|
|
960
976
|
// file in scope is committed after today (staleness). Point the user at the fix.
|
|
961
977
|
console.log(` ⚠ scope-only — this will downgrade to advisory once a file in scope is changed after today.`);
|
|
962
|
-
console.log(` To block the actual violation across the file's life, add
|
|
978
|
+
console.log(` To block the actual violation across the file's life, add --forbid-dep <pkg> (or --forbid-symbol / --match).`);
|
|
963
979
|
}
|
|
964
980
|
store.close();
|
|
965
981
|
});
|
|
@@ -1,38 +1,138 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* CONTENT (the rule was actually broken) instead of by bare SCOPE-touch.
|
|
1
|
+
/** Precise, AST-grounded matching for a constraint's content — the primitive behind
|
|
2
|
+
* "this change actually BREAKS the rule" (dec_… content-matched constraints).
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
4
|
+
* A constraint can forbid, in precision order:
|
|
5
|
+
* - deps: an external import (matched against the PARSED import set, so a comment
|
|
6
|
+
* or a string literal that merely names the module can't trip it; a
|
|
7
|
+
* submodule import like "lodash/groupBy" is caught by "lodash").
|
|
8
|
+
* - symbols: an identifier added in scoped code (whole-word, comments stripped).
|
|
9
|
+
* - patterns: a line regex (lint-grade last resort; comments stripped, strings kept).
|
|
10
|
+
*
|
|
11
|
+
* Deciding a violation by CONTENT is verifiable per commit, so a content-matched
|
|
12
|
+
* invariant is immune to file-change "staleness" and keeps its teeth across the file's
|
|
13
|
+
* whole life. The same matcher backs the Veto Guard's tripwires (one matcher, audited
|
|
14
|
+
* once) — see HunchStore.matchTripwire. */
|
|
15
|
+
const EMPTY = { deps: [], symbols: [], patterns: [] };
|
|
16
|
+
/** Walk the precision-first ladder against an analyzed diff/edit: dep (parsed import,
|
|
17
|
+
* exact or submodule) > symbol (whole-word identifier in scoped code) > pattern (scoped
|
|
18
|
+
* regex). Returns the highest-precision match, or null. */
|
|
19
|
+
export function matchForbids(f, addedDeps, scopedAdded) {
|
|
20
|
+
const codeLines = scopedAdded.map(matchableCode); // comments stripped once, strings kept
|
|
21
|
+
// dep tier: a forbidden dep (or a submodule of it) must be a genuinely-new external
|
|
22
|
+
// import (addedDeps, parsed) AND actually imported on a scoped line — never a mention
|
|
23
|
+
// in a comment or string. addedDeps comes from the import parser, so it is already
|
|
24
|
+
// comment/string-immune; the scoped importsDep check stops an out-of-scope import from
|
|
25
|
+
// tripping an in-scope edit.
|
|
26
|
+
const hitDeps = [];
|
|
27
|
+
for (const dep of f.deps) {
|
|
28
|
+
const added = [...addedDeps].find((d) => d === dep || d.startsWith(`${dep}/`));
|
|
29
|
+
if (added && codeLines.some((l) => importsDep(l, added)))
|
|
30
|
+
hitDeps.push(added);
|
|
17
31
|
}
|
|
18
|
-
|
|
19
|
-
return
|
|
32
|
+
if (hitDeps.length)
|
|
33
|
+
return { tier: "dep", evidence: hitDeps.map((d) => `+import ${d}`) };
|
|
34
|
+
const hitSyms = f.symbols.filter((s) => {
|
|
35
|
+
const re = new RegExp(`\\b${escapeRe(s)}\\b`);
|
|
36
|
+
return codeLines.some((l) => re.test(l));
|
|
37
|
+
});
|
|
38
|
+
if (hitSyms.length)
|
|
39
|
+
return { tier: "symbol", evidence: hitSyms.map((s) => `+${s}`) };
|
|
40
|
+
for (const p of f.patterns) {
|
|
41
|
+
const re = safeRe(p);
|
|
42
|
+
if (!re)
|
|
43
|
+
continue;
|
|
44
|
+
const hit = codeLines.find((l) => re.test(l));
|
|
45
|
+
if (hit)
|
|
46
|
+
return { tier: "pattern", evidence: [`/${p}/ matched: ${hit.trim().slice(0, 80)}`] };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/** A constraint's effective forbids: its structured `forbids` plus a legacy `--match`
|
|
51
|
+
* regex folded into the pattern tier (back-compat with v0.35). null when it has none. */
|
|
52
|
+
export function effectiveForbids(c) {
|
|
53
|
+
const base = c.forbids ?? EMPTY;
|
|
54
|
+
const patterns = c.match ? [...base.patterns, c.match] : base.patterns;
|
|
55
|
+
const f = { deps: base.deps, symbols: base.symbols, patterns };
|
|
56
|
+
return f.deps.length || f.symbols.length || f.patterns.length ? f : null;
|
|
57
|
+
}
|
|
58
|
+
/** The external import module names on a set of raw lines (for the edit-time hook,
|
|
59
|
+
* which sees proposed lines, not a parsed diff). Relative imports are ignored. */
|
|
60
|
+
export function importedDeps(lines) {
|
|
61
|
+
const out = new Set();
|
|
62
|
+
for (const raw of lines) {
|
|
63
|
+
const l = matchableCode(raw);
|
|
64
|
+
const m = l.match(/(?:^\s*import\b[^'"]*|^\s*\}?\s*from\s+|\brequire\(\s*)['"]([^'"]+)['"]/);
|
|
65
|
+
if (m && m[1] && !m[1].startsWith("."))
|
|
66
|
+
out.add(m[1]);
|
|
20
67
|
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** Best-effort: derive a forbidden DEP from a natural-language rule like "never import
|
|
71
|
+
* lodash" / "don't use the axios package" — so the seamless capture path (a human
|
|
72
|
+
* correction → enforced constraint) mints a PRECISE matcher, not a scope-only rule that
|
|
73
|
+
* goes stale. Conservative: only fires on an explicit import/require verb + a
|
|
74
|
+
* module-shaped token; returns null otherwise (caller falls back to scope-only). */
|
|
75
|
+
export function deriveForbids(rule) {
|
|
76
|
+
// Derive ONLY from a NEGATIVE rule with an UNAMBIGUOUS import verb. "use"/"add" are
|
|
77
|
+
// deliberately excluded: "don't use react hooks" names hooks, not react, and "never use
|
|
78
|
+
// synchronous fs" names no dependency at all — over-deriving there mints a wrong or
|
|
79
|
+
// never-firing rule on the seamless path. Under-deriving is safe (caller falls back to a
|
|
80
|
+
// scope-only rule + the "add --forbid-dep" warning); over-deriving is not.
|
|
81
|
+
if (!/\b(never|don'?t|do\s+not|avoid|stop|without|ban|banned|forbid|forbidden|prohibit|not\s+allowed|no\s+longer)\b/i.test(rule))
|
|
82
|
+
return null;
|
|
83
|
+
const m = rule.match(/\b(?:import(?:ing)?|requir(?:e|ing)|depend(?:s|ing)?\s+on)\s+(?:from\s+|the\s+)?["'`]?(@?[a-z0-9][\w.@/-]*)["'`]?/i);
|
|
84
|
+
if (!m || !m[1])
|
|
85
|
+
return null;
|
|
86
|
+
const dep = m[1].replace(/[).,;:'"`]+$/, "").toLowerCase();
|
|
87
|
+
if (!dep || dep.length < 2 || /\s/.test(dep) || STOP_TOKENS.has(dep))
|
|
88
|
+
return null;
|
|
89
|
+
return { deps: [dep], symbols: [], patterns: [] };
|
|
21
90
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
91
|
+
// English words a forbid-verb might grab that are never a dependency.
|
|
92
|
+
const STOP_TOKENS = new Set([
|
|
93
|
+
"strict", "this", "that", "it", "them", "the", "a", "an", "any", "async", "await",
|
|
94
|
+
"const", "let", "var", "new", "type", "types", "care", "caution", "again", "anything",
|
|
95
|
+
]);
|
|
96
|
+
/** The enforceable CODE of an added line: a comment carries no invariant, so the lint-grade
|
|
97
|
+
* symbol/pattern tiers must not fire on it (a `// we avoid lodash` note is not a violation).
|
|
98
|
+
* Strips a line comment, an inline block comment, and a comment-only / JSDoc line — but NOT
|
|
99
|
+
* string literals, since an import specifier (`from "lodash"`) is itself a string.
|
|
100
|
+
* Single-pass and line-local, so a multi-line block-comment BODY line (no marker) is NOT
|
|
101
|
+
* stripped: that residual false-positive is why the DEP tier — gated on the PARSED import
|
|
102
|
+
* set, immune to any comment — is the precise path for "never import X". */
|
|
27
103
|
export function matchableCode(line) {
|
|
28
104
|
if (/^\s*(\/\/|\/\*|\*)/.test(line))
|
|
29
|
-
return "";
|
|
30
|
-
return line
|
|
105
|
+
return "";
|
|
106
|
+
return line
|
|
107
|
+
.replace(/\/\*.*?\*\//g, "") // inline /* … */
|
|
108
|
+
.replace(/\s+\/\/.*$/, ""); // trailing // comment (keeps "://" inside strings/URLs)
|
|
31
109
|
}
|
|
32
|
-
/**
|
|
110
|
+
/** Legacy: compile a constraint's `--match` regex defensively (bad regex → inert). */
|
|
111
|
+
export function constraintMatcher(pattern) {
|
|
112
|
+
return safeRe(pattern);
|
|
113
|
+
}
|
|
114
|
+
/** True iff any added line's CODE trips a single regex (the v0.35 textual path). */
|
|
33
115
|
export function contentViolates(re, addedLines) {
|
|
34
116
|
if (!re)
|
|
35
117
|
return false;
|
|
36
118
|
return addedLines.some((l) => re.test(matchableCode(l)));
|
|
37
119
|
}
|
|
120
|
+
function safeRe(p) {
|
|
121
|
+
if (!p)
|
|
122
|
+
return null;
|
|
123
|
+
try {
|
|
124
|
+
return new RegExp(p);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function escapeRe(s) {
|
|
131
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
132
|
+
}
|
|
133
|
+
/** Does a line actually IMPORT `dep` (not merely mention it)? Covers `import x from
|
|
134
|
+
* "dep"`, `import "dep"`, `} from "dep"`, `require("dep")`. */
|
|
135
|
+
function importsDep(line, dep) {
|
|
136
|
+
return new RegExp(`(?:from|import|require\\(?)\\s*['"]${escapeRe(dep)}['"]`).test(line);
|
|
137
|
+
}
|
|
38
138
|
//# sourceMappingURL=constraintmatch.js.map
|
package/dist/core/correction.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { constraintId } from "./ids.js";
|
|
12
12
|
import { toPosixTarget } from "./paths.js";
|
|
13
|
+
import { deriveForbids } from "./constraintmatch.js";
|
|
13
14
|
/** Correction cues. Deliberately conservative — anchored to imperative/rebuke
|
|
14
15
|
* phrasing, not bare "no", so ordinary conversational negation ("no idea",
|
|
15
16
|
* "no problem") doesn't train users to ignore the nudge (research risk #5). */
|
|
@@ -68,6 +69,10 @@ export function buildCorrectionConstraint(input, now) {
|
|
|
68
69
|
severity,
|
|
69
70
|
enforcement: "advisory_v1",
|
|
70
71
|
match: null,
|
|
72
|
+
// Best-effort precise matcher from the rule text ("never import lodash" → forbids lodash),
|
|
73
|
+
// so the seamless capture path mints enforcement that survives file churn, not a scope-only
|
|
74
|
+
// rule that goes stale. null when nothing derivable → falls back to scope-based.
|
|
75
|
+
forbids: deriveForbids(rule),
|
|
71
76
|
rationale: input.rationale ?? "Captured from a human correction of the agent (Never Twice).",
|
|
72
77
|
source_decision: input.source_decision ?? null,
|
|
73
78
|
violations: [],
|
package/dist/core/hookpolicy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { effectiveForbids, matchForbids, importedDeps } from "./constraintmatch.js";
|
|
2
2
|
/** Return a BlockingHit if editing `file` (repo-relative) hits a blocking
|
|
3
3
|
* invariant directly or through its blast radius, else null. `proposedAddedLines`
|
|
4
4
|
* are the lines the edit would ADD: a CONTENT-MATCHED invariant (one carrying a
|
|
@@ -6,12 +6,14 @@ import { constraintMatcher, contentViolates } from "./constraintmatch.js";
|
|
|
6
6
|
* on edits that don't break the rule, instead of blocking every edit in scope
|
|
7
7
|
* (dec_e0a36efbf5). Scope-only invariants keep the blunt scope-touch behavior. */
|
|
8
8
|
export function blockingInScope(store, file, proposedAddedLines = []) {
|
|
9
|
+
const addedDeps = importedDeps(proposedAddedLines); // parse imports out of the proposed edit
|
|
9
10
|
for (const c of store.checkConstraints(file)) {
|
|
10
11
|
if (c.severity !== "blocking")
|
|
11
12
|
continue;
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const forbids = effectiveForbids(c);
|
|
14
|
+
// content-matched & not tripped by the proposed edit → allow (don't block every edit in scope)
|
|
15
|
+
if (forbids && !matchForbids(forbids, addedDeps, proposedAddedLines))
|
|
16
|
+
continue;
|
|
15
17
|
return {
|
|
16
18
|
reason: `Hunch: editing ${file} would touch a BLOCKING invariant — "${c.statement}" (${c.id}). Do not proceed unless this change is meant to modify that invariant; otherwise preserve it.`,
|
|
17
19
|
};
|
|
@@ -23,7 +25,7 @@ export function blockingInScope(store, file, proposedAddedLines = []) {
|
|
|
23
25
|
// A content matcher tests the EDITED file's own added lines; it has nothing to
|
|
24
26
|
// assert about a transitive dependency, so content-matched invariants don't fire
|
|
25
27
|
// via blast radius — only scope-only invariants keep the blast-radius warning.
|
|
26
|
-
if (
|
|
28
|
+
if (effectiveForbids(c))
|
|
27
29
|
continue;
|
|
28
30
|
return {
|
|
29
31
|
reason: `Hunch: ${file} is in the blast radius of a BLOCKING invariant — "${c.statement}" (${c.id}; via ${b.file}, ${b.via} depth ${b.depth}). Verify the invariant still holds before editing.`,
|
package/dist/core/types.js
CHANGED
|
@@ -169,7 +169,20 @@ export const ConstraintSchema = z.object({
|
|
|
169
169
|
// instead of on bare scope-touch. A content-verifiable invariant is decided per
|
|
170
170
|
// commit, so it is immune to file-change "staleness" and keeps its teeth across the
|
|
171
171
|
// file's whole life — and stays quiet on edits that don't break it (dec_e0a36efbf5).
|
|
172
|
+
// Legacy textual tier; prefer `forbids` below, which is parsed-import precise.
|
|
172
173
|
match: z.string().nullable().default(null),
|
|
174
|
+
// Precise content matcher (same ladder as a veto tripwire): a violation is a forbidden
|
|
175
|
+
// dep IMPORTED, symbol added, or pattern matched in scoped code. The dep tier is parsed
|
|
176
|
+
// from the import set, so comments/strings naming the module can't false-positive. Like
|
|
177
|
+
// `match`, a forbids-matched invariant is staleness-immune.
|
|
178
|
+
forbids: z
|
|
179
|
+
.object({
|
|
180
|
+
deps: z.array(z.string()).default([]),
|
|
181
|
+
symbols: z.array(z.string()).default([]),
|
|
182
|
+
patterns: z.array(z.string()).default([]),
|
|
183
|
+
})
|
|
184
|
+
.nullable()
|
|
185
|
+
.default(null),
|
|
173
186
|
rationale: z.string().default(""),
|
|
174
187
|
source_decision: z.string().nullable().default(null),
|
|
175
188
|
violations: z.array(z.string()).default([]),
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -22,7 +22,7 @@ import { gitCommonDir } from "../extractors/git.js";
|
|
|
22
22
|
import { pathMatchesGlob } from "../core/glob.js";
|
|
23
23
|
import { edgeId } from "../core/ids.js";
|
|
24
24
|
import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
|
|
25
|
-
import {
|
|
25
|
+
import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
|
|
26
26
|
import { analyzeDiff } from "../extractors/diff.js";
|
|
27
27
|
export class HunchStore {
|
|
28
28
|
paths;
|
|
@@ -654,16 +654,18 @@ export class HunchStore {
|
|
|
654
654
|
removedNames: new Set(an.removedSymbols.map((s) => s.name)),
|
|
655
655
|
});
|
|
656
656
|
const directReport = [];
|
|
657
|
+
const addedDepSet = new Set(an.addedDeps);
|
|
657
658
|
for (const { c, files: fs } of direct.values()) {
|
|
658
|
-
const
|
|
659
|
-
if (
|
|
660
|
-
// CONTENT-MATCHED: decide by whether
|
|
661
|
-
//
|
|
662
|
-
// doesn't trip
|
|
663
|
-
// the staleness gate: content is verified per
|
|
664
|
-
// the teeth (dec_e0a36efbf5). Empty diff ⇒ can't
|
|
665
|
-
|
|
666
|
-
|
|
659
|
+
const forbids = effectiveForbids(c);
|
|
660
|
+
if (forbids) {
|
|
661
|
+
// CONTENT-MATCHED: decide by whether the diff actually breaks the rule (a forbidden
|
|
662
|
+
// dep imported / symbol added / pattern matched in scoped code) — not by bare
|
|
663
|
+
// scope-touch. A commit that touches the scope but doesn't trip it COMPLIES → drop it
|
|
664
|
+
// (no noise). A real hit blocks WITHOUT the staleness gate: content is verified per
|
|
665
|
+
// commit, so file churn can't retract the teeth (dec_e0a36efbf5). Empty diff ⇒ can't
|
|
666
|
+
// prove a violation ⇒ treat as clean.
|
|
667
|
+
const scopedAdded = fs.flatMap((f) => an.addedLinesByFile.get(f) ?? []);
|
|
668
|
+
if (!matchForbids(forbids, addedDepSet, scopedAdded))
|
|
667
669
|
continue;
|
|
668
670
|
const strictBlocks = isStrictBlocker(c, false);
|
|
669
671
|
directReport.push({
|
|
@@ -1050,48 +1052,11 @@ export class HunchStore {
|
|
|
1050
1052
|
return ctx;
|
|
1051
1053
|
}
|
|
1052
1054
|
}
|
|
1053
|
-
/**
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
* is compiled defensively and never throws (a malformed pattern is simply inert). */
|
|
1055
|
+
/** A tripwire matches via the shared precision ladder (dep > symbol > pattern). One
|
|
1056
|
+
* matcher backs both the Veto Guard and content-matched constraints (constraintmatch.ts),
|
|
1057
|
+
* so the parse-the-import precision is audited in exactly one place. */
|
|
1057
1058
|
function matchTripwire(tw, addedDeps, scopedAdded) {
|
|
1058
|
-
|
|
1059
|
-
// AND imported in a SCOPED added line — not merely added somewhere else in the
|
|
1060
|
-
// diff. Without the scoped check, axios added in an out-of-scope file plus any
|
|
1061
|
-
// edit to an in-scope file would false-positive.
|
|
1062
|
-
const hitDeps = tw.forbids.deps.filter((dep) => addedDeps.has(dep) && scopedAdded.some((l) => importsDep(l, dep)));
|
|
1063
|
-
if (hitDeps.length)
|
|
1064
|
-
return { tier: "dep", evidence: hitDeps.map((d) => `+import ${d}`) };
|
|
1065
|
-
const hitSyms = tw.forbids.symbols.filter((s) => {
|
|
1066
|
-
const re = new RegExp(`\\b${escapeRe(s)}\\b`);
|
|
1067
|
-
return scopedAdded.some((l) => re.test(l));
|
|
1068
|
-
});
|
|
1069
|
-
if (hitSyms.length)
|
|
1070
|
-
return { tier: "symbol", evidence: hitSyms.map((s) => `+${s}`) };
|
|
1071
|
-
for (const p of tw.forbids.patterns) {
|
|
1072
|
-
let re = null;
|
|
1073
|
-
try {
|
|
1074
|
-
re = new RegExp(p);
|
|
1075
|
-
}
|
|
1076
|
-
catch {
|
|
1077
|
-
re = null; // malformed pattern is inert, never a thrown error in the gate
|
|
1078
|
-
}
|
|
1079
|
-
if (!re)
|
|
1080
|
-
continue;
|
|
1081
|
-
const hit = scopedAdded.find((l) => re.test(l));
|
|
1082
|
-
if (hit)
|
|
1083
|
-
return { tier: "pattern", evidence: [`/${p}/ matched: ${hit.trim().slice(0, 80)}`] };
|
|
1084
|
-
}
|
|
1085
|
-
return null;
|
|
1086
|
-
}
|
|
1087
|
-
function escapeRe(s) {
|
|
1088
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1089
|
-
}
|
|
1090
|
-
/** Does an added line actually IMPORT `dep` (not merely mention it in a string)?
|
|
1091
|
-
* Covers `import x from "dep"`, `import "dep"`, `} from "dep"`, `require("dep")` —
|
|
1092
|
-
* so a literal like `const m = "axios"` no longer trips the dep tier. */
|
|
1093
|
-
function importsDep(line, dep) {
|
|
1094
|
-
return new RegExp(`(?:from|import|require\\(?)\\s*['"]${escapeRe(dep)}['"]`).test(line);
|
|
1059
|
+
return matchForbids(tw.forbids, addedDeps, scopedAdded);
|
|
1095
1060
|
}
|
|
1096
1061
|
function sev(s) {
|
|
1097
1062
|
return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
|
|
@@ -281,6 +281,7 @@ function promoteConstraint(store, bug) {
|
|
|
281
281
|
severity: bug.severity === "critical" ? "blocking" : "warning",
|
|
282
282
|
enforcement: "advisory_v1",
|
|
283
283
|
match: null,
|
|
284
|
+
forbids: null,
|
|
284
285
|
rationale: `Derived from ${bug.id}: ${bug.root_cause || bug.symptom}`,
|
|
285
286
|
source_decision: null,
|
|
286
287
|
violations: [],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Hunch — an Engineering Memory OS: a persistent, git-native graph of the decisions, bugs, and rules behind your code, served to any MCP coding assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",
|