@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 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
- Give a blocking rule a content matcher — `record-constraint "…" --scope "src/**" --severity
176
- blocking --match "lodash"` — and it blocks the *actual* violation across the file's whole life
177
- (matched against added code; comments ignored) instead of relaxing to advisory after the file
178
- is edited again. It's a deterministic guard for the obvious/accidental violation, not a
179
- bypass-proof boundary.
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("--match <regex>", "content matcher: block only when an ADDED line matches this regex (precise + immune to staleness, vs scope-touch)")
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 (c.severity === "blocking" && !c.match) {
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 a content matcher, e.g. --match "lodash"`);
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
- /** Optional CONTENT matcher for a constraint: a regex tested against the lines a
2
- * diff/edit ADDS. When a constraint carries one, the gate decides a violation by
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
- * Why this matters: scope-touch enforcement is so blunt that strict had to fail
6
- * OPEN once a guarded file changed (the "staleness" gate) or it would block every
7
- * edit in scope which silently retracts the teeth over a file's normal life
8
- * (dec_e0a36efbf5). A content match is verifiable PER COMMIT, so it needs no
9
- * staleness proxy: a vouched, content-matched invariant keeps blocking the actual
10
- * violation across the whole life of the file, and stays quiet on edits that don't
11
- * break it. Bad user/LLM regex is compiled defensively and is simply inert. */
12
- export function constraintMatcher(pattern) {
13
- if (!pattern)
14
- return null;
15
- try {
16
- return new RegExp(pattern);
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
- catch {
19
- return null; // malformed pattern inert, never throws
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
- /** The enforceable CODE of an added line: a comment carries no invariant, so a rule
23
- * must not fire on it (a `// we avoid lodash` note is not a violation). We strip
24
- * comments but NOT string literals — the thing a matcher most often targets, an
25
- * import specifier (`from "lodash"`), IS a string, so stripping strings would blind
26
- * the matcher to the real violation. A comment-only line → "". */
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 ""; // // line, /* block, or * JSDoc-continuation
30
- return line.replace(/\s+\/\/.*$/, ""); // drop a trailing inline // comment (keeps "://" in URLs/strings)
105
+ return "";
106
+ return line
107
+ .replace(/\/\*.*?\*\//g, "") // inline /* … */
108
+ .replace(/\s+\/\/.*$/, ""); // trailing // comment (keeps "://" inside strings/URLs)
31
109
  }
32
- /** True iff any ADDED line's CODE trips the constraint's content matcher. */
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
@@ -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: [],
@@ -1,4 +1,4 @@
1
- import { constraintMatcher, contentViolates } from "./constraintmatch.js";
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 re = constraintMatcher(c.match);
13
- if (re && !contentViolates(re, proposedAddedLines))
14
- continue; // content-matched & not tripped → allow
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 (constraintMatcher(c.match))
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.`,
@@ -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([]),
@@ -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 { constraintMatcher, contentViolates } from "../core/constraintmatch.js";
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 re = constraintMatcher(c.match);
659
- if (re) {
660
- // CONTENT-MATCHED: decide by whether an ADDED line in the matched files actually
661
- // breaks the rule not by bare scope-touch. A commit that touches the scope but
662
- // doesn't trip the matcher COMPLIES → drop it (no noise). A real hit blocks WITHOUT
663
- // the staleness gate: content is verified per commit, so file churn can't retract
664
- // the teeth (dec_e0a36efbf5). Empty diff ⇒ can't prove a violation ⇒ treat as clean.
665
- const added = fs.flatMap((f) => an.addedLinesByFile.get(f) ?? []);
666
- if (!contentViolates(re, added))
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
- /** Walk a tripwire's precision-first ladder against an analyzed diff. dep (exact
1054
- * set intersection) > symbol (whole-word identifier in an added line) > pattern
1055
- * (scoped regex). Returns the highest-precision match, or null. Bad user/LLM regex
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
- // dep tier: the forbidden dep must be a genuinely-new external import (addedDeps)
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.35.1",
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).",