@syv-ai/rulecast 0.2.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,24 +89,148 @@ 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 |
95
- | `rulecast validate` | Check the config and print diagnostics |
96
- | `rulecast doctor` | Compile, check the environment, dry-run every rule |
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 |
96
+ | `rulecast test` | Run each rule's good/bad examples; `--against` says how much it would flag |
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 |
97
99
  | `rulecast autoupdate` | Move pinned rule repos to their latest tag |
98
100
  | `rulecast try-repo` | Run a rule repo against your project without configuring it |
99
101
  | `rulecast clean` | Delete the cache |
100
102
  | `rulecast warm` | Build detector caches ahead of time |
101
- | `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) |
102
104
 
103
- `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.
182
+
183
+ ## Adopting a rule on a codebase that already breaks it
184
+
185
+ `rulecast run --all-files --summary` tells you how much you already owe:
186
+
187
+ ```text
188
+ backlog: 57 violations in 31 files
189
+
190
+ rule violations files
191
+ python/routes-never-call-crud 57 31
192
+
193
+ worst files violations
194
+ app/api/routes/orders.py 12
195
+ app/api/routes/users.py 9
196
+ ```
197
+
198
+ Watch the count, not the rate. Here is one real codebase on one rule, on `main`:
199
+
200
+ | date | violating / total |
201
+ |---|---|
202
+ | 2026-01-15 | 61 / 80 |
203
+ | 2026-06-01 | 58 / 143 |
204
+ | 2026-09-25 | 57 / 194 |
205
+
206
+ The rate fell from 76% to 29% and the count did not move. New code complied; the old violations were
207
+ never fixed, only diluted. A team watching the percentage would have believed it was winning.
208
+
209
+ Per-edit enforcement does not touch that stock — it only stops it growing. Fix it a file at a time:
210
+ edits to files that already followed the rule broke it 2% of the time, against 37% in files that
211
+ mostly did not, so a file you clean tends to stay clean. (One rule across 19 files: suggestive, not
212
+ proven.)
104
213
 
105
214
  If something is configured but quiet, `rulecast doctor` says why: it compiles the config, reports whether each rule's linter, parser, script or API key is actually there, says where the hooks and caches live, and runs every rule against one file it matches.
106
215
 
107
216
  ## Agents
108
217
 
109
- rulecast ships docs written for coding agents, not for people:
218
+ rulecast ships docs written for coding agents, not for people. Paste this to yours and it will set
219
+ rulecast up itself:
220
+
221
+ ```text
222
+ Set up rulecast in this project. Read
223
+ https://raw.githubusercontent.com/syv-ai/rulecast/v0.4.0/agents/SETUP.md
224
+ and follow it. Show me every file it creates or changes before I commit anything.
225
+ ```
226
+
227
+ If your agent cannot fetch a URL, this is the whole of it: run
228
+ `npx @syv-ai/rulecast init --yes --agent <your agent, e.g. claude-code>`, then show me
229
+ `.rulecast-config.yaml` and the other files it listed. Never edit hook settings by hand —
230
+ `rulecast install` and `rulecast uninstall` own them.
231
+
232
+ Once it is set up, ask the same agent to draft rules for your own conventions; `init` prints the
233
+ prompt to paste.
110
234
 
111
235
  - [`agents/SETUP.md`](https://github.com/syv-ai/rulecast/blob/main/agents/SETUP.md) — give this to your agent and it will set rulecast up itself.
112
236
  - [`agents/DRAFT-RULES.md`](https://github.com/syv-ai/rulecast/blob/main/agents/DRAFT-RULES.md) — it reads your `AGENTS.md`, proposes one rule per convention that code can visibly break, shows you what each would flag today, and asks you to keep, edit or drop it.
@@ -136,9 +260,9 @@ The budget is what shapes the design: one detector run per kind per event, kinds
136
260
 
137
261
  ## Status
138
262
 
139
- 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.
140
264
 
141
- Next: adapters for Codex, Cursor and OpenCode; Biome; rule tests (good and bad examples run by `rulecast test`); an Azure OpenAI provider.
265
+ Next: adapters for Codex, Cursor and OpenCode; Biome; an Azure OpenAI provider.
142
266
 
143
267
  ## License
144
268
 
@@ -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.2.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"];
@@ -457,6 +563,19 @@ function templateBindings(template) {
457
563
  var GROUP_FROM = 3;
458
564
  var GROUP_MIN_LITERAL = 40;
459
565
  var MAX_LOCATION_PAD = 40;
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
+ ];
460
579
  var WRAP = 96;
461
580
  var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
462
581
  function header(delivery) {
@@ -467,7 +586,7 @@ function header(delivery) {
467
586
  return `rulecast: ${plural(rules, "rule")} violated${where}`;
468
587
  }
469
588
  if (delivery.references.length > 0 || delivery.preexistingSummary.length > 0) {
470
- return "rulecast: conventions for the files you are working on";
589
+ return CONVENTIONS_TITLE;
471
590
  }
472
591
  return null;
473
592
  }
@@ -544,12 +663,66 @@ function ruleBlock(rule, findings, template, dropped, options) {
544
663
  }
545
664
  return out;
546
665
  }
547
- function measureRuleBlock(rule, findings, template, options) {
548
- 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), ""]);
549
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
+ };
550
721
  function renderAgentText(delivery, options) {
551
722
  const title = header(delivery);
552
- 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 "";
553
726
  const out = [];
554
727
  if (title !== null) out.push(title, "");
555
728
  const byRule = /* @__PURE__ */ new Map();
@@ -562,25 +735,24 @@ function renderAgentText(delivery, options) {
562
735
  out.push(...ruleBlock(rule, findings, delivery.templates[rule], omittedFor(delivery.omitted, rule), options));
563
736
  out.push("");
564
737
  }
565
- for (const summary of delivery.preexistingSummary) {
566
- out.push(`pre-existing (not blocking): ${summary.rule} \xD7${summary.count} in ${summary.file}`);
738
+ if (delivery.preexistingSummary.length > 0 || delivery.omitted.preexisting > 0) {
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));
742
+ out.push(BACKLOG_HINT, "");
567
743
  }
568
- if (delivery.omitted.preexisting > 0) {
569
- out.push(`\u2026and ${delivery.omitted.preexisting} more pre-existing (not blocking)`);
570
- }
571
- if (delivery.preexistingSummary.length > 0 || delivery.omitted.preexisting > 0) out.push("");
572
744
  for (const reference of delivery.references) out.push(...referenceLines(reference));
573
745
  if (out.length > 0 && out.at(-1) !== "") out.push("");
574
- if (delivery.warnings.length > 0) {
575
- out.push("rulecast warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`));
746
+ if (warnings.length > 0) {
747
+ out.push(WARNINGS_HEADING, ...warnings.map(warningLine));
576
748
  }
577
749
  if (delivery.overflowPath !== null) {
578
750
  if (out.at(-1) !== "") out.push("");
579
- out.push(
580
- `\u2026${plural(delivery.omitted.rules, "rule")} and the conventions they cite did not fit here.`,
581
- "Every finding, with the doc sections it cites, is in:",
582
- ` ${delivery.overflowPath}`
583
- );
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));
584
756
  }
585
757
  while (out.at(-1) === "") out.pop();
586
758
  return out.join("\n");
@@ -757,8 +929,8 @@ function fileOf(spec) {
757
929
  async function compileRule(input, context) {
758
930
  const { data } = input;
759
931
  const minimum = data.minimum_rulecast_version;
760
- if (minimum !== void 0 && isOlder(VERSION2, minimum)) {
761
- 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})`;
762
934
  }
763
935
  let detector = null;
764
936
  let defaultStages = ["touch"];
@@ -767,7 +939,8 @@ async function compileRule(input, context) {
767
939
  const implementation = context.registry.get(kind);
768
940
  if (!implementation) return `unknown detector "${kind}"`;
769
941
  const parsed = await implementation.schema.safeParseAsync(rawConfig ?? {});
770
- if (!parsed.success) return `detect.${kind}: ${formatZodError(parsed.error)}`;
942
+ if (!parsed.success)
943
+ return formatZodError(parsed.error, { prefix: `detect.${kind}`, schema: implementation.schema });
771
944
  detector = { kind, config: parsed.data, captures: implementation.captures(parsed.data) };
772
945
  defaultStages = implementation.events(parsed.data);
773
946
  }
@@ -788,6 +961,8 @@ async function compileRule(input, context) {
788
961
  if (unknown) return `unknown template variable "${unknown}"`;
789
962
  } else {
790
963
  if (data.refuse_write) return "refuse_write needs detect: there is nothing to refuse a write for";
964
+ if (data.scope) return "scope needs detect: there is nothing to classify";
965
+ if (data.examples) return "examples need detect: there is nothing to run them against";
791
966
  if (stages.some((stage) => stage !== "touch")) return "rules without detect need stages: [touch]";
792
967
  if (data.message !== void 0) return "message needs detect";
793
968
  if (!data.context?.length) return "rules without detect need context";
@@ -800,6 +975,9 @@ async function compileRule(input, context) {
800
975
  excludeTypes: data.exclude_types ?? []
801
976
  });
802
977
  if (typeof filter === "string") return filter;
978
+ for (const example of [...data.examples?.good ?? [], ...data.examples?.bad ?? []]) {
979
+ if (!filter(example.path)) return `example path "${example.path}" does not match this rule's files`;
980
+ }
803
981
  const references = [];
804
982
  for (const reference of data.context ?? []) {
805
983
  let spec;
@@ -823,12 +1001,16 @@ async function compileRule(input, context) {
823
1001
  description: data.description ?? null,
824
1002
  source: input.source,
825
1003
  severity: data.severity ?? "error",
1004
+ scope: data.scope ?? "instance",
826
1005
  refuseWrite: data.refuse_write === true,
1006
+ enabled: data.enabled !== false,
827
1007
  stages,
1008
+ patterns: { files: data.files ?? "", exclude: data.exclude ?? "^$" },
828
1009
  matches: (file) => global(file) && filter(file),
829
1010
  detector,
830
1011
  message: data.message ?? null,
831
- context: references
1012
+ context: references,
1013
+ examples: data.examples ?? null
832
1014
  };
833
1015
  }
834
1016
 
@@ -871,37 +1053,18 @@ function detectorCacheDir(stateDir, kind) {
871
1053
  return path6.join(stateDir, "cache", kind);
872
1054
  }
873
1055
 
874
- // src/core/detection/positions.ts
875
- function lineStarts(text) {
876
- const starts = [0];
877
- for (let i = 0; i < text.length; i++) {
878
- if (text.charCodeAt(i) === 10) starts.push(i + 1);
879
- }
880
- return starts;
881
- }
882
- function positionAt(starts, offset) {
883
- let low = 0;
884
- let high = starts.length - 1;
885
- while (low < high) {
886
- const mid = low + high + 1 >> 1;
887
- if (starts[mid] <= offset) low = mid;
888
- else high = mid - 1;
889
- }
890
- return { line: low + 1, column: offset - starts[low] + 1 };
891
- }
892
- function offsetAt(starts, line, column, length) {
893
- const index = Math.min(Math.max(line, 1), starts.length) - 1;
894
- const offset = starts[index] + Math.max(column, 1) - 1;
895
- return Math.min(Math.max(offset, 0), length);
896
- }
897
-
898
1056
  export {
899
1057
  DeadlineError,
900
1058
  isNotFound,
901
1059
  errorMessage,
902
1060
  formatZodError,
1061
+ VERSION,
1062
+ parseVersion,
1063
+ isOlder,
1064
+ pastDeadline,
903
1065
  perRule,
904
1066
  readSourceFile,
1067
+ sourceReader,
905
1068
  fileBytes,
906
1069
  onPath,
907
1070
  CORE_VARIABLES,
@@ -918,10 +1081,8 @@ export {
918
1081
  LLM_PROVIDERS,
919
1082
  defaultDetectorSettings,
920
1083
  emptyDelivery,
921
- VERSION2 as VERSION,
922
- parseVersion,
923
- isOlder,
924
1084
  measureRuleBlock,
1085
+ deliveryCost,
925
1086
  renderAgentText,
926
1087
  tagsOf,
927
1088
  compileFilter,