@syv-ai/rulecast 0.2.0 → 0.3.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
@@ -92,6 +92,7 @@ A refusal only ever rests on the text the agent is writing, never on a reconstru
92
92
  | `rulecast init` | Interactive setup |
93
93
  | `rulecast install` / `uninstall` | Add or remove the agent hooks |
94
94
  | `rulecast run` | Check staged files, changed files, or everything |
95
+ | `rulecast test` | Run each rule's good/bad examples; `--against` says how much it would flag |
95
96
  | `rulecast validate` | Check the config and print diagnostics |
96
97
  | `rulecast doctor` | Compile, check the environment, dry-run every rule |
97
98
  | `rulecast autoupdate` | Move pinned rule repos to their latest tag |
@@ -102,11 +103,57 @@ A refusal only ever rests on the text the agent is writing, never on a reconstru
102
103
 
103
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.
104
105
 
106
+ ## Adopting a rule on a codebase that already breaks it
107
+
108
+ `rulecast run --all-files --summary` tells you how much you already owe:
109
+
110
+ ```text
111
+ backlog: 57 violations in 31 files
112
+
113
+ rule violations files
114
+ python/routes-never-call-crud 57 31
115
+
116
+ worst files violations
117
+ app/api/routes/orders.py 12
118
+ app/api/routes/users.py 9
119
+ ```
120
+
121
+ Watch the count, not the rate. Here is one real codebase on one rule, on `main`:
122
+
123
+ | date | violating / total |
124
+ |---|---|
125
+ | 2026-01-15 | 61 / 80 |
126
+ | 2026-06-01 | 58 / 143 |
127
+ | 2026-09-25 | 57 / 194 |
128
+
129
+ The rate fell from 76% to 29% and the count did not move. New code complied; the old violations were
130
+ never fixed, only diluted. A team watching the percentage would have believed it was winning.
131
+
132
+ Per-edit enforcement does not touch that stock — it only stops it growing. Fix it a file at a time:
133
+ edits to files that already followed the rule broke it 2% of the time, against 37% in files that
134
+ mostly did not, so a file you clean tends to stay clean. (One rule across 19 files: suggestive, not
135
+ proven.)
136
+
105
137
  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
138
 
107
139
  ## Agents
108
140
 
109
- rulecast ships docs written for coding agents, not for people:
141
+ rulecast ships docs written for coding agents, not for people. Paste this to yours and it will set
142
+ rulecast up itself:
143
+
144
+ ```text
145
+ Set up rulecast in this project. Read
146
+ https://raw.githubusercontent.com/syv-ai/rulecast/v0.3.0/agents/SETUP.md
147
+ and follow it. Show me every file it creates or changes before I commit anything.
148
+ ```
149
+
150
+ If your agent cannot fetch a URL, this is the whole of it: run
151
+ `npx @syv-ai/rulecast init --yes --agent <your agent, e.g. claude-code>`, then show me
152
+ `.rulecast-config.yaml` and the other files it listed. Never edit hook settings by hand —
153
+ `rulecast install` and `rulecast uninstall` own them.
154
+
155
+ Once it is set up, ask the same agent to draft rules for your own conventions; `init` prints the
156
+ prompt to paste.
110
157
 
111
158
  - [`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
159
  - [`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.
@@ -138,7 +185,7 @@ The budget is what shapes the design: one detector run per kind per event, kinds
138
185
 
139
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.
140
187
 
141
- Next: adapters for Codex, Cursor and OpenCode; Biome; rule tests (good and bad examples run by `rulecast test`); an Azure OpenAI provider.
188
+ Next: adapters for Codex, Cursor and OpenCode; Biome; an Azure OpenAI provider.
142
189
 
143
190
  ## License
144
191
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ BACKLOG_HINT,
2
3
  DeadlineError,
3
4
  LLM_PROVIDERS,
4
5
  VERSION,
@@ -23,7 +24,7 @@ import {
23
24
  sectionRange,
24
25
  sectionText,
25
26
  templateBindings
26
- } from "./chunk-ZEFEQKG5.js";
27
+ } from "./chunk-HZUATJ53.js";
27
28
 
28
29
  // src/core/config/load.ts
29
30
  import { readFile } from "fs/promises";
@@ -34,9 +35,11 @@ import { parse } from "yaml";
34
35
  import { z } from "zod";
35
36
  var STAGES = ["touch", "edit", "verify"];
36
37
  var RULE_ID = /^[a-z0-9-]+(\/[a-z0-9-]+)*$/;
38
+ var RULE_SCOPES = ["instance", "container"];
37
39
  var idSchema = z.string().regex(RULE_ID, "must be lowercase segments separated by /");
38
40
  var versionSchema = z.string().regex(/^\d+\.\d+\.\d+$/, "must be a version like 0.2.0");
39
41
  var stagesSchema = z.array(z.enum(STAGES)).nonempty();
42
+ var exampleSchema = z.object({ path: z.string().min(1), code: z.string() }).strict();
40
43
  var ruleKeys = {
41
44
  alias: idSchema.optional(),
42
45
  name: z.string().min(1).optional(),
@@ -49,10 +52,18 @@ var ruleKeys = {
49
52
  stages: stagesSchema.optional(),
50
53
  minimum_rulecast_version: versionSchema.optional(),
51
54
  severity: z.enum(["error", "warning"]).optional(),
55
+ /**
56
+ * What the convention belongs to (§8). `instance`: the token the detector matched, classified by
57
+ * whether the agent's edit touched it. `container`: the node the match spans, classified by
58
+ * whether that node already violated the rule at the baseline.
59
+ */
60
+ scope: z.enum(RULE_SCOPES).optional(),
52
61
  refuse_write: z.boolean().optional(),
53
62
  detect: z.record(z.unknown()).refine((value) => Object.keys(value).length === 1, "must name exactly one detector").optional(),
54
63
  message: z.string().optional(),
55
- context: z.array(referenceInputSchema).optional()
64
+ context: z.array(referenceInputSchema).optional(),
65
+ /** Code the rule must flag (`bad`) and must not (`good`), run by `rulecast test`. */
66
+ examples: z.object({ good: z.array(exampleSchema).default([]), bad: z.array(exampleSchema).default([]) }).strict().optional()
56
67
  };
57
68
  var overrideSchema = z.object({ id: idSchema, ...ruleKeys }).strict();
58
69
  var ruleSchema = overrideSchema.extend({ name: z.string().min(1) });
@@ -655,14 +666,16 @@ import { createHash as createHash3 } from "crypto";
655
666
 
656
667
  // src/core/baseline/changes.ts
657
668
  import { diffArrays } from "diff";
658
- function changedLines(before, after) {
659
- const ranges = [];
669
+ function diffLines(before, after) {
670
+ const changed = [];
671
+ const runs = [];
660
672
  const mark = (start, end) => {
661
- const previous = ranges.at(-1);
673
+ const previous = changed.at(-1);
662
674
  if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
663
- else ranges.push([start, end]);
675
+ else changed.push([start, end]);
664
676
  };
665
677
  let line = 1;
678
+ let baselineLine = 1;
666
679
  for (const part of diffArrays(Array.from(before), Array.from(after))) {
667
680
  const count = part.count ?? part.value.length;
668
681
  if (part.added) {
@@ -673,11 +686,17 @@ function changedLines(before, after) {
673
686
  const at = Math.min(line, after.length);
674
687
  mark(at, at);
675
688
  }
689
+ baselineLine += count;
676
690
  } else {
691
+ runs.push({ beforeStart: baselineLine, afterStart: line, count });
677
692
  line += count;
693
+ baselineLine += count;
678
694
  }
679
695
  }
680
- return ranges;
696
+ return { changed, map: { runs, afterLines: after.length } };
697
+ }
698
+ function identityMap(lines) {
699
+ return { runs: lines === 0 ? [] : [{ beforeStart: 1, afterStart: 1, count: lines }], afterLines: lines };
681
700
  }
682
701
 
683
702
  // src/core/baseline/hash.ts
@@ -716,21 +735,28 @@ function snapshotOf(text) {
716
735
 
717
736
  // src/core/baseline/baseline.ts
718
737
  async function computeChanges(root, files, sources) {
719
- const changes = /* @__PURE__ */ new Map();
738
+ const changes = { sets: /* @__PURE__ */ new Map(), maps: /* @__PURE__ */ new Map(), fromCommit: /* @__PURE__ */ new Map() };
720
739
  for (const file of files) {
721
740
  if (!sources.snapshots.has(file) && !sources.fallbackCommit) continue;
722
741
  let before = sources.snapshots.get(file) ?? null;
742
+ let commitText = null;
723
743
  if (!before && sources.fallbackCommit) {
724
- const text = await fileAtCommit(root, sources.fallbackCommit, file);
725
- before = text === null ? null : snapshotOf(text);
744
+ commitText = await fileAtCommit(root, sources.fallbackCommit, file);
745
+ before = commitText === null ? null : snapshotOf(commitText);
726
746
  }
727
747
  if (!before) continue;
728
748
  const current = await readSourceFile(root, file);
729
749
  if (current === null) continue;
750
+ if (commitText !== null) changes.fromCommit.set(file, commitText);
730
751
  const after = snapshotOf(current);
731
- changes.set(file, {
732
- changedLines: after.fileHash === before.fileHash ? [] : changedLines(before.lines, after.lines)
733
- });
752
+ if (after.fileHash === before.fileHash) {
753
+ changes.sets.set(file, { changedLines: [] });
754
+ changes.maps.set(file, identityMap(after.lines.length));
755
+ continue;
756
+ }
757
+ const diff = diffLines(before.lines, after.lines);
758
+ changes.sets.set(file, { changedLines: diff.changed });
759
+ changes.maps.set(file, diff.map);
734
760
  }
735
761
  return changes;
736
762
  }
@@ -740,6 +766,151 @@ function isNew(match, changes) {
740
766
  return change.changedLines.some(([start, end]) => match.line <= end && match.endLine >= start);
741
767
  }
742
768
 
769
+ // src/core/baseline/fingerprint.ts
770
+ function mapRange(map, [start, end]) {
771
+ let first = null;
772
+ let last = 0;
773
+ for (const run2 of map.runs) {
774
+ const from = Math.max(start, run2.beforeStart);
775
+ const to = Math.min(end, run2.beforeStart + run2.count - 1);
776
+ if (from > to) continue;
777
+ if (first === null) first = run2.afterStart + (from - run2.beforeStart);
778
+ last = run2.afterStart + (to - run2.beforeStart);
779
+ }
780
+ return first === null ? null : [first, last];
781
+ }
782
+ function classify(match, scope, rule, changes, fingerprints) {
783
+ const instance = () => isNew(match, changes.sets) ? "new" : "preexisting";
784
+ if (scope === "instance") return instance();
785
+ const recorded = fingerprints.get(match.file)?.get(rule);
786
+ const map = changes.maps.get(match.file);
787
+ if (recorded === void 0 || map === void 0) return instance();
788
+ for (const range of recorded) {
789
+ const mapped = mapRange(map, range);
790
+ if (mapped !== null && match.line <= mapped[1] && match.endLine >= mapped[0]) return "preexisting";
791
+ }
792
+ return "new";
793
+ }
794
+
795
+ // src/core/detection/budget.ts
796
+ function applyFileBudget(kind, selections, maxFiles, recent) {
797
+ const ofKind = selections.filter((selection) => selection.rule.detector?.kind === kind);
798
+ const files = [...new Set(ofKind.flatMap((selection) => selection.files))];
799
+ if (files.length <= maxFiles) return { selections, skipped: [] };
800
+ const rank = new Map(recent.map((file, index) => [file, index]));
801
+ const ordered = [...files].sort((a, b) => (rank.get(a) ?? recent.length) - (rank.get(b) ?? recent.length));
802
+ const keep = new Set(ordered.slice(0, maxFiles));
803
+ return {
804
+ selections: selections.map(
805
+ (selection) => selection.rule.detector?.kind === kind ? { ...selection, files: selection.files.filter((file) => keep.has(file)) } : selection
806
+ ).filter((selection) => selection.files.length > 0),
807
+ skipped: ordered.slice(maxFiles)
808
+ };
809
+ }
810
+ async function applySizeCeiling(selections, maxBytes, bounded, sizeOf) {
811
+ const files = [...new Set(selections.filter((one) => bounded(one.rule.detector.kind)).flatMap((one) => one.files))];
812
+ if (files.length === 0) return { selections, skipped: [] };
813
+ const sizes = await Promise.all(files.map(async (file) => [file, await sizeOf(file)]));
814
+ const over = new Set(sizes.filter(([, size]) => size !== null && size > maxBytes).map(([file]) => file));
815
+ if (over.size === 0) return { selections, skipped: [] };
816
+ return {
817
+ selections: selections.map(
818
+ (selection) => bounded(selection.rule.detector.kind) ? { ...selection, files: selection.files.filter((file) => !over.has(file)) } : selection
819
+ ).filter((selection) => selection.files.length > 0),
820
+ skipped: files.filter((file) => over.has(file))
821
+ };
822
+ }
823
+
824
+ // src/core/detection/run.ts
825
+ var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
826
+ async function runDetection(input) {
827
+ const output = { findings: [], errors: [], timedOut: [] };
828
+ const byKind = /* @__PURE__ */ new Map();
829
+ for (const selection of input.selections) {
830
+ const kind = selection.rule.detector.kind;
831
+ const group = byKind.get(kind);
832
+ if (group === void 0) byKind.set(kind, [selection]);
833
+ else group.push(selection);
834
+ }
835
+ const controller = new AbortController();
836
+ const deadline = new Promise((resolve) => {
837
+ controller.signal.addEventListener("abort", () => resolve(TIMED_OUT), { once: true });
838
+ });
839
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs);
840
+ const deadlineAt = Date.now() + input.timeoutMs;
841
+ const late = () => Date.now() > deadlineAt;
842
+ const perKind = await Promise.all(
843
+ [...byKind].map(async ([kind, selections]) => {
844
+ const detector = input.registry.get(kind);
845
+ if (!detector) return { status: "error", kind, selections, message: `detector "${kind}" is not registered` };
846
+ try {
847
+ const rules = await Promise.all(
848
+ selections.map(async ({ rule, files }) => ({
849
+ id: rule.id,
850
+ config: rule.detector.config,
851
+ files,
852
+ context: await input.contextFor(rule)
853
+ }))
854
+ );
855
+ const result = await Promise.race([
856
+ detector.run({
857
+ event: input.event,
858
+ rules,
859
+ read: input.read,
860
+ changes: input.changes,
861
+ cache: input.cacheFor(kind),
862
+ settings: input.settings,
863
+ cwd: input.root,
864
+ signal: controller.signal,
865
+ deadlineAt
866
+ }),
867
+ deadline
868
+ ]);
869
+ if (result === TIMED_OUT || late()) return { status: "timedOut", kind, selections };
870
+ return { status: "ok", kind, selections, result };
871
+ } catch (error) {
872
+ if (error instanceof DeadlineError || controller.signal.aborted || late()) {
873
+ return { status: "timedOut", kind, selections };
874
+ }
875
+ return { status: "error", kind, selections, message: errorMessage(error) };
876
+ }
877
+ })
878
+ );
879
+ clearTimeout(timer);
880
+ for (const outcome of perKind) {
881
+ const ids = outcome.selections.map((selection) => selection.rule.id);
882
+ if (outcome.status === "timedOut") {
883
+ output.timedOut.push({ kind: outcome.kind, rules: ids });
884
+ continue;
885
+ }
886
+ if (outcome.status === "error") {
887
+ output.errors.push({ kind: outcome.kind, rules: ids, message: outcome.message });
888
+ continue;
889
+ }
890
+ const result = outcome.result;
891
+ const rulesById = new Map(outcome.selections.map((selection) => [selection.rule.id, selection.rule]));
892
+ const stray = result.findings.find((finding) => !rulesById.has(finding.rule));
893
+ const wholeRun = result.errors.find((error) => error.rule === null);
894
+ if (stray || wholeRun) {
895
+ const message = stray ? `detector reported unknown rule "${stray.rule}"` : wholeRun.message;
896
+ output.errors.push({ kind: outcome.kind, rules: ids, message });
897
+ continue;
898
+ }
899
+ const failed = /* @__PURE__ */ new Map();
900
+ for (const error of result.errors) failed.set(error.rule, error.message);
901
+ for (const finding of result.findings) {
902
+ const rule = rulesById.get(finding.rule);
903
+ const missing = rule.detector.captures.find((name) => typeof finding.match.captures[name] !== "string");
904
+ if (missing && !failed.has(rule.id)) failed.set(rule.id, `match is missing declared capture "${missing}"`);
905
+ }
906
+ for (const [rule, message] of failed) output.errors.push({ kind: outcome.kind, rules: [rule], message });
907
+ for (const finding of result.findings) {
908
+ if (!failed.has(finding.rule)) output.findings.push({ rule: rulesById.get(finding.rule), match: finding.match });
909
+ }
910
+ }
911
+ return output;
912
+ }
913
+
743
914
  // src/core/baseline/store.ts
744
915
  import path7 from "path";
745
916
 
@@ -819,22 +990,74 @@ function startRecord(commit) {
819
990
  function snapshotRecord(file, snapshot) {
820
991
  return { t: "snapshot", file, fileHash: snapshot.fileHash, lines: encodeLines(snapshot.lines) };
821
992
  }
993
+ function fingerprintRecord(file, rule, ranges) {
994
+ return { t: "fingerprint", file, rule, ranges };
995
+ }
822
996
  async function appendBaseline(sessionDir2, records) {
823
997
  await appendRecords(storeFile(sessionDir2), records);
824
998
  }
825
999
  async function readBaseline(sessionDir2) {
826
- const state = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map() };
1000
+ const state = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map(), fingerprints: /* @__PURE__ */ new Map() };
827
1001
  for (const record of await readRecords(storeFile(sessionDir2))) {
828
1002
  if (record.t === "start" && !state.started) {
829
1003
  state.started = true;
830
1004
  state.startCommit = record.commit;
831
1005
  } else if (record.t === "snapshot" && !state.snapshots.has(record.file)) {
832
1006
  state.snapshots.set(record.file, { fileHash: record.fileHash, lines: decodeLines(record.lines) });
1007
+ } else if (record.t === "fingerprint") {
1008
+ const byRule = state.fingerprints.get(record.file) ?? /* @__PURE__ */ new Map();
1009
+ state.fingerprints.set(record.file, byRule);
1010
+ if (!byRule.has(record.rule)) byRule.set(record.rule, record.ranges);
833
1011
  }
834
1012
  }
835
1013
  return state;
836
1014
  }
837
1015
 
1016
+ // src/core/baseline/record.ts
1017
+ async function recordFingerprints(input) {
1018
+ if (!input.rules.some((rule) => rule.scope === "container")) return [];
1019
+ const guards = (kind) => input.registry.get(kind)?.guards === true;
1020
+ let selections = selectDetectorRules(input.rules, input.event, input.files, input.disabled).filter(
1021
+ (selection) => selection.rule.scope === "container" && selection.rule.detector.kind !== "llm" && (input.source === "disk" || guards(selection.rule.detector.kind))
1022
+ );
1023
+ if (input.ceiling) {
1024
+ const applied = await applySizeCeiling(selections, input.ceiling.maxFileBytes, guards, input.ceiling.sizeOf);
1025
+ selections = applied.selections;
1026
+ }
1027
+ if (selections.length === 0) return [];
1028
+ const output = await runDetection({
1029
+ root: input.root,
1030
+ event: input.event,
1031
+ selections,
1032
+ // No change set: the baseline content is being measured whole, and there is nothing it is a
1033
+ // change from. A detector that reads `changes` treats an absent file as entirely new, which is
1034
+ // exactly right here.
1035
+ changes: /* @__PURE__ */ new Map(),
1036
+ read: input.read,
1037
+ registry: input.registry,
1038
+ cacheFor: input.cacheFor,
1039
+ contextFor: input.contextFor,
1040
+ settings: input.settings,
1041
+ timeoutMs: input.timeoutMs
1042
+ });
1043
+ const unmeasured = /* @__PURE__ */ new Set();
1044
+ for (const error of output.errors) for (const rule of error.rules) unmeasured.add(rule);
1045
+ for (const timeout of output.timedOut) for (const rule of timeout.rules) unmeasured.add(rule);
1046
+ const key = (file, rule) => `${file}\0${rule}`;
1047
+ const ranges = /* @__PURE__ */ new Map();
1048
+ for (const { rule, match } of output.findings) {
1049
+ const found = ranges.get(key(match.file, rule.id));
1050
+ if (found === void 0) ranges.set(key(match.file, rule.id), [[match.line, match.endLine]]);
1051
+ else found.push([match.line, match.endLine]);
1052
+ }
1053
+ const records = [];
1054
+ for (const { rule, files } of selections) {
1055
+ if (unmeasured.has(rule.id)) continue;
1056
+ for (const file of files) records.push(fingerprintRecord(file, rule.id, ranges.get(key(file, rule.id)) ?? []));
1057
+ }
1058
+ return records;
1059
+ }
1060
+
838
1061
  // src/core/delivery/persist.ts
839
1062
  import { mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
840
1063
  import path8 from "path";
@@ -913,125 +1136,6 @@ function createReferenceResolver(root) {
913
1136
  };
914
1137
  }
915
1138
 
916
- // src/core/detection/budget.ts
917
- function applyFileBudget(kind, selections, maxFiles, recent) {
918
- const ofKind = selections.filter((selection) => selection.rule.detector?.kind === kind);
919
- const files = [...new Set(ofKind.flatMap((selection) => selection.files))];
920
- if (files.length <= maxFiles) return { selections, skipped: [] };
921
- const rank = new Map(recent.map((file, index) => [file, index]));
922
- const ordered = [...files].sort((a, b) => (rank.get(a) ?? recent.length) - (rank.get(b) ?? recent.length));
923
- const keep = new Set(ordered.slice(0, maxFiles));
924
- return {
925
- selections: selections.map(
926
- (selection) => selection.rule.detector?.kind === kind ? { ...selection, files: selection.files.filter((file) => keep.has(file)) } : selection
927
- ).filter((selection) => selection.files.length > 0),
928
- skipped: ordered.slice(maxFiles)
929
- };
930
- }
931
- async function applySizeCeiling(selections, maxBytes, bounded, sizeOf) {
932
- const files = [...new Set(selections.filter((one) => bounded(one.rule.detector.kind)).flatMap((one) => one.files))];
933
- if (files.length === 0) return { selections, skipped: [] };
934
- const sizes = await Promise.all(files.map(async (file) => [file, await sizeOf(file)]));
935
- const over = new Set(sizes.filter(([, size]) => size !== null && size > maxBytes).map(([file]) => file));
936
- if (over.size === 0) return { selections, skipped: [] };
937
- return {
938
- selections: selections.map(
939
- (selection) => bounded(selection.rule.detector.kind) ? { ...selection, files: selection.files.filter((file) => !over.has(file)) } : selection
940
- ).filter((selection) => selection.files.length > 0),
941
- skipped: files.filter((file) => over.has(file))
942
- };
943
- }
944
-
945
- // src/core/detection/run.ts
946
- var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
947
- async function runDetection(input) {
948
- const output = { findings: [], errors: [], timedOut: [] };
949
- const byKind = /* @__PURE__ */ new Map();
950
- for (const selection of input.selections) {
951
- const kind = selection.rule.detector.kind;
952
- const group = byKind.get(kind);
953
- if (group === void 0) byKind.set(kind, [selection]);
954
- else group.push(selection);
955
- }
956
- const controller = new AbortController();
957
- const deadline = new Promise((resolve) => {
958
- controller.signal.addEventListener("abort", () => resolve(TIMED_OUT), { once: true });
959
- });
960
- const timer = setTimeout(() => controller.abort(), input.timeoutMs);
961
- const deadlineAt = Date.now() + input.timeoutMs;
962
- const late = () => Date.now() > deadlineAt;
963
- const perKind = await Promise.all(
964
- [...byKind].map(async ([kind, selections]) => {
965
- const detector = input.registry.get(kind);
966
- if (!detector) return { status: "error", kind, selections, message: `detector "${kind}" is not registered` };
967
- try {
968
- const rules = await Promise.all(
969
- selections.map(async ({ rule, files }) => ({
970
- id: rule.id,
971
- config: rule.detector.config,
972
- files,
973
- context: await input.contextFor(rule)
974
- }))
975
- );
976
- const result = await Promise.race([
977
- detector.run({
978
- event: input.event,
979
- rules,
980
- read: input.read,
981
- changes: input.changes,
982
- cache: input.cacheFor(kind),
983
- settings: input.settings,
984
- cwd: input.root,
985
- signal: controller.signal,
986
- deadlineAt
987
- }),
988
- deadline
989
- ]);
990
- if (result === TIMED_OUT || late()) return { status: "timedOut", kind, selections };
991
- return { status: "ok", kind, selections, result };
992
- } catch (error) {
993
- if (error instanceof DeadlineError || controller.signal.aborted || late()) {
994
- return { status: "timedOut", kind, selections };
995
- }
996
- return { status: "error", kind, selections, message: errorMessage(error) };
997
- }
998
- })
999
- );
1000
- clearTimeout(timer);
1001
- for (const outcome of perKind) {
1002
- const ids = outcome.selections.map((selection) => selection.rule.id);
1003
- if (outcome.status === "timedOut") {
1004
- output.timedOut.push({ kind: outcome.kind, rules: ids });
1005
- continue;
1006
- }
1007
- if (outcome.status === "error") {
1008
- output.errors.push({ kind: outcome.kind, rules: ids, message: outcome.message });
1009
- continue;
1010
- }
1011
- const result = outcome.result;
1012
- const rulesById = new Map(outcome.selections.map((selection) => [selection.rule.id, selection.rule]));
1013
- const stray = result.findings.find((finding) => !rulesById.has(finding.rule));
1014
- const wholeRun = result.errors.find((error) => error.rule === null);
1015
- if (stray || wholeRun) {
1016
- const message = stray ? `detector reported unknown rule "${stray.rule}"` : wholeRun.message;
1017
- output.errors.push({ kind: outcome.kind, rules: ids, message });
1018
- continue;
1019
- }
1020
- const failed = /* @__PURE__ */ new Map();
1021
- for (const error of result.errors) failed.set(error.rule, error.message);
1022
- for (const finding of result.findings) {
1023
- const rule = rulesById.get(finding.rule);
1024
- const missing = rule.detector.captures.find((name) => typeof finding.match.captures[name] !== "string");
1025
- if (missing && !failed.has(rule.id)) failed.set(rule.id, `match is missing declared capture "${missing}"`);
1026
- }
1027
- for (const [rule, message] of failed) output.errors.push({ kind: outcome.kind, rules: [rule], message });
1028
- for (const finding of result.findings) {
1029
- if (!failed.has(finding.rule)) output.findings.push({ rule: rulesById.get(finding.rule), match: finding.match });
1030
- }
1031
- }
1032
- return output;
1033
- }
1034
-
1035
1139
  // src/core/detection/proposal.ts
1036
1140
  function propose(current, intent) {
1037
1141
  if (intent.content !== void 0) {
@@ -1140,6 +1244,7 @@ var ITEM_OVERHEAD = 64;
1140
1244
  var HEADER_OVERHEAD = 128;
1141
1245
  var OVERFLOW_NOTICE = 256;
1142
1246
  var REFERENCE_OVERHEAD = 96;
1247
+ var BACKLOG_HEADING = "backlog in files you touched (not from your edit):";
1143
1248
  var WARNING_SHARE = 0.15;
1144
1249
  var moreWarnings = (count) => `\u2026and ${count} more (run rulecast validate)`;
1145
1250
  function locationOf(spec) {
@@ -1351,6 +1456,7 @@ async function decide(input) {
1351
1456
  used += size;
1352
1457
  context.push({ t: "delivered", path: resolved.spec.path, anchor: resolved.spec.anchor, hash: resolved.hash });
1353
1458
  }
1459
+ if (delivery.preexistingSummary.length > 0) used += BACKLOG_HEADING.length + BACKLOG_HINT.length + ITEM_OVERHEAD;
1354
1460
  const summariesKept = [];
1355
1461
  for (const summary of delivery.preexistingSummary) {
1356
1462
  if (!fits(summary.rule.length + summary.file.length + ITEM_OVERHEAD)) {
@@ -1589,7 +1695,12 @@ async function runPipeline(options) {
1589
1695
  if (session) await appendContext(session.dir, session.agent, [{ t: "reset" }]);
1590
1696
  if (!session || restoredFiles === 0) return { delivery: emptyDelivery(), failed, deadlineMissed };
1591
1697
  }
1592
- let baseline = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map() };
1698
+ let baseline = {
1699
+ started: false,
1700
+ startCommit: null,
1701
+ snapshots: /* @__PURE__ */ new Map(),
1702
+ fingerprints: /* @__PURE__ */ new Map()
1703
+ };
1593
1704
  if (session && event.kind !== "reset" && event.kind !== "guard") {
1594
1705
  const dir = session.dir;
1595
1706
  baseline = await readingStore(() => readBaseline(dir), baseline);
@@ -1644,10 +1755,32 @@ async function runPipeline(options) {
1644
1755
  if (event.completeRead) agentRead = event.files[0] ?? null;
1645
1756
  if (session) {
1646
1757
  const records = [];
1758
+ const snapshotted = [];
1647
1759
  for (const file of relevant(event.files)) {
1648
1760
  if (baseline.snapshots.has(file)) continue;
1649
1761
  const text = await readSourceFile(root, file);
1650
- if (text !== null) records.push(snapshotRecord(file, snapshotOf(text)));
1762
+ if (text === null) continue;
1763
+ records.push(snapshotRecord(file, snapshotOf(text)));
1764
+ snapshotted.push(file);
1765
+ }
1766
+ if (snapshotted.length > 0) {
1767
+ records.push(
1768
+ ...await recordFingerprints({
1769
+ root,
1770
+ files: snapshotted,
1771
+ rules: project.rules,
1772
+ disabled,
1773
+ registry,
1774
+ read: (file) => readSourceFile(root, file),
1775
+ cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
1776
+ contextFor: (rule) => resolveRuleContext(resolver, rule.context),
1777
+ settings: { llm: config.llm },
1778
+ event: "edit",
1779
+ source: "disk",
1780
+ timeoutMs: config.timeouts.editDeadlineMs,
1781
+ ceiling: { maxFileBytes: config.maxFileBytes, sizeOf: (file) => fileBytes(root, file) }
1782
+ })
1783
+ );
1651
1784
  }
1652
1785
  await appendBaseline(session.dir, records);
1653
1786
  }
@@ -1669,10 +1802,36 @@ async function runPipeline(options) {
1669
1802
  }
1670
1803
  if (event.files.length === 0 && session) files = relevant(view.work.edited);
1671
1804
  }
1672
- const changes = await computeChanges(root, files, { snapshots, fallbackCommit });
1805
+ const baselineChanges = await computeChanges(root, files, { snapshots, fallbackCommit });
1806
+ const changes = baselineChanges.sets;
1673
1807
  if (event.kind === "verify" && session && !event.baseCommit) {
1674
1808
  files = files.filter((file) => changes.get(file)?.changedLines.length !== 0);
1675
1809
  }
1810
+ if (event.kind === "verify" && baselineChanges.fromCommit.size > 0) {
1811
+ const texts = baselineChanges.fromCommit;
1812
+ const missing = [...texts.keys()].filter((file) => !baseline.fingerprints.has(file));
1813
+ const records = missing.length === 0 ? [] : await recordFingerprints({
1814
+ root,
1815
+ files: missing,
1816
+ rules: project.rules,
1817
+ disabled,
1818
+ registry,
1819
+ read: async (file) => texts.get(file) ?? null,
1820
+ cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
1821
+ contextFor: (rule) => resolveRuleContext(resolver, rule.context),
1822
+ settings: { llm: config.llm },
1823
+ event: "verify",
1824
+ source: "memory",
1825
+ timeoutMs: config.timeouts.verifyMs
1826
+ });
1827
+ for (const record of records) {
1828
+ if (record.t !== "fingerprint") continue;
1829
+ const byRule = baseline.fingerprints.get(record.file) ?? /* @__PURE__ */ new Map();
1830
+ baseline.fingerprints.set(record.file, byRule);
1831
+ byRule.set(record.rule, record.ranges);
1832
+ }
1833
+ if (session && records.length > 0) await appendBaseline(session.dir, records);
1834
+ }
1676
1835
  const skip = options.skipDetectorKinds ?? /* @__PURE__ */ new Set();
1677
1836
  let selections = selectDetectorRules(project.rules, event.kind, files, disabled).filter(
1678
1837
  (selection) => !skip.has(selection.rule.detector.kind) && (options.onlyRules?.has(selection.rule.id) ?? true)
@@ -1710,7 +1869,7 @@ async function runPipeline(options) {
1710
1869
  findings = output.findings.map(({ rule, match }) => ({
1711
1870
  rule,
1712
1871
  match,
1713
- status: isNew(match, changes) ? "new" : "preexisting"
1872
+ status: classify(match, rule.scope, rule.id, baselineChanges, baseline.fingerprints)
1714
1873
  }));
1715
1874
  for (const error of output.errors) {
1716
1875
  failed = true;
@@ -1732,7 +1891,7 @@ async function runPipeline(options) {
1732
1891
  failed = true;
1733
1892
  warnings.push({
1734
1893
  key: `timeout:${timeout.kind}:${timeout.rules.join(",")}`,
1735
- // Naming the lever matters: dogfooding measured a single haiku call on a 135-line file
1894
+ // Naming the lever matters: a field trial measured a single haiku call on a 135-line file
1736
1895
  // at 89 s against a 60 s default, and "timed out" alone leaves nobody knowing what to do.
1737
1896
  text: `${timeout.kind} detector timed out after ${config.timeouts.verifyMs} ms for ${timeout.rules.join(", ")}. Raise timeouts.verify_ms${timeout.kind === "llm" ? " (an llm rule on a large file can need 120000 or more)" : ""}.`
1738
1897
  });
@@ -1812,5 +1971,7 @@ export {
1812
1971
  cachedRepos,
1813
1972
  fetchingRepos,
1814
1973
  fixedRepo,
1974
+ resolveRuleContext,
1975
+ createReferenceResolver,
1815
1976
  runPipeline
1816
1977
  };