@syv-ai/rulecast 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -10
- package/dist/{chunk-ZEFEQKG5.js → chunk-DQUCE2IK.js} +230 -69
- package/dist/{chunk-C6C5EER7.js → chunk-IKONOTME.js} +238 -130
- package/dist/{chunk-RODZRI7B.js → chunk-WUFU4LW2.js} +792 -319
- package/dist/cli.js +803 -102
- package/dist/index.d.ts +111 -4
- package/dist/index.js +37 -5
- package/dist/internal.d.ts +13 -10
- package/dist/internal.js +2 -2
- package/dist/{types-2oz2AlwA.d.ts → types-Dcra5LXR.d.ts} +200 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -89,24 +89,148 @@ A refusal only ever rests on the text the agent is writing, never on a reconstru
|
|
|
89
89
|
|
|
90
90
|
| Command | Does |
|
|
91
91
|
|---|---|
|
|
92
|
-
| `rulecast init` |
|
|
93
|
-
| `rulecast install` / `uninstall` | Add or remove the agent hooks |
|
|
94
|
-
| `rulecast run` | Check staged files, changed files, or everything |
|
|
95
|
-
| `rulecast
|
|
96
|
-
| `rulecast
|
|
92
|
+
| `rulecast init` | Set rulecast up: config, catalog rules, agent hooks |
|
|
93
|
+
| `rulecast install` / `uninstall` | Add (or upgrade) or remove the agent hooks |
|
|
94
|
+
| `rulecast run` | Check staged files (the default), changed files, or everything |
|
|
95
|
+
| `rulecast list` | Show every configured rule: where it comes from, what it matches, what it cites |
|
|
96
|
+
| `rulecast test` | Run each rule's good/bad examples; `--against` says how much it would flag |
|
|
97
|
+
| `rulecast validate` | Check the config (and a rules manifest) and print diagnostics |
|
|
98
|
+
| `rulecast doctor` | Compile, check the environment and hooks, dry-run every rule |
|
|
97
99
|
| `rulecast autoupdate` | Move pinned rule repos to their latest tag |
|
|
98
100
|
| `rulecast try-repo` | Run a rule repo against your project without configuring it |
|
|
99
101
|
| `rulecast clean` | Delete the cache |
|
|
100
102
|
| `rulecast warm` | Build detector caches ahead of time |
|
|
101
|
-
| `rulecast hook <adapter>` | Answer an agent hook on stdin |
|
|
103
|
+
| `rulecast hook <adapter>` | Answer an agent hook on stdin (the agent runs this, not you) |
|
|
102
104
|
|
|
103
|
-
`rulecast
|
|
105
|
+
`rulecast <command> --help` prints a command's flags; `rulecast --version` prints the version.
|
|
106
|
+
|
|
107
|
+
## Git hooks and CI
|
|
108
|
+
|
|
109
|
+
The same rule reports the same line the same way in an agent hook, a git hook and CI. What counts as new is a match on a line you changed; a violation that was already there is backlog, shown and never failing.
|
|
110
|
+
|
|
111
|
+
- **Pre-commit:** `rulecast run` with no file flags checks the staged files, reads their content from the index (what is being committed, not the working tree), and judges it against `HEAD`. `llm` rules are skipped unless you pass `--llm`, and one line says so: a commit should not cost money.
|
|
112
|
+
- **Pre-push and CI:** `rulecast run --from-ref A --to-ref B` checks the files changed between the merge base with `A` and `B`, reading them at `B`. Without `--to-ref` it reads the working tree.
|
|
113
|
+
- `rulecast run --files F...` judges files whole, with no baseline. Use the hook below rather than `--files` from a hook manager.
|
|
114
|
+
|
|
115
|
+
rulecast installs no git hooks itself: lefthook, husky and pre-commit already own `.git/hooks`. Add `@syv-ai/rulecast` to the project's devDependencies (the agent hooks need it there anyway), and wire it into the one you use.
|
|
116
|
+
|
|
117
|
+
**pre-commit** (`.pre-commit-config.yaml`):
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
minimum_pre_commit_version: "3.2.0"
|
|
121
|
+
repos:
|
|
122
|
+
- repo: https://github.com/syv-ai/rulecast
|
|
123
|
+
rev: v0.4.0
|
|
124
|
+
hooks:
|
|
125
|
+
- id: rulecast
|
|
126
|
+
- id: rulecast-push
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**lefthook** (`lefthook.yml`):
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
pre-commit:
|
|
133
|
+
jobs:
|
|
134
|
+
- name: rulecast
|
|
135
|
+
run: npx --no-install rulecast run
|
|
136
|
+
pre-push:
|
|
137
|
+
jobs:
|
|
138
|
+
- name: rulecast
|
|
139
|
+
run: npx --no-install rulecast run --from-ref "$(git merge-base origin/HEAD HEAD)" --to-ref HEAD
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**GitHub Actions:**
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
on: pull_request
|
|
146
|
+
jobs:
|
|
147
|
+
rulecast:
|
|
148
|
+
runs-on: ubuntu-latest
|
|
149
|
+
permissions:
|
|
150
|
+
contents: read
|
|
151
|
+
security-events: write
|
|
152
|
+
steps:
|
|
153
|
+
- uses: actions/checkout@v4
|
|
154
|
+
with:
|
|
155
|
+
fetch-depth: 0 # rulecast needs the merge base with the target branch
|
|
156
|
+
- uses: actions/setup-node@v4
|
|
157
|
+
with:
|
|
158
|
+
node-version: 22
|
|
159
|
+
- run: npm ci
|
|
160
|
+
- run: npx rulecast run --from-ref origin/${{ github.base_ref }} --format sarif > rulecast.sarif
|
|
161
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
162
|
+
if: always() # rulecast exits 1 on findings, which is when the upload matters
|
|
163
|
+
with:
|
|
164
|
+
sarif_file: rulecast.sarif
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A shallow clone has no merge base; rulecast says so and names `fetch-depth: 0`. `--from-ref` runs `llm` rules, which judge changed files only.
|
|
168
|
+
|
|
169
|
+
`command` scripts, and `oxlint`, are handed a scratch copy of each file in staged and `--to-ref` runs, because the content to judge is not the file on disk. `ruff` and `eslint` get it on stdin under the real path, so path-keyed configuration still applies.
|
|
170
|
+
|
|
171
|
+
## Turning a rule off
|
|
172
|
+
|
|
173
|
+
- **A whole rule:** `enabled: false` on the rule. Under a rule repo's entry, `- id: <rule>` with `enabled: false` switches a catalog rule off and keeps the line.
|
|
174
|
+
- **One finding:** a comment on the matched line or the line above it, in the file's own comment syntax. The reason is required; an ignore without one suppresses nothing.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
# rulecast-ignore: python/no-httpexception-in-services legacy endpoint, removed in #412
|
|
178
|
+
raise HTTPException(404)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`rulecast run --all-files --summary` counts the ignores, and an ignore or a config change made during an agent session is reported to you when the agent stops: the agent cannot quietly edit its way past a rule.
|
|
182
|
+
|
|
183
|
+
## Adopting a rule on a codebase that already breaks it
|
|
184
|
+
|
|
185
|
+
`rulecast run --all-files --summary` tells you how much you already owe:
|
|
186
|
+
|
|
187
|
+
```text
|
|
188
|
+
backlog: 57 violations in 31 files
|
|
189
|
+
|
|
190
|
+
rule violations files
|
|
191
|
+
python/routes-never-call-crud 57 31
|
|
192
|
+
|
|
193
|
+
worst files violations
|
|
194
|
+
app/api/routes/orders.py 12
|
|
195
|
+
app/api/routes/users.py 9
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Watch the count, not the rate. Here is one real codebase on one rule, on `main`:
|
|
199
|
+
|
|
200
|
+
| date | violating / total |
|
|
201
|
+
|---|---|
|
|
202
|
+
| 2026-01-15 | 61 / 80 |
|
|
203
|
+
| 2026-06-01 | 58 / 143 |
|
|
204
|
+
| 2026-09-25 | 57 / 194 |
|
|
205
|
+
|
|
206
|
+
The rate fell from 76% to 29% and the count did not move. New code complied; the old violations were
|
|
207
|
+
never fixed, only diluted. A team watching the percentage would have believed it was winning.
|
|
208
|
+
|
|
209
|
+
Per-edit enforcement does not touch that stock — it only stops it growing. Fix it a file at a time:
|
|
210
|
+
edits to files that already followed the rule broke it 2% of the time, against 37% in files that
|
|
211
|
+
mostly did not, so a file you clean tends to stay clean. (One rule across 19 files: suggestive, not
|
|
212
|
+
proven.)
|
|
104
213
|
|
|
105
214
|
If something is configured but quiet, `rulecast doctor` says why: it compiles the config, reports whether each rule's linter, parser, script or API key is actually there, says where the hooks and caches live, and runs every rule against one file it matches.
|
|
106
215
|
|
|
107
216
|
## Agents
|
|
108
217
|
|
|
109
|
-
rulecast ships docs written for coding agents, not for people
|
|
218
|
+
rulecast ships docs written for coding agents, not for people. Paste this to yours and it will set
|
|
219
|
+
rulecast up itself:
|
|
220
|
+
|
|
221
|
+
```text
|
|
222
|
+
Set up rulecast in this project. Read
|
|
223
|
+
https://raw.githubusercontent.com/syv-ai/rulecast/v0.4.0/agents/SETUP.md
|
|
224
|
+
and follow it. Show me every file it creates or changes before I commit anything.
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
If your agent cannot fetch a URL, this is the whole of it: run
|
|
228
|
+
`npx @syv-ai/rulecast init --yes --agent <your agent, e.g. claude-code>`, then show me
|
|
229
|
+
`.rulecast-config.yaml` and the other files it listed. Never edit hook settings by hand —
|
|
230
|
+
`rulecast install` and `rulecast uninstall` own them.
|
|
231
|
+
|
|
232
|
+
Once it is set up, ask the same agent to draft rules for your own conventions; `init` prints the
|
|
233
|
+
prompt to paste.
|
|
110
234
|
|
|
111
235
|
- [`agents/SETUP.md`](https://github.com/syv-ai/rulecast/blob/main/agents/SETUP.md) — give this to your agent and it will set rulecast up itself.
|
|
112
236
|
- [`agents/DRAFT-RULES.md`](https://github.com/syv-ai/rulecast/blob/main/agents/DRAFT-RULES.md) — it reads your `AGENTS.md`, proposes one rule per convention that code can visibly break, shows you what each would flag today, and asks you to keep, edit or drop it.
|
|
@@ -136,9 +260,9 @@ The budget is what shapes the design: one detector run per kind per event, kinds
|
|
|
136
260
|
|
|
137
261
|
## Status
|
|
138
262
|
|
|
139
|
-
0.
|
|
263
|
+
0.4. Everything above works and is tested. Before 1.0 the config format and the output of `rulecast run` may still change; `minimum_rulecast_version` exists so a rule can say what it needs.
|
|
140
264
|
|
|
141
|
-
Next: adapters for Codex, Cursor and OpenCode; Biome;
|
|
265
|
+
Next: adapters for Codex, Cursor and OpenCode; Biome; an Azure OpenAI provider.
|
|
142
266
|
|
|
143
267
|
## License
|
|
144
268
|
|
|
@@ -1,8 +1,31 @@
|
|
|
1
|
+
// src/core/version.ts
|
|
2
|
+
var VERSION = "0.4.0";
|
|
3
|
+
function parseVersion(text) {
|
|
4
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
|
|
5
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
6
|
+
}
|
|
7
|
+
function isOlder(running, minimum) {
|
|
8
|
+
const a = parseVersion(running);
|
|
9
|
+
const b = parseVersion(minimum);
|
|
10
|
+
if (!a || !b) return false;
|
|
11
|
+
for (let i = 0; i < 3; i++) {
|
|
12
|
+
if (a[i] !== b[i]) return a[i] < b[i];
|
|
13
|
+
}
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
1
17
|
// src/core/detection/per-rule.ts
|
|
2
18
|
import { readFile, stat } from "fs/promises";
|
|
3
19
|
import path from "path";
|
|
4
20
|
|
|
5
21
|
// src/core/errors.ts
|
|
22
|
+
import {
|
|
23
|
+
ZodDefault,
|
|
24
|
+
ZodEffects,
|
|
25
|
+
ZodNullable,
|
|
26
|
+
ZodObject,
|
|
27
|
+
ZodOptional
|
|
28
|
+
} from "zod";
|
|
6
29
|
var DeadlineError = class extends Error {
|
|
7
30
|
};
|
|
8
31
|
function isNotFound(error) {
|
|
@@ -11,11 +34,75 @@ function isNotFound(error) {
|
|
|
11
34
|
function errorMessage(error) {
|
|
12
35
|
return error instanceof Error ? error.message : String(error);
|
|
13
36
|
}
|
|
14
|
-
function formatZodError(error) {
|
|
15
|
-
|
|
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.2.0";
|
|
408
|
-
function parseVersion(text) {
|
|
409
|
-
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
|
|
410
|
-
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
411
|
-
}
|
|
412
|
-
function isOlder(running, minimum) {
|
|
413
|
-
const a = parseVersion(running);
|
|
414
|
-
const b = parseVersion(minimum);
|
|
415
|
-
if (!a || !b) return false;
|
|
416
|
-
for (let i = 0; i < 3; i++) {
|
|
417
|
-
if (a[i] !== b[i]) return a[i] < b[i];
|
|
418
|
-
}
|
|
419
|
-
return false;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
528
|
// src/core/template.ts
|
|
423
529
|
var VARIABLE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
424
530
|
var CORE_VARIABLES = ["file", "line", "column", "text", "rule"];
|
|
@@ -457,6 +563,19 @@ function templateBindings(template) {
|
|
|
457
563
|
var GROUP_FROM = 3;
|
|
458
564
|
var GROUP_MIN_LITERAL = 40;
|
|
459
565
|
var MAX_LOCATION_PAD = 40;
|
|
566
|
+
var BACKLOG_HINT = " see all of it: rulecast run --all-files --summary";
|
|
567
|
+
var BACKLOG_HEADING = "backlog in files you touched (not from your edit; leave it unless asked):";
|
|
568
|
+
var WARNINGS_HEADING = "rulecast warnings:";
|
|
569
|
+
var CONVENTIONS_TITLE = "rulecast: conventions for the files you are working on";
|
|
570
|
+
var NOTICES_HEADING = "rulecast notices (for the user):";
|
|
571
|
+
var backlogLine = (summary) => ` ${summary.rule} \xD7${summary.count} in ${summary.file}`;
|
|
572
|
+
var backlogMoreLine = (count) => ` \u2026and ${count} more`;
|
|
573
|
+
var warningLine = (warning) => ` - ${warning}`;
|
|
574
|
+
var overflowLines = (rules, path7) => [
|
|
575
|
+
`\u2026${plural(rules, "rule")} and the conventions they cite did not fit here.`,
|
|
576
|
+
"Every finding, with the doc sections it cites, is in:",
|
|
577
|
+
` ${path7}`
|
|
578
|
+
];
|
|
460
579
|
var WRAP = 96;
|
|
461
580
|
var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
462
581
|
function header(delivery) {
|
|
@@ -467,7 +586,7 @@ function header(delivery) {
|
|
|
467
586
|
return `rulecast: ${plural(rules, "rule")} violated${where}`;
|
|
468
587
|
}
|
|
469
588
|
if (delivery.references.length > 0 || delivery.preexistingSummary.length > 0) {
|
|
470
|
-
return
|
|
589
|
+
return CONVENTIONS_TITLE;
|
|
471
590
|
}
|
|
472
591
|
return null;
|
|
473
592
|
}
|
|
@@ -544,12 +663,66 @@ function ruleBlock(rule, findings, template, dropped, options) {
|
|
|
544
663
|
}
|
|
545
664
|
return out;
|
|
546
665
|
}
|
|
547
|
-
|
|
548
|
-
|
|
666
|
+
var linesCost = (lines) => lines.reduce((sum, line) => sum + line.length + 1, 0);
|
|
667
|
+
function measureRuleBlock(rule, shown, all, template, options) {
|
|
668
|
+
const shownFiles = new Set(shown.map((finding) => finding.file));
|
|
669
|
+
const rest = all.slice(shown.length);
|
|
670
|
+
const cut = { count: rest.length, files: new Set(rest.map((f) => f.file).filter((f) => !shownFiles.has(f))).size };
|
|
671
|
+
return linesCost([...ruleBlock(rule, shown, template, cut, options), ""]);
|
|
549
672
|
}
|
|
673
|
+
var OVERFLOW_PATH_ALLOWANCE = 192;
|
|
674
|
+
var deliveryCost = {
|
|
675
|
+
/**
|
|
676
|
+
* The title and the blank line under it, at its longest — two things about it are not known yet.
|
|
677
|
+
* It names a file when every *delivered* finding is in one, and the budget may cut a rule down to
|
|
678
|
+
* findings in a single file however many files it fired in; so the longest file name is charged.
|
|
679
|
+
* And if the budget drops every rule that fired, no findings are left and the conventions title
|
|
680
|
+
* prints instead; so with `conventions` the longer of the two is charged.
|
|
681
|
+
*/
|
|
682
|
+
header(rules, files, conventions) {
|
|
683
|
+
const longest = [...files].reduce((most, file) => file.length > most.length ? file : most, "");
|
|
684
|
+
const where = longest === "" ? "" : ` in ${longest}`;
|
|
685
|
+
const violated = rules > 0 ? linesCost([`rulecast: ${plural(rules, "rule")} violated${where}`, ""]) : 0;
|
|
686
|
+
return Math.max(violated, conventions ? linesCost([CONVENTIONS_TITLE, ""]) : 0);
|
|
687
|
+
},
|
|
688
|
+
/**
|
|
689
|
+
* One reference's line, at its longest — the "not included, too long" form, naming the file when
|
|
690
|
+
* it has an absolute location. Every other state prints a shorter line, so this bounds them all.
|
|
691
|
+
*/
|
|
692
|
+
referenceLine(ref, location) {
|
|
693
|
+
return linesCost(referenceLines({ ref, state: "read", reason: "budget", location }));
|
|
694
|
+
},
|
|
695
|
+
/** What a reference given in full adds over its one-line price: the content and the blank line. */
|
|
696
|
+
referenceContent(ref, content, location) {
|
|
697
|
+
return linesCost(referenceLines({ ref, state: "full", content })) - deliveryCost.referenceLine(ref, location);
|
|
698
|
+
},
|
|
699
|
+
/** The blank line that closes the references, once. */
|
|
700
|
+
referencesEnd: 1,
|
|
701
|
+
/** The warnings heading, once. */
|
|
702
|
+
warningsFrame: linesCost([WARNINGS_HEADING]),
|
|
703
|
+
warning(text) {
|
|
704
|
+
return linesCost([warningLine(text)]);
|
|
705
|
+
},
|
|
706
|
+
/**
|
|
707
|
+
* The backlog's heading, its hint, the blank line after, and the "…and N more" line at its
|
|
708
|
+
* longest — printed only when summaries are cut, and charged up front so a cut cannot overflow.
|
|
709
|
+
*/
|
|
710
|
+
backlogFrame(summaries) {
|
|
711
|
+
return linesCost([BACKLOG_HEADING, backlogMoreLine(summaries), BACKLOG_HINT, ""]);
|
|
712
|
+
},
|
|
713
|
+
backlogSummary(summary) {
|
|
714
|
+
return linesCost([backlogLine(summary)]);
|
|
715
|
+
},
|
|
716
|
+
/** The lines naming the overflow file, and the blank line before them. */
|
|
717
|
+
overflowNotice(rules) {
|
|
718
|
+
return linesCost(["", ...overflowLines(rules, "x".repeat(OVERFLOW_PATH_ALLOWANCE))]);
|
|
719
|
+
}
|
|
720
|
+
};
|
|
550
721
|
function renderAgentText(delivery, options) {
|
|
551
722
|
const title = header(delivery);
|
|
552
|
-
|
|
723
|
+
const notices = options.notices === false ? [] : delivery.notices ?? [];
|
|
724
|
+
const warnings = options.warnings === false ? [] : delivery.warnings;
|
|
725
|
+
if (title === null && warnings.length === 0 && notices.length === 0) return "";
|
|
553
726
|
const out = [];
|
|
554
727
|
if (title !== null) out.push(title, "");
|
|
555
728
|
const byRule = /* @__PURE__ */ new Map();
|
|
@@ -562,25 +735,24 @@ function renderAgentText(delivery, options) {
|
|
|
562
735
|
out.push(...ruleBlock(rule, findings, delivery.templates[rule], omittedFor(delivery.omitted, rule), options));
|
|
563
736
|
out.push("");
|
|
564
737
|
}
|
|
565
|
-
|
|
566
|
-
out.push(
|
|
738
|
+
if (delivery.preexistingSummary.length > 0 || delivery.omitted.preexisting > 0) {
|
|
739
|
+
out.push(BACKLOG_HEADING);
|
|
740
|
+
for (const summary of delivery.preexistingSummary) out.push(backlogLine(summary));
|
|
741
|
+
if (delivery.omitted.preexisting > 0) out.push(backlogMoreLine(delivery.omitted.preexisting));
|
|
742
|
+
out.push(BACKLOG_HINT, "");
|
|
567
743
|
}
|
|
568
|
-
if (delivery.omitted.preexisting > 0) {
|
|
569
|
-
out.push(`\u2026and ${delivery.omitted.preexisting} more pre-existing (not blocking)`);
|
|
570
|
-
}
|
|
571
|
-
if (delivery.preexistingSummary.length > 0 || delivery.omitted.preexisting > 0) out.push("");
|
|
572
744
|
for (const reference of delivery.references) out.push(...referenceLines(reference));
|
|
573
745
|
if (out.length > 0 && out.at(-1) !== "") out.push("");
|
|
574
|
-
if (
|
|
575
|
-
out.push(
|
|
746
|
+
if (warnings.length > 0) {
|
|
747
|
+
out.push(WARNINGS_HEADING, ...warnings.map(warningLine));
|
|
576
748
|
}
|
|
577
749
|
if (delivery.overflowPath !== null) {
|
|
578
750
|
if (out.at(-1) !== "") out.push("");
|
|
579
|
-
out.push(
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
);
|
|
751
|
+
out.push(...overflowLines(delivery.omitted.rules, delivery.overflowPath));
|
|
752
|
+
}
|
|
753
|
+
if (notices.length > 0) {
|
|
754
|
+
if (out.length > 0 && out.at(-1) !== "") out.push("");
|
|
755
|
+
out.push(NOTICES_HEADING, ...notices.map(warningLine));
|
|
584
756
|
}
|
|
585
757
|
while (out.at(-1) === "") out.pop();
|
|
586
758
|
return out.join("\n");
|
|
@@ -757,8 +929,8 @@ function fileOf(spec) {
|
|
|
757
929
|
async function compileRule(input, context) {
|
|
758
930
|
const { data } = input;
|
|
759
931
|
const minimum = data.minimum_rulecast_version;
|
|
760
|
-
if (minimum !== void 0 && isOlder(
|
|
761
|
-
return `requires rulecast ${minimum} or newer (running ${
|
|
932
|
+
if (minimum !== void 0 && isOlder(VERSION, minimum)) {
|
|
933
|
+
return `requires rulecast ${minimum} or newer (running ${VERSION})`;
|
|
762
934
|
}
|
|
763
935
|
let detector = null;
|
|
764
936
|
let defaultStages = ["touch"];
|
|
@@ -767,7 +939,8 @@ async function compileRule(input, context) {
|
|
|
767
939
|
const implementation = context.registry.get(kind);
|
|
768
940
|
if (!implementation) return `unknown detector "${kind}"`;
|
|
769
941
|
const parsed = await implementation.schema.safeParseAsync(rawConfig ?? {});
|
|
770
|
-
if (!parsed.success)
|
|
942
|
+
if (!parsed.success)
|
|
943
|
+
return formatZodError(parsed.error, { prefix: `detect.${kind}`, schema: implementation.schema });
|
|
771
944
|
detector = { kind, config: parsed.data, captures: implementation.captures(parsed.data) };
|
|
772
945
|
defaultStages = implementation.events(parsed.data);
|
|
773
946
|
}
|
|
@@ -788,6 +961,8 @@ async function compileRule(input, context) {
|
|
|
788
961
|
if (unknown) return `unknown template variable "${unknown}"`;
|
|
789
962
|
} else {
|
|
790
963
|
if (data.refuse_write) return "refuse_write needs detect: there is nothing to refuse a write for";
|
|
964
|
+
if (data.scope) return "scope needs detect: there is nothing to classify";
|
|
965
|
+
if (data.examples) return "examples need detect: there is nothing to run them against";
|
|
791
966
|
if (stages.some((stage) => stage !== "touch")) return "rules without detect need stages: [touch]";
|
|
792
967
|
if (data.message !== void 0) return "message needs detect";
|
|
793
968
|
if (!data.context?.length) return "rules without detect need context";
|
|
@@ -800,6 +975,9 @@ async function compileRule(input, context) {
|
|
|
800
975
|
excludeTypes: data.exclude_types ?? []
|
|
801
976
|
});
|
|
802
977
|
if (typeof filter === "string") return filter;
|
|
978
|
+
for (const example of [...data.examples?.good ?? [], ...data.examples?.bad ?? []]) {
|
|
979
|
+
if (!filter(example.path)) return `example path "${example.path}" does not match this rule's files`;
|
|
980
|
+
}
|
|
803
981
|
const references = [];
|
|
804
982
|
for (const reference of data.context ?? []) {
|
|
805
983
|
let spec;
|
|
@@ -823,12 +1001,16 @@ async function compileRule(input, context) {
|
|
|
823
1001
|
description: data.description ?? null,
|
|
824
1002
|
source: input.source,
|
|
825
1003
|
severity: data.severity ?? "error",
|
|
1004
|
+
scope: data.scope ?? "instance",
|
|
826
1005
|
refuseWrite: data.refuse_write === true,
|
|
1006
|
+
enabled: data.enabled !== false,
|
|
827
1007
|
stages,
|
|
1008
|
+
patterns: { files: data.files ?? "", exclude: data.exclude ?? "^$" },
|
|
828
1009
|
matches: (file) => global(file) && filter(file),
|
|
829
1010
|
detector,
|
|
830
1011
|
message: data.message ?? null,
|
|
831
|
-
context: references
|
|
1012
|
+
context: references,
|
|
1013
|
+
examples: data.examples ?? null
|
|
832
1014
|
};
|
|
833
1015
|
}
|
|
834
1016
|
|
|
@@ -871,37 +1053,18 @@ function detectorCacheDir(stateDir, kind) {
|
|
|
871
1053
|
return path6.join(stateDir, "cache", kind);
|
|
872
1054
|
}
|
|
873
1055
|
|
|
874
|
-
// src/core/detection/positions.ts
|
|
875
|
-
function lineStarts(text) {
|
|
876
|
-
const starts = [0];
|
|
877
|
-
for (let i = 0; i < text.length; i++) {
|
|
878
|
-
if (text.charCodeAt(i) === 10) starts.push(i + 1);
|
|
879
|
-
}
|
|
880
|
-
return starts;
|
|
881
|
-
}
|
|
882
|
-
function positionAt(starts, offset) {
|
|
883
|
-
let low = 0;
|
|
884
|
-
let high = starts.length - 1;
|
|
885
|
-
while (low < high) {
|
|
886
|
-
const mid = low + high + 1 >> 1;
|
|
887
|
-
if (starts[mid] <= offset) low = mid;
|
|
888
|
-
else high = mid - 1;
|
|
889
|
-
}
|
|
890
|
-
return { line: low + 1, column: offset - starts[low] + 1 };
|
|
891
|
-
}
|
|
892
|
-
function offsetAt(starts, line, column, length) {
|
|
893
|
-
const index = Math.min(Math.max(line, 1), starts.length) - 1;
|
|
894
|
-
const offset = starts[index] + Math.max(column, 1) - 1;
|
|
895
|
-
return Math.min(Math.max(offset, 0), length);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
1056
|
export {
|
|
899
1057
|
DeadlineError,
|
|
900
1058
|
isNotFound,
|
|
901
1059
|
errorMessage,
|
|
902
1060
|
formatZodError,
|
|
1061
|
+
VERSION,
|
|
1062
|
+
parseVersion,
|
|
1063
|
+
isOlder,
|
|
1064
|
+
pastDeadline,
|
|
903
1065
|
perRule,
|
|
904
1066
|
readSourceFile,
|
|
1067
|
+
sourceReader,
|
|
905
1068
|
fileBytes,
|
|
906
1069
|
onPath,
|
|
907
1070
|
CORE_VARIABLES,
|
|
@@ -918,10 +1081,8 @@ export {
|
|
|
918
1081
|
LLM_PROVIDERS,
|
|
919
1082
|
defaultDetectorSettings,
|
|
920
1083
|
emptyDelivery,
|
|
921
|
-
VERSION2 as VERSION,
|
|
922
|
-
parseVersion,
|
|
923
|
-
isOlder,
|
|
924
1084
|
measureRuleBlock,
|
|
1085
|
+
deliveryCost,
|
|
925
1086
|
renderAgentText,
|
|
926
1087
|
tagsOf,
|
|
927
1088
|
compileFilter,
|