@syv-ai/rulecast 0.3.0 → 0.5.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
@@ -4,7 +4,7 @@
4
4
 
5
5
  Your project's conventions are written down in `AGENTS.md` or `CLAUDE.md`, and your coding agent read them once, forty tool calls ago. rulecast delivers them again at the moment they matter: when the agent is about to break one.
6
6
 
7
- A rule pairs a check with a message and a pointer to the doc section it enforces. rulecast runs as an agent hook, so when the agent edits a file that breaks a rule, it gets told — in the same turn, before it moves on.
7
+ A rule pairs a check with a message and a pointer to the doc section it enforces. rulecast runs as an agent hook, so when the agent changes a file that breaks a rule — with its edit tools or with Bash — it gets told, in the same turn, before it moves on.
8
8
 
9
9
  ```
10
10
  rulecast: 1 rule violated in app/services/users.py
@@ -20,6 +20,8 @@ Services raise domain exceptions. `app/api/errors.py` maps each one to a status
20
20
 
21
21
  That is real output. The agent sees the finding *and* the paragraph of your documentation that explains it, without having to go looking.
22
22
 
23
+ A file the agent rewrites with `sed -i` or a script is checked like one it changes with `Edit`: rulecast compares the working tree before and after each Bash call. A change no call explains — your own editor, a formatter, a job the agent left running — is reported when the agent stops, and never blocks it.
24
+
23
25
  ## Getting started
24
26
 
25
27
  ```sh
@@ -85,23 +87,102 @@ Some rules are not advice. `refuse_write: true` refuses the edit before it happe
85
87
 
86
88
  A refusal only ever rests on the text the agent is writing, never on a reconstruction rulecast is unsure of, and it happens once per file per session — so a rule can stop a mistake without trapping the agent. Generated code, vendored directories, files that must not change. Everything else is better reported after the write, which is what the other rules do.
87
89
 
90
+ That covers the edit tools. A write through Bash cannot be refused before it runs, because nothing knows what a command will write, so rulecast catches it right after: the agent is told the file is protected and how to revert it, it cannot stop until it has, and you are told which file it changed.
91
+
88
92
  ## Commands
89
93
 
90
94
  | Command | Does |
91
95
  |---|---|
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 |
96
+ | `rulecast init` | Set rulecast up: config, catalog rules, agent hooks |
97
+ | `rulecast install` / `uninstall` | Add (or upgrade) or remove the agent hooks |
98
+ | `rulecast run` | Check staged files (the default), changed files, or everything |
99
+ | `rulecast list` | Show every configured rule: where it comes from, what it matches, what it cites |
95
100
  | `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 |
101
+ | `rulecast validate` | Check the config (and a rules manifest) and print diagnostics |
102
+ | `rulecast doctor` | Compile, check the environment and hooks, dry-run every rule |
98
103
  | `rulecast autoupdate` | Move pinned rule repos to their latest tag |
99
104
  | `rulecast try-repo` | Run a rule repo against your project without configuring it |
100
105
  | `rulecast clean` | Delete the cache |
101
106
  | `rulecast warm` | Build detector caches ahead of time |
102
- | `rulecast hook <adapter>` | Answer an agent hook on stdin |
107
+ | `rulecast hook <adapter>` | Answer an agent hook on stdin (the agent runs this, not you) |
108
+
109
+ `rulecast <command> --help` prints a command's flags; `rulecast --version` prints the version.
110
+
111
+ ## Git hooks and CI
112
+
113
+ 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.
114
+
115
+ - **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.
116
+ - **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.
117
+ - `rulecast run --files F...` judges files whole, with no baseline. Use the hook below rather than `--files` from a hook manager.
118
+
119
+ 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.
120
+
121
+ **pre-commit** (`.pre-commit-config.yaml`):
122
+
123
+ ```yaml
124
+ minimum_pre_commit_version: "3.2.0"
125
+ repos:
126
+ - repo: https://github.com/syv-ai/rulecast
127
+ rev: v0.4.0
128
+ hooks:
129
+ - id: rulecast
130
+ - id: rulecast-push
131
+ ```
132
+
133
+ **lefthook** (`lefthook.yml`):
103
134
 
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.
135
+ ```yaml
136
+ pre-commit:
137
+ jobs:
138
+ - name: rulecast
139
+ run: npx --no-install rulecast run
140
+ pre-push:
141
+ jobs:
142
+ - name: rulecast
143
+ run: npx --no-install rulecast run --from-ref "$(git merge-base origin/HEAD HEAD)" --to-ref HEAD
144
+ ```
145
+
146
+ **GitHub Actions:**
147
+
148
+ ```yaml
149
+ on: pull_request
150
+ jobs:
151
+ rulecast:
152
+ runs-on: ubuntu-latest
153
+ permissions:
154
+ contents: read
155
+ security-events: write
156
+ steps:
157
+ - uses: actions/checkout@v4
158
+ with:
159
+ fetch-depth: 0 # rulecast needs the merge base with the target branch
160
+ - uses: actions/setup-node@v4
161
+ with:
162
+ node-version: 22
163
+ - run: npm ci
164
+ - run: npx rulecast run --from-ref origin/${{ github.base_ref }} --format sarif > rulecast.sarif
165
+ - uses: github/codeql-action/upload-sarif@v3
166
+ if: always() # rulecast exits 1 on findings, which is when the upload matters
167
+ with:
168
+ sarif_file: rulecast.sarif
169
+ ```
170
+
171
+ 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.
172
+
173
+ `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.
174
+
175
+ ## Turning a rule off
176
+
177
+ - **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.
178
+ - **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.
179
+
180
+ ```python
181
+ # rulecast-ignore: python/no-httpexception-in-services legacy endpoint, removed in #412
182
+ raise HTTPException(404)
183
+ ```
184
+
185
+ `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
186
 
106
187
  ## Adopting a rule on a codebase that already breaks it
107
188
 
@@ -143,7 +224,7 @@ rulecast up itself:
143
224
 
144
225
  ```text
145
226
  Set up rulecast in this project. Read
146
- https://raw.githubusercontent.com/syv-ai/rulecast/v0.3.0/agents/SETUP.md
227
+ https://raw.githubusercontent.com/syv-ai/rulecast/v0.5.0/agents/SETUP.md
147
228
  and follow it. Show me every file it creates or changes before I commit anything.
148
229
  ```
149
230
 
@@ -179,11 +260,13 @@ Node ≥ 20.12. Linux and macOS; Windows is not supported.
179
260
 
180
261
  The edit hook is budgeted to finish in under 500 ms at p95 — measured from process start to exit, on a 30-rule project with warm caches, excluding `llm` rules. `pnpm perf` replays 50 edit events and checks it. The last measurement, on an Apple M3 Pro with all six detectors configured, was p50 170 ms and p95 187 ms.
181
262
 
263
+ The hook before every Bash call only records the working tree: it is held to 150 ms over a hook that does nothing, and adds 135–150 ms at p95 on a 20,000-file repository (`microsoft/vscode`), nearly all of it git finding untracked files. The hook after a Bash call that changed a file is an edit event, under the same 500 ms budget.
264
+
182
265
  The budget is what shapes the design: one detector run per kind per event, kinds in parallel, slow tools defaulted to `verify`, content-addressed caches on disk, no daemon. When an edit runs long, rulecast delivers what finished and builds the rest in the background rather than making the agent wait.
183
266
 
184
267
  ## Status
185
268
 
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.
269
+ 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
270
 
188
271
  Next: adapters for Codex, Cursor and OpenCode; Biome; an Azure OpenAI provider.
189
272
 
@@ -1,8 +1,31 @@
1
+ // src/core/version.ts
2
+ var VERSION = "0.5.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,17 +564,42 @@ 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 SWEPT_HEADING = "changed outside your tool calls (fix them if a process you started made them; they do not block):";
572
+ var SWEPT_BLOCK = "\0swept:";
573
+ var PROTECTED = "This file is protected; revert your change.";
574
+ var DIRTY_ADVICE = "It had uncommitted changes before this session: undo only your change, not the whole file.";
575
+ var refusedLines = (refused) => [
576
+ `${refused.file} (${refused.rule}): ${PROTECTED}`,
577
+ ` ${refused.dirtyAtStart ? DIRTY_ADVICE : `git checkout -- ${refused.file}`}`
578
+ ];
579
+ var SHELL_TRIGGER = "changed by your Bash command";
580
+ var backlogLine = (summary) => ` ${summary.rule} \xD7${summary.count} in ${summary.file}`;
581
+ var backlogMoreLine = (count) => ` \u2026and ${count} more`;
582
+ var warningLine = (warning) => ` - ${warning}`;
583
+ var overflowLines = (rules, path7) => [
584
+ `\u2026${plural(rules, "rule")} and the conventions they cite did not fit here.`,
585
+ "Every finding, with the doc sections it cites, is in:",
586
+ ` ${path7}`
587
+ ];
461
588
  var WRAP = 96;
462
589
  var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
590
+ function violatedTitle(rules, file, via) {
591
+ const where = via === "shell" ? file === null ? ` in files ${SHELL_TRIGGER}` : ` in ${file}, ${SHELL_TRIGGER}` : file === null ? "" : ` in ${file}`;
592
+ return `rulecast: ${plural(rules, "rule")} violated${where}`;
593
+ }
463
594
  function header(delivery) {
464
595
  if (delivery.findings.length > 0) {
465
596
  const rules = new Set(delivery.findings.map((finding) => finding.rule)).size + delivery.omitted.rules;
466
597
  const files = new Set(delivery.findings.map((finding) => finding.file));
467
- const where = files.size === 1 && delivery.omitted.rules === 0 ? ` in ${[...files][0]}` : "";
468
- return `rulecast: ${plural(rules, "rule")} violated${where}`;
598
+ const file = files.size === 1 && delivery.omitted.rules === 0 ? [...files][0] : null;
599
+ return violatedTitle(rules, file, delivery.via);
469
600
  }
470
601
  if (delivery.references.length > 0 || delivery.preexistingSummary.length > 0) {
471
- return "rulecast: conventions for the files you are working on";
602
+ return CONVENTIONS_TITLE;
472
603
  }
473
604
  return null;
474
605
  }
@@ -524,8 +655,8 @@ function referenceLines(reference) {
524
655
  }
525
656
  }
526
657
  }
527
- function omittedFor(omitted, rule) {
528
- const entry = omitted.findings.find((finding) => finding.rule === rule);
658
+ function omittedFor(omitted, rule, swept) {
659
+ const entry = omitted.findings.find((finding) => finding.rule === rule && finding.swept === true === swept);
529
660
  return { count: entry?.count ?? 0, files: entry?.files ?? 0 };
530
661
  }
531
662
  function ruleBlock(rule, findings, template, dropped, options) {
@@ -545,44 +676,116 @@ function ruleBlock(rule, findings, template, dropped, options) {
545
676
  }
546
677
  return out;
547
678
  }
548
- function measureRuleBlock(rule, findings, template, options) {
549
- return ruleBlock(rule, findings, template, { count: 0, files: 0 }, options).join("\n").length + 1;
679
+ var linesCost = (lines) => lines.reduce((sum, line) => sum + line.length + 1, 0);
680
+ function measureRuleBlock(rule, shown, all, template, options) {
681
+ const shownFiles = new Set(shown.map((finding) => finding.file));
682
+ const rest = all.slice(shown.length);
683
+ const cut = { count: rest.length, files: new Set(rest.map((f) => f.file).filter((f) => !shownFiles.has(f))).size };
684
+ return linesCost([...ruleBlock(rule, shown, template, cut, options), ""]);
550
685
  }
686
+ var OVERFLOW_PATH_ALLOWANCE = 192;
687
+ var deliveryCost = {
688
+ /**
689
+ * The title and the blank line under it, at its longest — two things about it are not known yet.
690
+ * It names a file when every *delivered* finding is in one, and the budget may cut a rule down to
691
+ * findings in a single file however many files it fired in; so the longest file name is charged.
692
+ * And if the budget drops every rule that fired, no findings are left and the conventions title
693
+ * prints instead; so with `conventions` the longer of the two is charged. A shell edit's title
694
+ * names the file or says "files", so both forms are measured and the longer charged.
695
+ */
696
+ header(rules, files, conventions, via) {
697
+ const longest = [...files].reduce((most, file) => file.length > most.length ? file : most, "");
698
+ const titles = [violatedTitle(rules, longest === "" ? null : longest, via), violatedTitle(rules, null, via)];
699
+ const violated = rules > 0 ? Math.max(...titles.map((title) => linesCost([title, ""]))) : 0;
700
+ return Math.max(violated, conventions ? linesCost([CONVENTIONS_TITLE, ""]) : 0);
701
+ },
702
+ /**
703
+ * One reference's line, at its longest — the "not included, too long" form, naming the file when
704
+ * it has an absolute location. Every other state prints a shorter line, so this bounds them all.
705
+ */
706
+ referenceLine(ref, location) {
707
+ return linesCost(referenceLines({ ref, state: "read", reason: "budget", location }));
708
+ },
709
+ /** What a reference given in full adds over its one-line price: the content and the blank line. */
710
+ referenceContent(ref, content, location) {
711
+ return linesCost(referenceLines({ ref, state: "full", content })) - deliveryCost.referenceLine(ref, location);
712
+ },
713
+ /** What to undo for one protected file changed with the shell, and the blank line after it. */
714
+ refused(refused) {
715
+ return linesCost([...refusedLines(refused), ""]);
716
+ },
717
+ /** The heading over findings in swept files, once. */
718
+ sweptFrame: linesCost([SWEPT_HEADING]),
719
+ /** The blank line that closes the references, once. */
720
+ referencesEnd: 1,
721
+ /** The warnings heading, once. */
722
+ warningsFrame: linesCost([WARNINGS_HEADING]),
723
+ warning(text) {
724
+ return linesCost([warningLine(text)]);
725
+ },
726
+ /**
727
+ * The backlog's heading, its hint, the blank line after, and the "…and N more" line at its
728
+ * longest — printed only when summaries are cut, and charged up front so a cut cannot overflow.
729
+ */
730
+ backlogFrame(summaries) {
731
+ return linesCost([BACKLOG_HEADING, backlogMoreLine(summaries), BACKLOG_HINT, ""]);
732
+ },
733
+ backlogSummary(summary) {
734
+ return linesCost([backlogLine(summary)]);
735
+ },
736
+ /** The lines naming the overflow file, and the blank line before them. */
737
+ overflowNotice(rules) {
738
+ return linesCost(["", ...overflowLines(rules, "x".repeat(OVERFLOW_PATH_ALLOWANCE))]);
739
+ }
740
+ };
551
741
  function renderAgentText(delivery, options) {
552
742
  const title = header(delivery);
553
- if (title === null && delivery.warnings.length === 0) return "";
743
+ const notices = options.notices === false ? [] : delivery.notices ?? [];
744
+ const warnings = options.warnings === false ? [] : delivery.warnings;
745
+ if (title === null && warnings.length === 0 && notices.length === 0) return "";
554
746
  const out = [];
555
747
  if (title !== null) out.push(title, "");
556
- const byRule = /* @__PURE__ */ new Map();
557
- for (const finding of delivery.findings) {
558
- const group = byRule.get(finding.rule);
559
- if (group === void 0) byRule.set(finding.rule, [finding]);
560
- else group.push(finding);
561
- }
562
- for (const [rule, findings] of byRule) {
563
- out.push(...ruleBlock(rule, findings, delivery.templates[rule], omittedFor(delivery.omitted, rule), options));
564
- out.push("");
748
+ const swept = new Set(delivery.swept ?? []);
749
+ const blocks = (findings, isSwept) => {
750
+ const byRule = /* @__PURE__ */ new Map();
751
+ for (const finding of findings) {
752
+ const group = byRule.get(finding.rule);
753
+ if (group === void 0) byRule.set(finding.rule, [finding]);
754
+ else group.push(finding);
755
+ }
756
+ for (const [rule, ruleFindings] of byRule) {
757
+ const dropped = omittedFor(delivery.omitted, rule, isSwept);
758
+ out.push(...ruleBlock(rule, ruleFindings, delivery.templates[rule], dropped, options), "");
759
+ }
760
+ };
761
+ blocks(
762
+ delivery.findings.filter((finding) => !swept.has(finding.file)),
763
+ false
764
+ );
765
+ for (const refused of delivery.refused ?? []) out.push(...refusedLines(refused), "");
766
+ const outside = delivery.findings.filter((finding) => swept.has(finding.file));
767
+ if (outside.length > 0) {
768
+ out.push(SWEPT_HEADING);
769
+ blocks(outside, true);
565
770
  }
566
771
  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`);
772
+ out.push(BACKLOG_HEADING);
773
+ for (const summary of delivery.preexistingSummary) out.push(backlogLine(summary));
774
+ if (delivery.omitted.preexisting > 0) out.push(backlogMoreLine(delivery.omitted.preexisting));
572
775
  out.push(BACKLOG_HINT, "");
573
776
  }
574
777
  for (const reference of delivery.references) out.push(...referenceLines(reference));
575
778
  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}`));
779
+ if (warnings.length > 0) {
780
+ out.push(WARNINGS_HEADING, ...warnings.map(warningLine));
578
781
  }
579
782
  if (delivery.overflowPath !== null) {
580
783
  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
- );
784
+ out.push(...overflowLines(delivery.omitted.rules, delivery.overflowPath));
785
+ }
786
+ if (notices.length > 0) {
787
+ if (out.length > 0 && out.at(-1) !== "") out.push("");
788
+ out.push(NOTICES_HEADING, ...notices.map(warningLine));
586
789
  }
587
790
  while (out.at(-1) === "") out.pop();
588
791
  return out.join("\n");
@@ -759,8 +962,8 @@ function fileOf(spec) {
759
962
  async function compileRule(input, context) {
760
963
  const { data } = input;
761
964
  const minimum = data.minimum_rulecast_version;
762
- if (minimum !== void 0 && isOlder(VERSION2, minimum)) {
763
- return `requires rulecast ${minimum} or newer (running ${VERSION2})`;
965
+ if (minimum !== void 0 && isOlder(VERSION, minimum)) {
966
+ return `requires rulecast ${minimum} or newer (running ${VERSION})`;
764
967
  }
765
968
  let detector = null;
766
969
  let defaultStages = ["touch"];
@@ -769,7 +972,8 @@ async function compileRule(input, context) {
769
972
  const implementation = context.registry.get(kind);
770
973
  if (!implementation) return `unknown detector "${kind}"`;
771
974
  const parsed = await implementation.schema.safeParseAsync(rawConfig ?? {});
772
- if (!parsed.success) return `detect.${kind}: ${formatZodError(parsed.error)}`;
975
+ if (!parsed.success)
976
+ return formatZodError(parsed.error, { prefix: `detect.${kind}`, schema: implementation.schema });
773
977
  detector = { kind, config: parsed.data, captures: implementation.captures(parsed.data) };
774
978
  defaultStages = implementation.events(parsed.data);
775
979
  }
@@ -832,7 +1036,9 @@ async function compileRule(input, context) {
832
1036
  severity: data.severity ?? "error",
833
1037
  scope: data.scope ?? "instance",
834
1038
  refuseWrite: data.refuse_write === true,
1039
+ enabled: data.enabled !== false,
835
1040
  stages,
1041
+ patterns: { files: data.files ?? "", exclude: data.exclude ?? "^$" },
836
1042
  matches: (file) => global(file) && filter(file),
837
1043
  detector,
838
1044
  message: data.message ?? null,
@@ -880,37 +1086,18 @@ function detectorCacheDir(stateDir, kind) {
880
1086
  return path6.join(stateDir, "cache", kind);
881
1087
  }
882
1088
 
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
1089
  export {
908
1090
  DeadlineError,
909
1091
  isNotFound,
910
1092
  errorMessage,
911
1093
  formatZodError,
1094
+ VERSION,
1095
+ parseVersion,
1096
+ isOlder,
1097
+ pastDeadline,
912
1098
  perRule,
913
1099
  readSourceFile,
1100
+ sourceReader,
914
1101
  fileBytes,
915
1102
  onPath,
916
1103
  CORE_VARIABLES,
@@ -927,11 +1114,9 @@ export {
927
1114
  LLM_PROVIDERS,
928
1115
  defaultDetectorSettings,
929
1116
  emptyDelivery,
930
- VERSION2 as VERSION,
931
- parseVersion,
932
- isOlder,
933
- BACKLOG_HINT,
1117
+ SWEPT_BLOCK,
934
1118
  measureRuleBlock,
1119
+ deliveryCost,
935
1120
  renderAgentText,
936
1121
  tagsOf,
937
1122
  compileFilter,