@syv-ai/rulecast 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -89,19 +89,96 @@ A refusal only ever rests on the text the agent is writing, never on a reconstru
89
89
 
90
90
  | Command | Does |
91
91
  |---|---|
92
- | `rulecast init` | Interactive setup |
93
- | `rulecast install` / `uninstall` | Add or remove the agent hooks |
94
- | `rulecast run` | Check staged files, changed files, or everything |
92
+ | `rulecast init` | Set rulecast up: config, catalog rules, agent hooks |
93
+ | `rulecast install` / `uninstall` | Add (or upgrade) or remove the agent hooks |
94
+ | `rulecast run` | Check staged files (the default), changed files, or everything |
95
+ | `rulecast list` | Show every configured rule: where it comes from, what it matches, what it cites |
95
96
  | `rulecast test` | Run each rule's good/bad examples; `--against` says how much it would flag |
96
- | `rulecast validate` | Check the config and print diagnostics |
97
- | `rulecast doctor` | Compile, check the environment, dry-run every rule |
97
+ | `rulecast validate` | Check the config (and a rules manifest) and print diagnostics |
98
+ | `rulecast doctor` | Compile, check the environment and hooks, dry-run every rule |
98
99
  | `rulecast autoupdate` | Move pinned rule repos to their latest tag |
99
100
  | `rulecast try-repo` | Run a rule repo against your project without configuring it |
100
101
  | `rulecast clean` | Delete the cache |
101
102
  | `rulecast warm` | Build detector caches ahead of time |
102
- | `rulecast hook <adapter>` | Answer an agent hook on stdin |
103
+ | `rulecast hook <adapter>` | Answer an agent hook on stdin (the agent runs this, not you) |
103
104
 
104
- `rulecast run` is also how you use rulecast in CI. `--from-ref main` checks only what a branch changed, which matters most for `llm` rules — they judge changed files only.
105
+ `rulecast <command> --help` prints a command's flags; `rulecast --version` prints the version.
106
+
107
+ ## Git hooks and CI
108
+
109
+ The same rule reports the same line the same way in an agent hook, a git hook and CI. What counts as new is a match on a line you changed; a violation that was already there is backlog, shown and never failing.
110
+
111
+ - **Pre-commit:** `rulecast run` with no file flags checks the staged files, reads their content from the index (what is being committed, not the working tree), and judges it against `HEAD`. `llm` rules are skipped unless you pass `--llm`, and one line says so: a commit should not cost money.
112
+ - **Pre-push and CI:** `rulecast run --from-ref A --to-ref B` checks the files changed between the merge base with `A` and `B`, reading them at `B`. Without `--to-ref` it reads the working tree.
113
+ - `rulecast run --files F...` judges files whole, with no baseline. Use the hook below rather than `--files` from a hook manager.
114
+
115
+ rulecast installs no git hooks itself: lefthook, husky and pre-commit already own `.git/hooks`. Add `@syv-ai/rulecast` to the project's devDependencies (the agent hooks need it there anyway), and wire it into the one you use.
116
+
117
+ **pre-commit** (`.pre-commit-config.yaml`):
118
+
119
+ ```yaml
120
+ minimum_pre_commit_version: "3.2.0"
121
+ repos:
122
+ - repo: https://github.com/syv-ai/rulecast
123
+ rev: v0.4.0
124
+ hooks:
125
+ - id: rulecast
126
+ - id: rulecast-push
127
+ ```
128
+
129
+ **lefthook** (`lefthook.yml`):
130
+
131
+ ```yaml
132
+ pre-commit:
133
+ jobs:
134
+ - name: rulecast
135
+ run: npx --no-install rulecast run
136
+ pre-push:
137
+ jobs:
138
+ - name: rulecast
139
+ run: npx --no-install rulecast run --from-ref "$(git merge-base origin/HEAD HEAD)" --to-ref HEAD
140
+ ```
141
+
142
+ **GitHub Actions:**
143
+
144
+ ```yaml
145
+ on: pull_request
146
+ jobs:
147
+ rulecast:
148
+ runs-on: ubuntu-latest
149
+ permissions:
150
+ contents: read
151
+ security-events: write
152
+ steps:
153
+ - uses: actions/checkout@v4
154
+ with:
155
+ fetch-depth: 0 # rulecast needs the merge base with the target branch
156
+ - uses: actions/setup-node@v4
157
+ with:
158
+ node-version: 22
159
+ - run: npm ci
160
+ - run: npx rulecast run --from-ref origin/${{ github.base_ref }} --format sarif > rulecast.sarif
161
+ - uses: github/codeql-action/upload-sarif@v3
162
+ if: always() # rulecast exits 1 on findings, which is when the upload matters
163
+ with:
164
+ sarif_file: rulecast.sarif
165
+ ```
166
+
167
+ A shallow clone has no merge base; rulecast says so and names `fetch-depth: 0`. `--from-ref` runs `llm` rules, which judge changed files only.
168
+
169
+ `command` scripts, and `oxlint`, are handed a scratch copy of each file in staged and `--to-ref` runs, because the content to judge is not the file on disk. `ruff` and `eslint` get it on stdin under the real path, so path-keyed configuration still applies.
170
+
171
+ ## Turning a rule off
172
+
173
+ - **A whole rule:** `enabled: false` on the rule. Under a rule repo's entry, `- id: <rule>` with `enabled: false` switches a catalog rule off and keeps the line.
174
+ - **One finding:** a comment on the matched line or the line above it, in the file's own comment syntax. The reason is required; an ignore without one suppresses nothing.
175
+
176
+ ```python
177
+ # rulecast-ignore: python/no-httpexception-in-services legacy endpoint, removed in #412
178
+ raise HTTPException(404)
179
+ ```
180
+
181
+ `rulecast run --all-files --summary` counts the ignores, and an ignore or a config change made during an agent session is reported to you when the agent stops: the agent cannot quietly edit its way past a rule.
105
182
 
106
183
  ## Adopting a rule on a codebase that already breaks it
107
184
 
@@ -143,7 +220,7 @@ rulecast up itself:
143
220
 
144
221
  ```text
145
222
  Set up rulecast in this project. Read
146
- https://raw.githubusercontent.com/syv-ai/rulecast/v0.3.0/agents/SETUP.md
223
+ https://raw.githubusercontent.com/syv-ai/rulecast/v0.4.0/agents/SETUP.md
147
224
  and follow it. Show me every file it creates or changes before I commit anything.
148
225
  ```
149
226
 
@@ -183,7 +260,7 @@ The budget is what shapes the design: one detector run per kind per event, kinds
183
260
 
184
261
  ## Status
185
262
 
186
- 0.1. Everything above works and is tested. The config format may still change before 1.0 — `minimum_rulecast_version` exists so a rule can say what it needs.
263
+ 0.4. Everything above works and is tested. Before 1.0 the config format and the output of `rulecast run` may still change; `minimum_rulecast_version` exists so a rule can say what it needs.
187
264
 
188
265
  Next: adapters for Codex, Cursor and OpenCode; Biome; an Azure OpenAI provider.
189
266
 
@@ -1,8 +1,31 @@
1
+ // src/core/version.ts
2
+ var VERSION = "0.4.0";
3
+ function parseVersion(text) {
4
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
5
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
6
+ }
7
+ function isOlder(running, minimum) {
8
+ const a = parseVersion(running);
9
+ const b = parseVersion(minimum);
10
+ if (!a || !b) return false;
11
+ for (let i = 0; i < 3; i++) {
12
+ if (a[i] !== b[i]) return a[i] < b[i];
13
+ }
14
+ return false;
15
+ }
16
+
1
17
  // src/core/detection/per-rule.ts
2
18
  import { readFile, stat } from "fs/promises";
3
19
  import path from "path";
4
20
 
5
21
  // src/core/errors.ts
22
+ import {
23
+ ZodDefault,
24
+ ZodEffects,
25
+ ZodNullable,
26
+ ZodObject,
27
+ ZodOptional
28
+ } from "zod";
6
29
  var DeadlineError = class extends Error {
7
30
  };
8
31
  function isNotFound(error) {
@@ -11,11 +34,75 @@ function isNotFound(error) {
11
34
  function errorMessage(error) {
12
35
  return error instanceof Error ? error.message : String(error);
13
36
  }
14
- function formatZodError(error) {
15
- return error.issues.map((issue) => `${issue.path.length ? issue.path.join(".") : "(root)"}: ${issue.message}`).join("; ");
37
+ function formatZodError(error, options = {}) {
38
+ const where = (path7) => [options.prefix, ...path7.map(String)].filter((part) => part !== void 0 && part !== "").join(".");
39
+ const describe = (issue) => {
40
+ const at = where(issue.path);
41
+ const label = at === "" ? "" : `${at}: `;
42
+ if (issue.code === "unrecognized_keys") {
43
+ const known = options.schema === void 0 ? [] : keysAt(options.schema, issue.path);
44
+ const missing = missingAt(error.issues, issue.path);
45
+ const standIn = issue.keys.length === 1 && missing.length === 1 ? missing[0] : null;
46
+ return issue.keys.map((key) => {
47
+ const guess = closest(key, known) ?? standIn;
48
+ return `${label}unknown key "${key}"${guess === null ? "" : ` (did you mean "${guess}"?)`}`;
49
+ }).join("; ");
50
+ }
51
+ const message = issue.message === "Required" ? "required" : issue.message;
52
+ return `${label}${message}`;
53
+ };
54
+ const ordered = [
55
+ ...error.issues.filter((issue) => issue.code === "unrecognized_keys"),
56
+ ...error.issues.filter((issue) => issue.code !== "unrecognized_keys")
57
+ ];
58
+ return ordered.map(describe).join("; ");
59
+ }
60
+ function keysAt(schema, path7) {
61
+ let current = schema;
62
+ const unwrap = (type) => {
63
+ if (type instanceof ZodOptional || type instanceof ZodNullable) return unwrap(type.unwrap());
64
+ if (type instanceof ZodDefault) return unwrap(type._def.innerType);
65
+ if (type instanceof ZodEffects) return unwrap(type.innerType());
66
+ return type;
67
+ };
68
+ for (const segment of path7) {
69
+ const object2 = current === void 0 ? void 0 : unwrap(current);
70
+ current = object2 instanceof ZodObject ? object2.shape[String(segment)] : void 0;
71
+ }
72
+ const object = current === void 0 ? void 0 : unwrap(current);
73
+ return object instanceof ZodObject ? Object.keys(object.shape) : [];
74
+ }
75
+ function missingAt(issues, path7) {
76
+ return issues.filter(
77
+ (issue) => issue.code === "invalid_type" && issue.received === "undefined" && issue.path.length === path7.length + 1 && path7.every((segment, index) => issue.path[index] === segment)
78
+ ).map((issue) => String(issue.path.at(-1)));
79
+ }
80
+ function closest(key, known) {
81
+ let best = null;
82
+ for (const candidate of known) {
83
+ const distance = levenshtein(key, candidate);
84
+ if (distance <= 2 && (best === null || distance < best.distance)) best = { key: candidate, distance };
85
+ }
86
+ return best?.key ?? null;
87
+ }
88
+ function levenshtein(a, b) {
89
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
90
+ for (let i = 1; i <= a.length; i++) {
91
+ let previous = row[0];
92
+ row[0] = i;
93
+ for (let j = 1; j <= b.length; j++) {
94
+ const above = row[j];
95
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
96
+ previous = above;
97
+ }
98
+ }
99
+ return row[b.length];
16
100
  }
17
101
 
18
102
  // src/core/detection/per-rule.ts
103
+ function pastDeadline(error, input) {
104
+ return error instanceof DeadlineError || input.signal.aborted || Date.now() > input.deadlineAt;
105
+ }
19
106
  function perRule(detect) {
20
107
  return async (input) => {
21
108
  const result = { findings: [], errors: [] };
@@ -24,7 +111,7 @@ function perRule(detect) {
24
111
  try {
25
112
  for (const match of await detect(rule, input)) result.findings.push({ rule: rule.id, match });
26
113
  } catch (error) {
27
- if (error instanceof DeadlineError || input.signal.aborted || Date.now() > input.deadlineAt) throw error;
114
+ if (pastDeadline(error, input)) throw error;
28
115
  result.errors.push({ rule: rule.id, message: errorMessage(error) });
29
116
  }
30
117
  })
@@ -40,6 +127,17 @@ async function readSourceFile(cwd, file) {
40
127
  throw error;
41
128
  }
42
129
  }
130
+ function sourceReader(cwd) {
131
+ const cache = /* @__PURE__ */ new Map();
132
+ return (file) => {
133
+ let source = cache.get(file);
134
+ if (source === void 0) {
135
+ source = readSourceFile(cwd, file);
136
+ cache.set(file, source);
137
+ }
138
+ return source;
139
+ };
140
+ }
43
141
  async function fileBytes(cwd, file) {
44
142
  try {
45
143
  return (await stat(path.resolve(cwd, file))).size;
@@ -48,6 +146,30 @@ async function fileBytes(cwd, file) {
48
146
  }
49
147
  }
50
148
 
149
+ // src/core/detection/positions.ts
150
+ function lineStarts(text) {
151
+ const starts = [0];
152
+ for (let i = 0; i < text.length; i++) {
153
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
154
+ }
155
+ return starts;
156
+ }
157
+ function positionAt(starts, offset) {
158
+ let low = 0;
159
+ let high = starts.length - 1;
160
+ while (low < high) {
161
+ const mid = low + high + 1 >> 1;
162
+ if (starts[mid] <= offset) low = mid;
163
+ else high = mid - 1;
164
+ }
165
+ return { line: low + 1, column: offset - starts[low] + 1 };
166
+ }
167
+ function offsetAt(starts, line, column, length) {
168
+ const index = Math.min(Math.max(line, 1), starts.length) - 1;
169
+ const offset = starts[index] + Math.max(column, 1) - 1;
170
+ return Math.min(Math.max(offset, 0), length);
171
+ }
172
+
51
173
  // src/detectors/llm/models.ts
52
174
  var ALIASES = {
53
175
  haiku: {
@@ -171,14 +293,14 @@ function keyAvailability(settings, env, fallback, path7) {
171
293
 
172
294
  // src/detectors/llm/providers/anthropic.ts
173
295
  var API = "https://api.anthropic.com";
174
- var VERSION = "2023-06-01";
296
+ var VERSION2 = "2023-06-01";
175
297
  var anthropicProvider = {
176
298
  name: "anthropic",
177
299
  async ask(request) {
178
300
  const key = apiKey(request);
179
301
  const reply = await postJson(
180
302
  endpoint(request.settings.baseUrl, API, "/v1/messages"),
181
- { "x-api-key": key, "anthropic-version": VERSION },
303
+ { "x-api-key": key, "anthropic-version": VERSION2 },
182
304
  {
183
305
  model: request.model,
184
306
  max_tokens: 4096,
@@ -403,22 +525,6 @@ function emptyDelivery() {
403
525
  };
404
526
  }
405
527
 
406
- // src/core/version.ts
407
- var VERSION2 = "0.3.0";
408
- function parseVersion(text) {
409
- const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
410
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
411
- }
412
- function isOlder(running, minimum) {
413
- const a = parseVersion(running);
414
- const b = parseVersion(minimum);
415
- if (!a || !b) return false;
416
- for (let i = 0; i < 3; i++) {
417
- if (a[i] !== b[i]) return a[i] < b[i];
418
- }
419
- return false;
420
- }
421
-
422
528
  // src/core/template.ts
423
529
  var VARIABLE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
424
530
  var CORE_VARIABLES = ["file", "line", "column", "text", "rule"];
@@ -458,6 +564,18 @@ var GROUP_FROM = 3;
458
564
  var GROUP_MIN_LITERAL = 40;
459
565
  var MAX_LOCATION_PAD = 40;
460
566
  var BACKLOG_HINT = " see all of it: rulecast run --all-files --summary";
567
+ var BACKLOG_HEADING = "backlog in files you touched (not from your edit; leave it unless asked):";
568
+ var WARNINGS_HEADING = "rulecast warnings:";
569
+ var CONVENTIONS_TITLE = "rulecast: conventions for the files you are working on";
570
+ var NOTICES_HEADING = "rulecast notices (for the user):";
571
+ var backlogLine = (summary) => ` ${summary.rule} \xD7${summary.count} in ${summary.file}`;
572
+ var backlogMoreLine = (count) => ` \u2026and ${count} more`;
573
+ var warningLine = (warning) => ` - ${warning}`;
574
+ var overflowLines = (rules, path7) => [
575
+ `\u2026${plural(rules, "rule")} and the conventions they cite did not fit here.`,
576
+ "Every finding, with the doc sections it cites, is in:",
577
+ ` ${path7}`
578
+ ];
461
579
  var WRAP = 96;
462
580
  var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
463
581
  function header(delivery) {
@@ -468,7 +586,7 @@ function header(delivery) {
468
586
  return `rulecast: ${plural(rules, "rule")} violated${where}`;
469
587
  }
470
588
  if (delivery.references.length > 0 || delivery.preexistingSummary.length > 0) {
471
- return "rulecast: conventions for the files you are working on";
589
+ return CONVENTIONS_TITLE;
472
590
  }
473
591
  return null;
474
592
  }
@@ -545,12 +663,66 @@ function ruleBlock(rule, findings, template, dropped, options) {
545
663
  }
546
664
  return out;
547
665
  }
548
- function measureRuleBlock(rule, findings, template, options) {
549
- return ruleBlock(rule, findings, template, { count: 0, files: 0 }, options).join("\n").length + 1;
666
+ var linesCost = (lines) => lines.reduce((sum, line) => sum + line.length + 1, 0);
667
+ function measureRuleBlock(rule, shown, all, template, options) {
668
+ const shownFiles = new Set(shown.map((finding) => finding.file));
669
+ const rest = all.slice(shown.length);
670
+ const cut = { count: rest.length, files: new Set(rest.map((f) => f.file).filter((f) => !shownFiles.has(f))).size };
671
+ return linesCost([...ruleBlock(rule, shown, template, cut, options), ""]);
550
672
  }
673
+ var OVERFLOW_PATH_ALLOWANCE = 192;
674
+ var deliveryCost = {
675
+ /**
676
+ * The title and the blank line under it, at its longest — two things about it are not known yet.
677
+ * It names a file when every *delivered* finding is in one, and the budget may cut a rule down to
678
+ * findings in a single file however many files it fired in; so the longest file name is charged.
679
+ * And if the budget drops every rule that fired, no findings are left and the conventions title
680
+ * prints instead; so with `conventions` the longer of the two is charged.
681
+ */
682
+ header(rules, files, conventions) {
683
+ const longest = [...files].reduce((most, file) => file.length > most.length ? file : most, "");
684
+ const where = longest === "" ? "" : ` in ${longest}`;
685
+ const violated = rules > 0 ? linesCost([`rulecast: ${plural(rules, "rule")} violated${where}`, ""]) : 0;
686
+ return Math.max(violated, conventions ? linesCost([CONVENTIONS_TITLE, ""]) : 0);
687
+ },
688
+ /**
689
+ * One reference's line, at its longest — the "not included, too long" form, naming the file when
690
+ * it has an absolute location. Every other state prints a shorter line, so this bounds them all.
691
+ */
692
+ referenceLine(ref, location) {
693
+ return linesCost(referenceLines({ ref, state: "read", reason: "budget", location }));
694
+ },
695
+ /** What a reference given in full adds over its one-line price: the content and the blank line. */
696
+ referenceContent(ref, content, location) {
697
+ return linesCost(referenceLines({ ref, state: "full", content })) - deliveryCost.referenceLine(ref, location);
698
+ },
699
+ /** The blank line that closes the references, once. */
700
+ referencesEnd: 1,
701
+ /** The warnings heading, once. */
702
+ warningsFrame: linesCost([WARNINGS_HEADING]),
703
+ warning(text) {
704
+ return linesCost([warningLine(text)]);
705
+ },
706
+ /**
707
+ * The backlog's heading, its hint, the blank line after, and the "…and N more" line at its
708
+ * longest — printed only when summaries are cut, and charged up front so a cut cannot overflow.
709
+ */
710
+ backlogFrame(summaries) {
711
+ return linesCost([BACKLOG_HEADING, backlogMoreLine(summaries), BACKLOG_HINT, ""]);
712
+ },
713
+ backlogSummary(summary) {
714
+ return linesCost([backlogLine(summary)]);
715
+ },
716
+ /** The lines naming the overflow file, and the blank line before them. */
717
+ overflowNotice(rules) {
718
+ return linesCost(["", ...overflowLines(rules, "x".repeat(OVERFLOW_PATH_ALLOWANCE))]);
719
+ }
720
+ };
551
721
  function renderAgentText(delivery, options) {
552
722
  const title = header(delivery);
553
- if (title === null && delivery.warnings.length === 0) return "";
723
+ const notices = options.notices === false ? [] : delivery.notices ?? [];
724
+ const warnings = options.warnings === false ? [] : delivery.warnings;
725
+ if (title === null && warnings.length === 0 && notices.length === 0) return "";
554
726
  const out = [];
555
727
  if (title !== null) out.push(title, "");
556
728
  const byRule = /* @__PURE__ */ new Map();
@@ -564,25 +736,23 @@ function renderAgentText(delivery, options) {
564
736
  out.push("");
565
737
  }
566
738
  if (delivery.preexistingSummary.length > 0 || delivery.omitted.preexisting > 0) {
567
- out.push("backlog in files you touched (not from your edit):");
568
- for (const summary of delivery.preexistingSummary) {
569
- out.push(` ${summary.rule} \xD7${summary.count} in ${summary.file}`);
570
- }
571
- if (delivery.omitted.preexisting > 0) out.push(` \u2026and ${delivery.omitted.preexisting} more`);
739
+ out.push(BACKLOG_HEADING);
740
+ for (const summary of delivery.preexistingSummary) out.push(backlogLine(summary));
741
+ if (delivery.omitted.preexisting > 0) out.push(backlogMoreLine(delivery.omitted.preexisting));
572
742
  out.push(BACKLOG_HINT, "");
573
743
  }
574
744
  for (const reference of delivery.references) out.push(...referenceLines(reference));
575
745
  if (out.length > 0 && out.at(-1) !== "") out.push("");
576
- if (delivery.warnings.length > 0) {
577
- out.push("rulecast warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`));
746
+ if (warnings.length > 0) {
747
+ out.push(WARNINGS_HEADING, ...warnings.map(warningLine));
578
748
  }
579
749
  if (delivery.overflowPath !== null) {
580
750
  if (out.at(-1) !== "") out.push("");
581
- out.push(
582
- `\u2026${plural(delivery.omitted.rules, "rule")} and the conventions they cite did not fit here.`,
583
- "Every finding, with the doc sections it cites, is in:",
584
- ` ${delivery.overflowPath}`
585
- );
751
+ out.push(...overflowLines(delivery.omitted.rules, delivery.overflowPath));
752
+ }
753
+ if (notices.length > 0) {
754
+ if (out.length > 0 && out.at(-1) !== "") out.push("");
755
+ out.push(NOTICES_HEADING, ...notices.map(warningLine));
586
756
  }
587
757
  while (out.at(-1) === "") out.pop();
588
758
  return out.join("\n");
@@ -759,8 +929,8 @@ function fileOf(spec) {
759
929
  async function compileRule(input, context) {
760
930
  const { data } = input;
761
931
  const minimum = data.minimum_rulecast_version;
762
- if (minimum !== void 0 && isOlder(VERSION2, minimum)) {
763
- return `requires rulecast ${minimum} or newer (running ${VERSION2})`;
932
+ if (minimum !== void 0 && isOlder(VERSION, minimum)) {
933
+ return `requires rulecast ${minimum} or newer (running ${VERSION})`;
764
934
  }
765
935
  let detector = null;
766
936
  let defaultStages = ["touch"];
@@ -769,7 +939,8 @@ async function compileRule(input, context) {
769
939
  const implementation = context.registry.get(kind);
770
940
  if (!implementation) return `unknown detector "${kind}"`;
771
941
  const parsed = await implementation.schema.safeParseAsync(rawConfig ?? {});
772
- if (!parsed.success) return `detect.${kind}: ${formatZodError(parsed.error)}`;
942
+ if (!parsed.success)
943
+ return formatZodError(parsed.error, { prefix: `detect.${kind}`, schema: implementation.schema });
773
944
  detector = { kind, config: parsed.data, captures: implementation.captures(parsed.data) };
774
945
  defaultStages = implementation.events(parsed.data);
775
946
  }
@@ -832,7 +1003,9 @@ async function compileRule(input, context) {
832
1003
  severity: data.severity ?? "error",
833
1004
  scope: data.scope ?? "instance",
834
1005
  refuseWrite: data.refuse_write === true,
1006
+ enabled: data.enabled !== false,
835
1007
  stages,
1008
+ patterns: { files: data.files ?? "", exclude: data.exclude ?? "^$" },
836
1009
  matches: (file) => global(file) && filter(file),
837
1010
  detector,
838
1011
  message: data.message ?? null,
@@ -880,37 +1053,18 @@ function detectorCacheDir(stateDir, kind) {
880
1053
  return path6.join(stateDir, "cache", kind);
881
1054
  }
882
1055
 
883
- // src/core/detection/positions.ts
884
- function lineStarts(text) {
885
- const starts = [0];
886
- for (let i = 0; i < text.length; i++) {
887
- if (text.charCodeAt(i) === 10) starts.push(i + 1);
888
- }
889
- return starts;
890
- }
891
- function positionAt(starts, offset) {
892
- let low = 0;
893
- let high = starts.length - 1;
894
- while (low < high) {
895
- const mid = low + high + 1 >> 1;
896
- if (starts[mid] <= offset) low = mid;
897
- else high = mid - 1;
898
- }
899
- return { line: low + 1, column: offset - starts[low] + 1 };
900
- }
901
- function offsetAt(starts, line, column, length) {
902
- const index = Math.min(Math.max(line, 1), starts.length) - 1;
903
- const offset = starts[index] + Math.max(column, 1) - 1;
904
- return Math.min(Math.max(offset, 0), length);
905
- }
906
-
907
1056
  export {
908
1057
  DeadlineError,
909
1058
  isNotFound,
910
1059
  errorMessage,
911
1060
  formatZodError,
1061
+ VERSION,
1062
+ parseVersion,
1063
+ isOlder,
1064
+ pastDeadline,
912
1065
  perRule,
913
1066
  readSourceFile,
1067
+ sourceReader,
914
1068
  fileBytes,
915
1069
  onPath,
916
1070
  CORE_VARIABLES,
@@ -927,11 +1081,8 @@ export {
927
1081
  LLM_PROVIDERS,
928
1082
  defaultDetectorSettings,
929
1083
  emptyDelivery,
930
- VERSION2 as VERSION,
931
- parseVersion,
932
- isOlder,
933
- BACKLOG_HINT,
934
1084
  measureRuleBlock,
1085
+ deliveryCost,
935
1086
  renderAgentText,
936
1087
  tagsOf,
937
1088
  compileFilter,