@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 +93 -10
- package/dist/{chunk-HZUATJ53.js → chunk-4XS34SYW.js} +266 -81
- package/dist/{chunk-KOUHBT7F.js → chunk-MB4F7ZXC.js} +236 -133
- package/dist/{chunk-7KUPYT35.js → chunk-Y3FXJRZB.js} +818 -275
- package/dist/cli.js +607 -152
- package/dist/index.d.ts +75 -4
- package/dist/index.js +32 -4
- package/dist/internal.d.ts +13 -10
- package/dist/internal.js +2 -2
- package/dist/{types-C8b7hrnG.d.ts → types-D5y_IokF.d.ts} +124 -5
- package/package.json +3 -2
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
|
|
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` |
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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":
|
|
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
|
|
468
|
-
return
|
|
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
|
|
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
|
-
|
|
549
|
-
|
|
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
|
-
|
|
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
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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(
|
|
568
|
-
for (const summary of delivery.preexistingSummary)
|
|
569
|
-
|
|
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 (
|
|
577
|
-
out.push(
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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(
|
|
763
|
-
return `requires rulecast ${minimum} or newer (running ${
|
|
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)
|
|
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
|
-
|
|
931
|
-
parseVersion,
|
|
932
|
-
isOlder,
|
|
933
|
-
BACKLOG_HINT,
|
|
1117
|
+
SWEPT_BLOCK,
|
|
934
1118
|
measureRuleBlock,
|
|
1119
|
+
deliveryCost,
|
|
935
1120
|
renderAgentText,
|
|
936
1121
|
tagsOf,
|
|
937
1122
|
compileFilter,
|