@syv-ai/rulecast 0.3.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 +86 -9
- package/dist/{chunk-HZUATJ53.js → chunk-DQUCE2IK.js} +219 -68
- package/dist/{chunk-KOUHBT7F.js → chunk-IKONOTME.js} +177 -129
- package/dist/{chunk-7KUPYT35.js → chunk-WUFU4LW2.js} +577 -265
- package/dist/cli.js +586 -147
- package/dist/index.d.ts +75 -4
- package/dist/index.js +31 -3
- package/dist/internal.d.ts +13 -10
- package/dist/internal.js +2 -2
- package/dist/{types-C8b7hrnG.d.ts → types-Dcra5LXR.d.ts} +96 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -89,19 +89,96 @@ 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 |
|
|
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 |
|
|
95
96
|
| `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 |
|
|
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 |
|
|
98
99
|
| `rulecast autoupdate` | Move pinned rule repos to their latest tag |
|
|
99
100
|
| `rulecast try-repo` | Run a rule repo against your project without configuring it |
|
|
100
101
|
| `rulecast clean` | Delete the cache |
|
|
101
102
|
| `rulecast warm` | Build detector caches ahead of time |
|
|
102
|
-
| `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) |
|
|
103
104
|
|
|
104
|
-
`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.
|
|
105
182
|
|
|
106
183
|
## Adopting a rule on a codebase that already breaks it
|
|
107
184
|
|
|
@@ -143,7 +220,7 @@ rulecast up itself:
|
|
|
143
220
|
|
|
144
221
|
```text
|
|
145
222
|
Set up rulecast in this project. Read
|
|
146
|
-
https://raw.githubusercontent.com/syv-ai/rulecast/v0.
|
|
223
|
+
https://raw.githubusercontent.com/syv-ai/rulecast/v0.4.0/agents/SETUP.md
|
|
147
224
|
and follow it. Show me every file it creates or changes before I commit anything.
|
|
148
225
|
```
|
|
149
226
|
|
|
@@ -183,7 +260,7 @@ The budget is what shapes the design: one detector run per kind per event, kinds
|
|
|
183
260
|
|
|
184
261
|
## Status
|
|
185
262
|
|
|
186
|
-
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.
|
|
187
264
|
|
|
188
265
|
Next: adapters for Codex, Cursor and OpenCode; Biome; an Azure OpenAI provider.
|
|
189
266
|
|
|
@@ -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.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,6 +564,18 @@ 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 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
|
+
];
|
|
461
579
|
var WRAP = 96;
|
|
462
580
|
var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
463
581
|
function header(delivery) {
|
|
@@ -468,7 +586,7 @@ function header(delivery) {
|
|
|
468
586
|
return `rulecast: ${plural(rules, "rule")} violated${where}`;
|
|
469
587
|
}
|
|
470
588
|
if (delivery.references.length > 0 || delivery.preexistingSummary.length > 0) {
|
|
471
|
-
return
|
|
589
|
+
return CONVENTIONS_TITLE;
|
|
472
590
|
}
|
|
473
591
|
return null;
|
|
474
592
|
}
|
|
@@ -545,12 +663,66 @@ function ruleBlock(rule, findings, template, dropped, options) {
|
|
|
545
663
|
}
|
|
546
664
|
return out;
|
|
547
665
|
}
|
|
548
|
-
|
|
549
|
-
|
|
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), ""]);
|
|
550
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
|
+
};
|
|
551
721
|
function renderAgentText(delivery, options) {
|
|
552
722
|
const title = header(delivery);
|
|
553
|
-
|
|
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 "";
|
|
554
726
|
const out = [];
|
|
555
727
|
if (title !== null) out.push(title, "");
|
|
556
728
|
const byRule = /* @__PURE__ */ new Map();
|
|
@@ -564,25 +736,23 @@ function renderAgentText(delivery, options) {
|
|
|
564
736
|
out.push("");
|
|
565
737
|
}
|
|
566
738
|
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`);
|
|
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));
|
|
572
742
|
out.push(BACKLOG_HINT, "");
|
|
573
743
|
}
|
|
574
744
|
for (const reference of delivery.references) out.push(...referenceLines(reference));
|
|
575
745
|
if (out.length > 0 && out.at(-1) !== "") out.push("");
|
|
576
|
-
if (
|
|
577
|
-
out.push(
|
|
746
|
+
if (warnings.length > 0) {
|
|
747
|
+
out.push(WARNINGS_HEADING, ...warnings.map(warningLine));
|
|
578
748
|
}
|
|
579
749
|
if (delivery.overflowPath !== null) {
|
|
580
750
|
if (out.at(-1) !== "") out.push("");
|
|
581
|
-
out.push(
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
);
|
|
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));
|
|
586
756
|
}
|
|
587
757
|
while (out.at(-1) === "") out.pop();
|
|
588
758
|
return out.join("\n");
|
|
@@ -759,8 +929,8 @@ function fileOf(spec) {
|
|
|
759
929
|
async function compileRule(input, context) {
|
|
760
930
|
const { data } = input;
|
|
761
931
|
const minimum = data.minimum_rulecast_version;
|
|
762
|
-
if (minimum !== void 0 && isOlder(
|
|
763
|
-
return `requires rulecast ${minimum} or newer (running ${
|
|
932
|
+
if (minimum !== void 0 && isOlder(VERSION, minimum)) {
|
|
933
|
+
return `requires rulecast ${minimum} or newer (running ${VERSION})`;
|
|
764
934
|
}
|
|
765
935
|
let detector = null;
|
|
766
936
|
let defaultStages = ["touch"];
|
|
@@ -769,7 +939,8 @@ async function compileRule(input, context) {
|
|
|
769
939
|
const implementation = context.registry.get(kind);
|
|
770
940
|
if (!implementation) return `unknown detector "${kind}"`;
|
|
771
941
|
const parsed = await implementation.schema.safeParseAsync(rawConfig ?? {});
|
|
772
|
-
if (!parsed.success)
|
|
942
|
+
if (!parsed.success)
|
|
943
|
+
return formatZodError(parsed.error, { prefix: `detect.${kind}`, schema: implementation.schema });
|
|
773
944
|
detector = { kind, config: parsed.data, captures: implementation.captures(parsed.data) };
|
|
774
945
|
defaultStages = implementation.events(parsed.data);
|
|
775
946
|
}
|
|
@@ -832,7 +1003,9 @@ async function compileRule(input, context) {
|
|
|
832
1003
|
severity: data.severity ?? "error",
|
|
833
1004
|
scope: data.scope ?? "instance",
|
|
834
1005
|
refuseWrite: data.refuse_write === true,
|
|
1006
|
+
enabled: data.enabled !== false,
|
|
835
1007
|
stages,
|
|
1008
|
+
patterns: { files: data.files ?? "", exclude: data.exclude ?? "^$" },
|
|
836
1009
|
matches: (file) => global(file) && filter(file),
|
|
837
1010
|
detector,
|
|
838
1011
|
message: data.message ?? null,
|
|
@@ -880,37 +1053,18 @@ function detectorCacheDir(stateDir, kind) {
|
|
|
880
1053
|
return path6.join(stateDir, "cache", kind);
|
|
881
1054
|
}
|
|
882
1055
|
|
|
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
1056
|
export {
|
|
908
1057
|
DeadlineError,
|
|
909
1058
|
isNotFound,
|
|
910
1059
|
errorMessage,
|
|
911
1060
|
formatZodError,
|
|
1061
|
+
VERSION,
|
|
1062
|
+
parseVersion,
|
|
1063
|
+
isOlder,
|
|
1064
|
+
pastDeadline,
|
|
912
1065
|
perRule,
|
|
913
1066
|
readSourceFile,
|
|
1067
|
+
sourceReader,
|
|
914
1068
|
fileBytes,
|
|
915
1069
|
onPath,
|
|
916
1070
|
CORE_VARIABLES,
|
|
@@ -927,11 +1081,8 @@ export {
|
|
|
927
1081
|
LLM_PROVIDERS,
|
|
928
1082
|
defaultDetectorSettings,
|
|
929
1083
|
emptyDelivery,
|
|
930
|
-
VERSION2 as VERSION,
|
|
931
|
-
parseVersion,
|
|
932
|
-
isOlder,
|
|
933
|
-
BACKLOG_HINT,
|
|
934
1084
|
measureRuleBlock,
|
|
1085
|
+
deliveryCost,
|
|
935
1086
|
renderAgentText,
|
|
936
1087
|
tagsOf,
|
|
937
1088
|
compileFilter,
|