@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
|
@@ -8,13 +8,14 @@ import {
|
|
|
8
8
|
lineStarts,
|
|
9
9
|
offsetAt,
|
|
10
10
|
onPath,
|
|
11
|
+
pastDeadline,
|
|
11
12
|
perRule,
|
|
12
13
|
positionAt,
|
|
13
14
|
providerByName,
|
|
14
|
-
readSourceFile,
|
|
15
15
|
renderAgentText,
|
|
16
|
-
resolveModel
|
|
17
|
-
|
|
16
|
+
resolveModel,
|
|
17
|
+
sourceReader
|
|
18
|
+
} from "./chunk-DQUCE2IK.js";
|
|
18
19
|
|
|
19
20
|
// src/core/detection/registry.ts
|
|
20
21
|
function createRegistry(detectors) {
|
|
@@ -29,6 +30,61 @@ function createRegistry(detectors) {
|
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// src/core/detection/tool.ts
|
|
34
|
+
import { execFile } from "child_process";
|
|
35
|
+
import { promisify } from "util";
|
|
36
|
+
var exec = promisify(execFile);
|
|
37
|
+
async function runTool(command, args, options) {
|
|
38
|
+
try {
|
|
39
|
+
const running = exec(command, [...args], {
|
|
40
|
+
cwd: options.cwd,
|
|
41
|
+
signal: options.signal,
|
|
42
|
+
// A whole-repository run of a linter on a large project prints megabytes of JSON.
|
|
43
|
+
maxBuffer: 64 * 1024 * 1024
|
|
44
|
+
});
|
|
45
|
+
running.child.stdin?.on("error", () => {
|
|
46
|
+
});
|
|
47
|
+
if (options.stdin === void 0) running.child.stdin?.end();
|
|
48
|
+
else running.child.stdin?.end(options.stdin);
|
|
49
|
+
const result = await running;
|
|
50
|
+
return result.stdout;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (options.signal.aborted) throw error;
|
|
53
|
+
if (isNotFound(error)) throw new Error(options.notFound);
|
|
54
|
+
const failure = error;
|
|
55
|
+
if (typeof failure.stdout !== "string") throw error;
|
|
56
|
+
return failure.stdout;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/core/detection/paths.ts
|
|
61
|
+
import { realpathSync } from "fs";
|
|
62
|
+
import path from "path";
|
|
63
|
+
import { fileURLToPath } from "url";
|
|
64
|
+
function repoRelativeTo(cwd) {
|
|
65
|
+
const roots = rootsOf(cwd);
|
|
66
|
+
return (file) => {
|
|
67
|
+
const withoutScheme = file.startsWith("file://") ? fileURLToPath(file) : file;
|
|
68
|
+
if (!path.isAbsolute(withoutScheme)) return withoutScheme.split(path.sep).join("/");
|
|
69
|
+
for (const root of roots) {
|
|
70
|
+
const relative = path.relative(root, withoutScheme);
|
|
71
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) return relative.split(path.sep).join("/");
|
|
72
|
+
}
|
|
73
|
+
return path.relative(cwd, withoutScheme).split(path.sep).join("/");
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function repoRelative(file, cwd) {
|
|
77
|
+
return repoRelativeTo(cwd)(file);
|
|
78
|
+
}
|
|
79
|
+
function rootsOf(root) {
|
|
80
|
+
try {
|
|
81
|
+
const real = realpathSync(root);
|
|
82
|
+
return real === root ? [root] : [root, real];
|
|
83
|
+
} catch {
|
|
84
|
+
return [root];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
32
88
|
// src/detectors/ast-grep/languages.ts
|
|
33
89
|
var LANGUAGES = {
|
|
34
90
|
css: { builtin: "Css" },
|
|
@@ -180,7 +236,7 @@ var astGrepDetector = {
|
|
|
180
236
|
if (source === null) continue;
|
|
181
237
|
root = parser.parse(source).root();
|
|
182
238
|
} catch (error) {
|
|
183
|
-
if (input
|
|
239
|
+
if (pastDeadline(error, input)) throw error;
|
|
184
240
|
const message = errorMessage(error);
|
|
185
241
|
for (const rule of rules) {
|
|
186
242
|
if (rule.files.includes(file) && !result.errors.some((existing) => existing.rule === rule.id)) {
|
|
@@ -196,7 +252,7 @@ var astGrepDetector = {
|
|
|
196
252
|
result.findings.push({ rule: rule.id, match: matchOf(file, node, variables.get(rule.id)) });
|
|
197
253
|
}
|
|
198
254
|
} catch (error) {
|
|
199
|
-
if (input
|
|
255
|
+
if (pastDeadline(error, input)) throw error;
|
|
200
256
|
if (!result.errors.some((existing) => existing.rule === rule.id)) {
|
|
201
257
|
result.errors.push({ rule: rule.id, message: errorMessage(error) });
|
|
202
258
|
}
|
|
@@ -215,15 +271,10 @@ var astGrepDetector = {
|
|
|
215
271
|
};
|
|
216
272
|
|
|
217
273
|
// src/detectors/command/detector.ts
|
|
218
|
-
import { execFile } from "child_process";
|
|
219
274
|
import { access, constants } from "fs/promises";
|
|
220
275
|
import path2 from "path";
|
|
221
|
-
import { promisify } from "util";
|
|
222
276
|
|
|
223
277
|
// src/detectors/command/results.ts
|
|
224
|
-
import { realpathSync } from "fs";
|
|
225
|
-
import path from "path";
|
|
226
|
-
import { fileURLToPath } from "url";
|
|
227
278
|
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
228
279
|
function parseJson(output) {
|
|
229
280
|
try {
|
|
@@ -232,23 +283,6 @@ function parseJson(output) {
|
|
|
232
283
|
throw new Error("output is not JSON");
|
|
233
284
|
}
|
|
234
285
|
}
|
|
235
|
-
function repoRelative(file, cwd) {
|
|
236
|
-
const withoutScheme = file.startsWith("file://") ? fileURLToPath(file) : file;
|
|
237
|
-
if (!path.isAbsolute(withoutScheme)) return withoutScheme.split(path.sep).join("/");
|
|
238
|
-
for (const root of rootsOf(cwd)) {
|
|
239
|
-
const relative = path.relative(root, withoutScheme);
|
|
240
|
-
if (!relative.startsWith("..") && !path.isAbsolute(relative)) return relative.split(path.sep).join("/");
|
|
241
|
-
}
|
|
242
|
-
return path.relative(cwd, withoutScheme).split(path.sep).join("/");
|
|
243
|
-
}
|
|
244
|
-
function rootsOf(root) {
|
|
245
|
-
try {
|
|
246
|
-
const real = realpathSync(root);
|
|
247
|
-
return real === root ? [root] : [root, real];
|
|
248
|
-
} catch {
|
|
249
|
-
return [root];
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
286
|
function capturesOf2(source, names, where) {
|
|
253
287
|
const captures = {};
|
|
254
288
|
for (const name of names) {
|
|
@@ -259,6 +293,7 @@ function capturesOf2(source, names, where) {
|
|
|
259
293
|
return captures;
|
|
260
294
|
}
|
|
261
295
|
function matchesFromJson(output, captures, cwd) {
|
|
296
|
+
const relative = repoRelativeTo(cwd);
|
|
262
297
|
const parsed = parseJson(output);
|
|
263
298
|
if (!Array.isArray(parsed)) throw new Error("output is not a JSON array of results");
|
|
264
299
|
return parsed.map((result, index) => {
|
|
@@ -267,7 +302,7 @@ function matchesFromJson(output, captures, cwd) {
|
|
|
267
302
|
if (typeof result.file !== "string") throw new Error(`${where}: "file" must be a string`);
|
|
268
303
|
if (typeof result.line !== "number") throw new Error(`${where}: "line" must be a number`);
|
|
269
304
|
return {
|
|
270
|
-
file:
|
|
305
|
+
file: relative(result.file),
|
|
271
306
|
line: result.line,
|
|
272
307
|
endLine: typeof result.endLine === "number" ? result.endLine : result.line,
|
|
273
308
|
column: typeof result.column === "number" ? result.column : 1,
|
|
@@ -277,6 +312,7 @@ function matchesFromJson(output, captures, cwd) {
|
|
|
277
312
|
});
|
|
278
313
|
}
|
|
279
314
|
function matchesFromSarif(output, captures, cwd) {
|
|
315
|
+
const relative = repoRelativeTo(cwd);
|
|
280
316
|
const parsed = parseJson(output);
|
|
281
317
|
if (!isObject(parsed) || !Array.isArray(parsed.runs)) throw new Error("output is not SARIF 2.1.0");
|
|
282
318
|
const matches = [];
|
|
@@ -296,7 +332,7 @@ function matchesFromSarif(output, captures, cwd) {
|
|
|
296
332
|
const message = isObject(result.message) && typeof result.message.text === "string" ? result.message.text : "";
|
|
297
333
|
const source = isObject(result.properties) ? { ...result, ...result.properties } : result;
|
|
298
334
|
matches.push({
|
|
299
|
-
file:
|
|
335
|
+
file: relative(artifact.uri),
|
|
300
336
|
line,
|
|
301
337
|
endLine: typeof region.endLine === "number" ? region.endLine : line,
|
|
302
338
|
column: typeof region.startColumn === "number" ? region.startColumn : 1,
|
|
@@ -325,7 +361,6 @@ var commandSchema = z2.object({
|
|
|
325
361
|
}).strict();
|
|
326
362
|
|
|
327
363
|
// src/detectors/command/detector.ts
|
|
328
|
-
var exec = promisify(execFile);
|
|
329
364
|
var FILES = "{{files}}";
|
|
330
365
|
function argvFor(run, files) {
|
|
331
366
|
if (!run.includes(FILES)) return [...run, ...files];
|
|
@@ -356,17 +391,11 @@ var commandDetector = {
|
|
|
356
391
|
run: perRule(async (rule, input) => {
|
|
357
392
|
if (rule.files.length === 0) return [];
|
|
358
393
|
const [command, ...rest] = argvFor(rule.config.run, rule.files);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
if (input.signal.aborted) throw error;
|
|
365
|
-
if (isNotFound(error)) throw new Error(`command not found: ${command}`);
|
|
366
|
-
const failure = error;
|
|
367
|
-
if (typeof failure.stdout !== "string") throw error;
|
|
368
|
-
stdout = failure.stdout;
|
|
369
|
-
}
|
|
394
|
+
const stdout = await runTool(command, rest, {
|
|
395
|
+
cwd: input.cwd,
|
|
396
|
+
signal: input.signal,
|
|
397
|
+
notFound: `command not found: ${command}`
|
|
398
|
+
});
|
|
370
399
|
return rule.config.output === "sarif" ? matchesFromSarif(stdout, rule.config.captures, input.cwd) : matchesFromJson(stdout, rule.config.captures, input.cwd);
|
|
371
400
|
}),
|
|
372
401
|
async check(input) {
|
|
@@ -384,10 +413,6 @@ var commandDetector = {
|
|
|
384
413
|
}
|
|
385
414
|
};
|
|
386
415
|
|
|
387
|
-
// src/detectors/linter/detector.ts
|
|
388
|
-
import { execFile as execFile2 } from "child_process";
|
|
389
|
-
import { promisify as promisify2 } from "util";
|
|
390
|
-
|
|
391
416
|
// src/detectors/linter/resolve.ts
|
|
392
417
|
import { access as access2, constants as constants2 } from "fs/promises";
|
|
393
418
|
import path3 from "path";
|
|
@@ -432,8 +457,6 @@ var linterSchema = z3.object({
|
|
|
432
457
|
}).strict();
|
|
433
458
|
|
|
434
459
|
// src/detectors/linter/tools.ts
|
|
435
|
-
import { realpathSync as realpathSync2 } from "fs";
|
|
436
|
-
import path4 from "path";
|
|
437
460
|
var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
438
461
|
function json(tool, stdout) {
|
|
439
462
|
try {
|
|
@@ -442,22 +465,6 @@ function json(tool, stdout) {
|
|
|
442
465
|
throw new Error(`${tool} output is not JSON`);
|
|
443
466
|
}
|
|
444
467
|
}
|
|
445
|
-
function relativeTo(roots, file) {
|
|
446
|
-
if (!path4.isAbsolute(file)) return file.split(path4.sep).join("/");
|
|
447
|
-
for (const root of roots) {
|
|
448
|
-
const relative = path4.relative(root, file);
|
|
449
|
-
if (!relative.startsWith("..") && !path4.isAbsolute(relative)) return relative.split(path4.sep).join("/");
|
|
450
|
-
}
|
|
451
|
-
return path4.relative(roots[0], file).split(path4.sep).join("/");
|
|
452
|
-
}
|
|
453
|
-
function rootsOf2(root) {
|
|
454
|
-
try {
|
|
455
|
-
const real = realpathSync2(root);
|
|
456
|
-
return real === root ? [root] : [root, real];
|
|
457
|
-
} catch {
|
|
458
|
-
return [root];
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
468
|
var number = (value, fallback) => typeof value === "number" ? value : fallback;
|
|
462
469
|
var text = (value) => typeof value === "string" ? value : "";
|
|
463
470
|
function oxlintRuleId(code) {
|
|
@@ -468,16 +475,17 @@ var TOOLS = {
|
|
|
468
475
|
ruff: {
|
|
469
476
|
events: ["edit", "verify"],
|
|
470
477
|
args: (files) => ["check", "--output-format", "json", "--force-exclude", "--", ...files],
|
|
478
|
+
stdinArgs: (file) => ["check", "--output-format", "json", "--force-exclude", "--stdin-filename", file, "-"],
|
|
471
479
|
parse(stdout, root) {
|
|
472
480
|
const parsed = json("ruff", stdout);
|
|
473
481
|
if (!Array.isArray(parsed)) throw new Error("ruff output is not a JSON array of diagnostics");
|
|
474
|
-
const
|
|
482
|
+
const relative = repoRelativeTo(root);
|
|
475
483
|
return parsed.filter(isObject2).map((result) => {
|
|
476
484
|
const start = isObject2(result.location) ? result.location : {};
|
|
477
485
|
const end = isObject2(result.end_location) ? result.end_location : {};
|
|
478
486
|
const line = number(start.row, 1);
|
|
479
487
|
return {
|
|
480
|
-
file:
|
|
488
|
+
file: relative(text(result.filename)),
|
|
481
489
|
line,
|
|
482
490
|
column: number(start.column, 1),
|
|
483
491
|
endLine: number(end.row, line),
|
|
@@ -496,13 +504,13 @@ var TOOLS = {
|
|
|
496
504
|
if (!isObject2(parsed) || !Array.isArray(parsed.diagnostics)) {
|
|
497
505
|
throw new Error("oxlint output is not JSON with diagnostics");
|
|
498
506
|
}
|
|
499
|
-
const
|
|
507
|
+
const relative = repoRelativeTo(root);
|
|
500
508
|
return parsed.diagnostics.filter(isObject2).map((diagnostic) => {
|
|
501
509
|
const label = Array.isArray(diagnostic.labels) ? diagnostic.labels[0] : void 0;
|
|
502
510
|
const span = isObject2(label) && isObject2(label.span) ? label.span : {};
|
|
503
511
|
const line = number(span.line, 1);
|
|
504
512
|
return {
|
|
505
|
-
file:
|
|
513
|
+
file: relative(text(diagnostic.filename)),
|
|
506
514
|
line,
|
|
507
515
|
column: number(span.column, 1),
|
|
508
516
|
endLine: line,
|
|
@@ -517,17 +525,18 @@ var TOOLS = {
|
|
|
517
525
|
// eslint is the slow one: spec §6 keeps it off the edit hook by default.
|
|
518
526
|
events: ["verify"],
|
|
519
527
|
args: (files) => ["--format=json", "--no-error-on-unmatched-pattern", "--", ...files],
|
|
528
|
+
stdinArgs: (file) => ["--format=json", "--stdin", "--stdin-filename", file],
|
|
520
529
|
parse(stdout, root) {
|
|
521
530
|
const parsed = json("eslint", stdout);
|
|
522
531
|
if (!Array.isArray(parsed)) throw new Error("eslint output is not a JSON array of file results");
|
|
523
|
-
const
|
|
532
|
+
const relative = repoRelativeTo(root);
|
|
524
533
|
const findings = [];
|
|
525
534
|
for (const file of parsed.filter(isObject2)) {
|
|
526
535
|
const messages = Array.isArray(file.messages) ? file.messages : [];
|
|
527
536
|
for (const message of messages.filter(isObject2)) {
|
|
528
537
|
const line = number(message.line, 1);
|
|
529
538
|
findings.push({
|
|
530
|
-
file:
|
|
539
|
+
file: relative(text(file.filePath)),
|
|
531
540
|
line,
|
|
532
541
|
column: number(message.column, 1),
|
|
533
542
|
endLine: number(message.endLine, line),
|
|
@@ -543,18 +552,6 @@ var TOOLS = {
|
|
|
543
552
|
};
|
|
544
553
|
|
|
545
554
|
// src/detectors/linter/detector.ts
|
|
546
|
-
var exec2 = promisify2(execFile2);
|
|
547
|
-
function sourceReader(cwd) {
|
|
548
|
-
const cache = /* @__PURE__ */ new Map();
|
|
549
|
-
return (file) => {
|
|
550
|
-
let source = cache.get(file);
|
|
551
|
-
if (source === void 0) {
|
|
552
|
-
source = readSourceFile(cwd, file);
|
|
553
|
-
cache.set(file, source);
|
|
554
|
-
}
|
|
555
|
-
return source;
|
|
556
|
-
};
|
|
557
|
-
}
|
|
558
555
|
function excerpt(source, finding) {
|
|
559
556
|
if (source === null) return "";
|
|
560
557
|
const starts = lineStarts(source);
|
|
@@ -568,41 +565,45 @@ function excerpt(source, finding) {
|
|
|
568
565
|
}
|
|
569
566
|
return source.slice(from, Math.max(from, to)).replace(/\n$/, "");
|
|
570
567
|
}
|
|
571
|
-
async function
|
|
568
|
+
async function runLinter(tool, files, cwd, signal) {
|
|
572
569
|
const resolved = await resolveTool(tool, cwd);
|
|
573
570
|
const args = [...resolved.prefix, ...TOOLS[tool].args(files)];
|
|
574
|
-
|
|
575
|
-
try {
|
|
576
|
-
const result = await exec2(resolved.command, args, { cwd, signal, maxBuffer: 64 * 1024 * 1024 });
|
|
577
|
-
stdout = result.stdout;
|
|
578
|
-
} catch (error) {
|
|
579
|
-
if (signal.aborted) throw error;
|
|
580
|
-
if (isNotFound(error)) throw new Error(`${tool} is not installed`);
|
|
581
|
-
const failure = error;
|
|
582
|
-
if (typeof failure.stdout !== "string") throw error;
|
|
583
|
-
stdout = failure.stdout;
|
|
584
|
-
}
|
|
571
|
+
const stdout = await runTool(resolved.command, args, { cwd, signal, notFound: `${tool} is not installed` });
|
|
585
572
|
return TOOLS[tool].parse(stdout, cwd);
|
|
586
573
|
}
|
|
574
|
+
async function runLinterOnContent(tool, files, read, cwd, signal) {
|
|
575
|
+
const resolved = await resolveTool(tool, cwd);
|
|
576
|
+
const findings = [];
|
|
577
|
+
for (const file of files) {
|
|
578
|
+
const stdin = await read(file);
|
|
579
|
+
if (stdin === null) continue;
|
|
580
|
+
const args = [...resolved.prefix, ...TOOLS[tool].stdinArgs(file)];
|
|
581
|
+
const stdout = await runTool(resolved.command, args, { cwd, signal, notFound: `${tool} is not installed`, stdin });
|
|
582
|
+
findings.push(...TOOLS[tool].parse(stdout, cwd));
|
|
583
|
+
}
|
|
584
|
+
return findings;
|
|
585
|
+
}
|
|
587
586
|
var linterDetector = {
|
|
588
587
|
kind: "linter",
|
|
589
588
|
schema: linterSchema,
|
|
590
589
|
captures: () => ["message", "ruleId"],
|
|
591
590
|
// A copy: compileRule stores this as the rule's stages, and module-level data must not escape into it.
|
|
592
591
|
events: (config) => [...TOOLS[config.tool].events],
|
|
592
|
+
takesContent: (config) => TOOLS[config.tool].stdinArgs !== void 0,
|
|
593
593
|
async run(input) {
|
|
594
594
|
const result = { findings: [], errors: [] };
|
|
595
595
|
const byTool = /* @__PURE__ */ new Map();
|
|
596
596
|
for (const rule of input.rules) {
|
|
597
597
|
byTool.set(rule.config.tool, [...byTool.get(rule.config.tool) ?? [], rule]);
|
|
598
598
|
}
|
|
599
|
-
const
|
|
599
|
+
const onDisk = input.fromDisk !== false;
|
|
600
|
+
const read = onDisk ? sourceReader(input.cwd) : input.read;
|
|
600
601
|
await Promise.all(
|
|
601
602
|
[...byTool].map(async ([tool, rules]) => {
|
|
602
603
|
const files = [...new Set(rules.flatMap((rule) => rule.files))].sort();
|
|
603
604
|
if (files.length === 0) return;
|
|
604
605
|
try {
|
|
605
|
-
const findings = await
|
|
606
|
+
const findings = onDisk ? await runLinter(tool, files, input.cwd, input.signal) : await runLinterOnContent(tool, files, read, input.cwd, input.signal);
|
|
606
607
|
for (const finding of findings) {
|
|
607
608
|
const match = {
|
|
608
609
|
file: finding.file,
|
|
@@ -619,7 +620,7 @@ var linterDetector = {
|
|
|
619
620
|
}
|
|
620
621
|
}
|
|
621
622
|
} catch (error) {
|
|
622
|
-
if (input
|
|
623
|
+
if (pastDeadline(error, input)) throw error;
|
|
623
624
|
const message = errorMessage(error);
|
|
624
625
|
for (const rule of rules) result.errors.push({ rule: rule.id, message });
|
|
625
626
|
}
|
|
@@ -722,17 +723,6 @@ var llmSchema = z4.object({
|
|
|
722
723
|
}).strict();
|
|
723
724
|
|
|
724
725
|
// src/detectors/llm/detector.ts
|
|
725
|
-
function sourceReader2(cwd) {
|
|
726
|
-
const cache = /* @__PURE__ */ new Map();
|
|
727
|
-
return (file) => {
|
|
728
|
-
let source = cache.get(file);
|
|
729
|
-
if (source === void 0) {
|
|
730
|
-
source = readSourceFile(cwd, file);
|
|
731
|
-
cache.set(file, source);
|
|
732
|
-
}
|
|
733
|
-
return source;
|
|
734
|
-
};
|
|
735
|
-
}
|
|
736
726
|
function changedLinesOf(changes, file) {
|
|
737
727
|
const change = changes.get(file);
|
|
738
728
|
if (change === void 0) return null;
|
|
@@ -824,6 +814,15 @@ var llmDetector = {
|
|
|
824
814
|
// verify only by default (spec §6): a model call is far too slow for an edit hook. A rule opts
|
|
825
815
|
// in with `stages: [edit, verify]`.
|
|
826
816
|
events: () => ["verify"],
|
|
817
|
+
// The prompt carries the file's text, so content that is not on disk needs no scratch copy.
|
|
818
|
+
takesContent: () => true,
|
|
819
|
+
// Spec §6, Consent: an llm rule sends file contents to a third party and costs money per file.
|
|
820
|
+
// Everything the core does differently for it follows from these, not from the name "llm".
|
|
821
|
+
metered: true,
|
|
822
|
+
cost: (config) => `llm \xB7 ${config.model} \xB7 sends file contents to your provider`,
|
|
823
|
+
fileBudget: (settings) => ({ max: settings.llm.maxFilesPerVerify, setting: "llm.max_files_per_verify" }),
|
|
824
|
+
// A field trial measured a single haiku call on a 135-line file at 89 s against a 60 s default.
|
|
825
|
+
timeoutHint: "an llm rule on a large file can need 120000 or more",
|
|
827
826
|
async run(input) {
|
|
828
827
|
const result = { findings: [], errors: [] };
|
|
829
828
|
const settings = input.settings.llm;
|
|
@@ -833,13 +832,14 @@ var llmDetector = {
|
|
|
833
832
|
} catch (error) {
|
|
834
833
|
return { findings: [], errors: [{ rule: null, message: errorMessage(error) }] };
|
|
835
834
|
}
|
|
836
|
-
const
|
|
835
|
+
const read = input.fromDisk === false ? input.read : sourceReader(input.cwd);
|
|
836
|
+
const calls = await groupCalls(input.rules, input.changes, read);
|
|
837
837
|
const outcomes = await Promise.all(
|
|
838
838
|
calls.map(async (call) => {
|
|
839
839
|
try {
|
|
840
840
|
return { call, findings: await runCall(call, provider, settings, input) };
|
|
841
841
|
} catch (error) {
|
|
842
|
-
if (input
|
|
842
|
+
if (pastDeadline(error, input)) throw error;
|
|
843
843
|
return { call, error };
|
|
844
844
|
}
|
|
845
845
|
})
|
|
@@ -923,6 +923,8 @@ var pathDetector = {
|
|
|
923
923
|
captures: () => [],
|
|
924
924
|
events: () => ["edit", "verify"],
|
|
925
925
|
guards: true,
|
|
926
|
+
// The match is the file itself, and writing it is what the rule forbids.
|
|
927
|
+
wholeFile: true,
|
|
926
928
|
run: perRule(async (rule, input) => {
|
|
927
929
|
const matches = [];
|
|
928
930
|
for (const file of rule.files) {
|
|
@@ -1162,18 +1164,38 @@ function hooksOf(settings) {
|
|
|
1162
1164
|
if (!isObject3(hooks)) throw new SettingsError('"hooks" must be an object');
|
|
1163
1165
|
return hooks;
|
|
1164
1166
|
}
|
|
1165
|
-
function mergeHooks(settings, command, verifyMs) {
|
|
1167
|
+
function mergeHooks(settings, command, verifyMs, keep) {
|
|
1166
1168
|
const merged = { ...hooksOf(settings) };
|
|
1167
1169
|
const added = [];
|
|
1170
|
+
const updated = [];
|
|
1168
1171
|
for (const { event, matcher: matcher2, timeout } of hookGroups(verifyMs)) {
|
|
1169
1172
|
const groups = merged[event] ?? [];
|
|
1170
1173
|
if (!Array.isArray(groups)) throw new SettingsError(`"hooks.${event}" must be an array`);
|
|
1171
|
-
if (installed(groups, matcher2))
|
|
1174
|
+
if (installed(groups, matcher2)) {
|
|
1175
|
+
let changed = false;
|
|
1176
|
+
const rewritten = groups.map((group) => {
|
|
1177
|
+
if (!isObject3(group) || group.matcher !== matcher2 || !Array.isArray(group.hooks)) return group;
|
|
1178
|
+
return {
|
|
1179
|
+
...group,
|
|
1180
|
+
hooks: group.hooks.map((hook2) => {
|
|
1181
|
+
const current = hook2.command;
|
|
1182
|
+
if (!isRulecastHook(hook2) || current === command || current === keep) return hook2;
|
|
1183
|
+
changed = true;
|
|
1184
|
+
return { ...hook2, command };
|
|
1185
|
+
})
|
|
1186
|
+
};
|
|
1187
|
+
});
|
|
1188
|
+
if (changed) {
|
|
1189
|
+
merged[event] = rewritten;
|
|
1190
|
+
updated.push(matcher2 === void 0 ? event : `${event} (${matcher2})`);
|
|
1191
|
+
}
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1172
1194
|
const hook = { type: "command", command, timeout };
|
|
1173
1195
|
merged[event] = [...groups, matcher2 === void 0 ? { hooks: [hook] } : { matcher: matcher2, hooks: [hook] }];
|
|
1174
1196
|
added.push(matcher2 === void 0 ? event : `${event} (${matcher2})`);
|
|
1175
1197
|
}
|
|
1176
|
-
return { settings: { ...settings, hooks: merged }, added };
|
|
1198
|
+
return { settings: { ...settings, hooks: merged }, added, updated };
|
|
1177
1199
|
}
|
|
1178
1200
|
function removeHooks(settings) {
|
|
1179
1201
|
const hooks = hooksOf(settings);
|
|
@@ -1206,9 +1228,11 @@ function removeHooks(settings) {
|
|
|
1206
1228
|
// src/adapters/claude-code/adapter.ts
|
|
1207
1229
|
var CONTEXT_LIMIT = 1e4;
|
|
1208
1230
|
var CONTEXT_BUDGET = 9e3;
|
|
1209
|
-
var BLOCK_PREAMBLE =
|
|
1231
|
+
var BLOCK_PREAMBLE = `This project's rulecast rules (.rulecast-config.yaml) found problems in code changed in this session. Fix them before you finish. If you believe a finding is wrong, say so to the user rather than changing .rulecast-config.yaml; add a "rulecast-ignore: <rule> <reason>" comment only if they agree.`;
|
|
1210
1232
|
var CAP_PREAMBLE = "rulecast: the agent stopped with these findings unresolved (stop gate limit reached).";
|
|
1211
1233
|
var DENY_PREAMBLE = "This project's rulecast rules (.rulecast-config.yaml) refuse this write. Change what you are writing, or the file you are writing it to, and try again.";
|
|
1234
|
+
var NOT_INSTALLED = "rulecast is configured in this project but not installed. Run npm install (or pnpm install), then rulecast doctor.";
|
|
1235
|
+
var LOCAL_COMMAND = `B="$CLAUDE_PROJECT_DIR"/node_modules/.bin/rulecast; [ -x "$B" ] && exec "$B" hook claude-code; grep -q '"hook_event_name": *"SessionStart"' && printf '%s' '${JSON.stringify({ systemMessage: NOT_INSTALLED })}'; exit 0`;
|
|
1212
1236
|
function cutNotice(delivery, event) {
|
|
1213
1237
|
if (delivery.overflowPath !== null) {
|
|
1214
1238
|
return `
|
|
@@ -1238,8 +1262,12 @@ var claudeCodeAdapter = {
|
|
|
1238
1262
|
restoredFiles: 5,
|
|
1239
1263
|
parse: parseClaudeCode,
|
|
1240
1264
|
format(delivery, event, options) {
|
|
1241
|
-
const text2 = renderAgentText(delivery, options);
|
|
1242
|
-
|
|
1265
|
+
const text2 = renderAgentText(delivery, { ...options, notices: false, warnings: false });
|
|
1266
|
+
const problems = delivery.warnings.length === 0 ? [] : ["rulecast problems:", ...delivery.warnings.map((w) => ` - ${w}`)];
|
|
1267
|
+
const notices = [...delivery.notices ?? [], ...problems].join("\n");
|
|
1268
|
+
const userOnly = event.kind === "guard" ? NONE : json2({ systemMessage: notices });
|
|
1269
|
+
if (text2 === "") return notices === "" ? NONE : userOnly;
|
|
1270
|
+
const withNotices = (value) => notices === "" ? value : { ...value, systemMessage: notices };
|
|
1243
1271
|
switch (event.kind) {
|
|
1244
1272
|
case "guard":
|
|
1245
1273
|
return json2({
|
|
@@ -1253,23 +1281,36 @@ ${text2}`, delivery, event)
|
|
|
1253
1281
|
});
|
|
1254
1282
|
case "touch":
|
|
1255
1283
|
case "edit":
|
|
1256
|
-
return json2(
|
|
1257
|
-
|
|
1258
|
-
|
|
1284
|
+
return json2(
|
|
1285
|
+
withNotices({
|
|
1286
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: withinLimit(text2, delivery, event) }
|
|
1287
|
+
})
|
|
1288
|
+
);
|
|
1259
1289
|
case "reset":
|
|
1260
|
-
return json2(
|
|
1261
|
-
|
|
1262
|
-
|
|
1290
|
+
return json2(
|
|
1291
|
+
withNotices({
|
|
1292
|
+
hookSpecificOutput: {
|
|
1293
|
+
hookEventName: "SessionStart",
|
|
1294
|
+
additionalContext: withinLimit(text2, delivery, event)
|
|
1295
|
+
}
|
|
1296
|
+
})
|
|
1297
|
+
);
|
|
1263
1298
|
case "verify":
|
|
1264
1299
|
if (delivery.stop === "block")
|
|
1265
|
-
return json2(
|
|
1300
|
+
return json2(
|
|
1301
|
+
withNotices({ decision: "block", reason: withinLimit(`${BLOCK_PREAMBLE}
|
|
1266
1302
|
|
|
1267
|
-
${text2}`, delivery, event) })
|
|
1268
|
-
|
|
1269
|
-
|
|
1303
|
+
${text2}`, delivery, event) })
|
|
1304
|
+
);
|
|
1305
|
+
if (delivery.stop === "capReached") {
|
|
1306
|
+
const cap = withinLimit(`${CAP_PREAMBLE}
|
|
1270
1307
|
|
|
1271
|
-
${text2}`, delivery, event)
|
|
1272
|
-
|
|
1308
|
+
${text2}`, delivery, event);
|
|
1309
|
+
return json2({ systemMessage: notices === "" ? cap : `${notices}
|
|
1310
|
+
|
|
1311
|
+
${cap}` });
|
|
1312
|
+
}
|
|
1313
|
+
return notices === "" ? NONE : json2({ systemMessage: notices });
|
|
1273
1314
|
default:
|
|
1274
1315
|
return NONE;
|
|
1275
1316
|
}
|
|
@@ -1280,8 +1321,11 @@ ${text2}`, delivery, event) });
|
|
|
1280
1321
|
{ scope: "shared", file: ".claude/settings.json" },
|
|
1281
1322
|
{ scope: "personal", file: ".claude/settings.local.json" }
|
|
1282
1323
|
],
|
|
1283
|
-
command: (local) => local ?
|
|
1284
|
-
|
|
1324
|
+
command: (local) => local ? LOCAL_COMMAND : "rulecast hook claude-code",
|
|
1325
|
+
// The guarded command is never rewritten to the bare one. It is right with or without
|
|
1326
|
+
// node_modules, and `install` in a clone that has not installed yet would otherwise swap the
|
|
1327
|
+
// committed command for one that exits 127 on every hook.
|
|
1328
|
+
merge: (settings, command, verifyMs) => mergeHooks(settings, command, verifyMs, LOCAL_COMMAND),
|
|
1285
1329
|
remove: removeHooks
|
|
1286
1330
|
}
|
|
1287
1331
|
};
|
|
@@ -1292,10 +1336,74 @@ function adapterByName(name) {
|
|
|
1292
1336
|
return ADAPTERS.find((adapter) => adapter.name === name);
|
|
1293
1337
|
}
|
|
1294
1338
|
|
|
1339
|
+
// src/adapters/cli/backlog.ts
|
|
1340
|
+
function summarise(delivery) {
|
|
1341
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
1342
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1343
|
+
const add = (rule, file, count) => {
|
|
1344
|
+
const entry = byRule.get(rule) ?? { violations: 0, files: /* @__PURE__ */ new Set() };
|
|
1345
|
+
entry.violations += count;
|
|
1346
|
+
entry.files.add(file);
|
|
1347
|
+
byRule.set(rule, entry);
|
|
1348
|
+
byFile.set(file, (byFile.get(file) ?? 0) + count);
|
|
1349
|
+
};
|
|
1350
|
+
for (const finding of delivery.findings) add(finding.rule, finding.file, finding.count);
|
|
1351
|
+
for (const summary of delivery.preexistingSummary) add(summary.rule, summary.file, summary.count);
|
|
1352
|
+
const rules = [...byRule].map(([rule, entry]) => ({ rule, violations: entry.violations, files: entry.files.size })).sort((a, b) => b.violations - a.violations || (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0));
|
|
1353
|
+
const files = [...byFile].map(([file, violations]) => ({ file, violations })).sort((a, b) => b.violations - a.violations || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
1354
|
+
return {
|
|
1355
|
+
rules,
|
|
1356
|
+
files,
|
|
1357
|
+
totalViolations: rules.reduce((sum, entry) => sum + entry.violations, 0),
|
|
1358
|
+
totalFiles: files.length
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
1362
|
+
function table(header, rows) {
|
|
1363
|
+
const widths = header.map((_, column) => Math.max(...[header, ...rows].map((row) => row[column].length)));
|
|
1364
|
+
const line = (row) => ` ${row.map((cell, column) => column === 0 ? cell.padEnd(widths[column]) : cell.padStart(widths[column])).join(" ")}`;
|
|
1365
|
+
return [line(header), ...rows.map(line)];
|
|
1366
|
+
}
|
|
1367
|
+
function renderBacklog(summary, options) {
|
|
1368
|
+
if (summary.totalViolations === 0) return "backlog: no violations";
|
|
1369
|
+
const out = [
|
|
1370
|
+
`backlog: ${plural(summary.totalViolations, "violation")} in ${plural(summary.totalFiles, "file")}`,
|
|
1371
|
+
"",
|
|
1372
|
+
...table(
|
|
1373
|
+
["rule", "violations", "files"],
|
|
1374
|
+
summary.rules.map((entry) => [entry.rule, String(entry.violations), String(entry.files)])
|
|
1375
|
+
)
|
|
1376
|
+
];
|
|
1377
|
+
const shown = summary.files.slice(0, options.topFiles);
|
|
1378
|
+
if (shown.length > 0) {
|
|
1379
|
+
out.push(
|
|
1380
|
+
"",
|
|
1381
|
+
...table(
|
|
1382
|
+
["worst files", "violations"],
|
|
1383
|
+
shown.map((entry) => [entry.file, String(entry.violations)])
|
|
1384
|
+
)
|
|
1385
|
+
);
|
|
1386
|
+
const rest = summary.files.length - shown.length;
|
|
1387
|
+
if (rest > 0) out.push(` \u2026and ${plural(rest, "more file")}`);
|
|
1388
|
+
}
|
|
1389
|
+
if (options.note === false) return out.join("\n");
|
|
1390
|
+
out.push(
|
|
1391
|
+
"",
|
|
1392
|
+
"This is the stock, not a rate. Enforcement on edits does not reduce it.",
|
|
1393
|
+
"Remediate a file at a time: a file that already follows a rule rarely breaks it."
|
|
1394
|
+
);
|
|
1395
|
+
return out.join("\n");
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1295
1398
|
export {
|
|
1296
1399
|
createRegistry,
|
|
1400
|
+
runTool,
|
|
1401
|
+
repoRelativeTo,
|
|
1402
|
+
repoRelative,
|
|
1297
1403
|
builtinDetectors,
|
|
1298
1404
|
claudeCodeAdapter,
|
|
1299
1405
|
ADAPTERS,
|
|
1300
|
-
adapterByName
|
|
1406
|
+
adapterByName,
|
|
1407
|
+
summarise,
|
|
1408
|
+
renderBacklog
|
|
1301
1409
|
};
|