@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 +49 -2
- package/dist/{chunk-RODZRI7B.js → chunk-7KUPYT35.js} +299 -138
- package/dist/{chunk-ZEFEQKG5.js → chunk-HZUATJ53.js} +18 -8
- package/dist/{chunk-C6C5EER7.js → chunk-KOUHBT7F.js} +62 -2
- package/dist/cli.js +289 -27
- package/dist/index.d.ts +40 -4
- package/dist/index.js +8 -4
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +2 -2
- package/dist/{types-2oz2AlwA.d.ts → types-C8b7hrnG.d.ts} +105 -1
- package/package.json +1 -1
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;
|
|
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-
|
|
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
|
|
659
|
-
const
|
|
669
|
+
function diffLines(before, after) {
|
|
670
|
+
const changed = [];
|
|
671
|
+
const runs = [];
|
|
660
672
|
const mark = (start, end) => {
|
|
661
|
-
const previous =
|
|
673
|
+
const previous = changed.at(-1);
|
|
662
674
|
if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
|
|
663
|
-
else
|
|
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
|
|
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
|
-
|
|
725
|
-
before =
|
|
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
|
-
|
|
732
|
-
|
|
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 = {
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
};
|