@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
|
@@ -3,18 +3,20 @@ import {
|
|
|
3
3
|
DeadlineError,
|
|
4
4
|
LlmUnavailableError,
|
|
5
5
|
MODEL_ALIASES,
|
|
6
|
+
emptyDelivery,
|
|
6
7
|
errorMessage,
|
|
7
8
|
isNotFound,
|
|
8
9
|
lineStarts,
|
|
9
10
|
offsetAt,
|
|
10
11
|
onPath,
|
|
12
|
+
pastDeadline,
|
|
11
13
|
perRule,
|
|
12
14
|
positionAt,
|
|
13
15
|
providerByName,
|
|
14
|
-
readSourceFile,
|
|
15
16
|
renderAgentText,
|
|
16
|
-
resolveModel
|
|
17
|
-
|
|
17
|
+
resolveModel,
|
|
18
|
+
sourceReader
|
|
19
|
+
} from "./chunk-4XS34SYW.js";
|
|
18
20
|
|
|
19
21
|
// src/core/detection/registry.ts
|
|
20
22
|
function createRegistry(detectors) {
|
|
@@ -29,6 +31,61 @@ function createRegistry(detectors) {
|
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
// src/core/detection/tool.ts
|
|
35
|
+
import { execFile } from "child_process";
|
|
36
|
+
import { promisify } from "util";
|
|
37
|
+
var exec = promisify(execFile);
|
|
38
|
+
async function runTool(command, args, options) {
|
|
39
|
+
try {
|
|
40
|
+
const running = exec(command, [...args], {
|
|
41
|
+
cwd: options.cwd,
|
|
42
|
+
signal: options.signal,
|
|
43
|
+
// A whole-repository run of a linter on a large project prints megabytes of JSON.
|
|
44
|
+
maxBuffer: 64 * 1024 * 1024
|
|
45
|
+
});
|
|
46
|
+
running.child.stdin?.on("error", () => {
|
|
47
|
+
});
|
|
48
|
+
if (options.stdin === void 0) running.child.stdin?.end();
|
|
49
|
+
else running.child.stdin?.end(options.stdin);
|
|
50
|
+
const result = await running;
|
|
51
|
+
return result.stdout;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (options.signal.aborted) throw error;
|
|
54
|
+
if (isNotFound(error)) throw new Error(options.notFound);
|
|
55
|
+
const failure = error;
|
|
56
|
+
if (typeof failure.stdout !== "string") throw error;
|
|
57
|
+
return failure.stdout;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/core/detection/paths.ts
|
|
62
|
+
import { realpathSync } from "fs";
|
|
63
|
+
import path from "path";
|
|
64
|
+
import { fileURLToPath } from "url";
|
|
65
|
+
function repoRelativeTo(cwd) {
|
|
66
|
+
const roots = rootsOf(cwd);
|
|
67
|
+
return (file) => {
|
|
68
|
+
const withoutScheme = file.startsWith("file://") ? fileURLToPath(file) : file;
|
|
69
|
+
if (!path.isAbsolute(withoutScheme)) return withoutScheme.split(path.sep).join("/");
|
|
70
|
+
for (const root of roots) {
|
|
71
|
+
const relative = path.relative(root, withoutScheme);
|
|
72
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) return relative.split(path.sep).join("/");
|
|
73
|
+
}
|
|
74
|
+
return path.relative(cwd, withoutScheme).split(path.sep).join("/");
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function repoRelative(file, cwd) {
|
|
78
|
+
return repoRelativeTo(cwd)(file);
|
|
79
|
+
}
|
|
80
|
+
function rootsOf(root) {
|
|
81
|
+
try {
|
|
82
|
+
const real = realpathSync(root);
|
|
83
|
+
return real === root ? [root] : [root, real];
|
|
84
|
+
} catch {
|
|
85
|
+
return [root];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
32
89
|
// src/detectors/ast-grep/languages.ts
|
|
33
90
|
var LANGUAGES = {
|
|
34
91
|
css: { builtin: "Css" },
|
|
@@ -180,7 +237,7 @@ var astGrepDetector = {
|
|
|
180
237
|
if (source === null) continue;
|
|
181
238
|
root = parser.parse(source).root();
|
|
182
239
|
} catch (error) {
|
|
183
|
-
if (input
|
|
240
|
+
if (pastDeadline(error, input)) throw error;
|
|
184
241
|
const message = errorMessage(error);
|
|
185
242
|
for (const rule of rules) {
|
|
186
243
|
if (rule.files.includes(file) && !result.errors.some((existing) => existing.rule === rule.id)) {
|
|
@@ -196,7 +253,7 @@ var astGrepDetector = {
|
|
|
196
253
|
result.findings.push({ rule: rule.id, match: matchOf(file, node, variables.get(rule.id)) });
|
|
197
254
|
}
|
|
198
255
|
} catch (error) {
|
|
199
|
-
if (input
|
|
256
|
+
if (pastDeadline(error, input)) throw error;
|
|
200
257
|
if (!result.errors.some((existing) => existing.rule === rule.id)) {
|
|
201
258
|
result.errors.push({ rule: rule.id, message: errorMessage(error) });
|
|
202
259
|
}
|
|
@@ -215,15 +272,10 @@ var astGrepDetector = {
|
|
|
215
272
|
};
|
|
216
273
|
|
|
217
274
|
// src/detectors/command/detector.ts
|
|
218
|
-
import { execFile } from "child_process";
|
|
219
275
|
import { access, constants } from "fs/promises";
|
|
220
276
|
import path2 from "path";
|
|
221
|
-
import { promisify } from "util";
|
|
222
277
|
|
|
223
278
|
// src/detectors/command/results.ts
|
|
224
|
-
import { realpathSync } from "fs";
|
|
225
|
-
import path from "path";
|
|
226
|
-
import { fileURLToPath } from "url";
|
|
227
279
|
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
228
280
|
function parseJson(output) {
|
|
229
281
|
try {
|
|
@@ -232,23 +284,6 @@ function parseJson(output) {
|
|
|
232
284
|
throw new Error("output is not JSON");
|
|
233
285
|
}
|
|
234
286
|
}
|
|
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
287
|
function capturesOf2(source, names, where) {
|
|
253
288
|
const captures = {};
|
|
254
289
|
for (const name of names) {
|
|
@@ -259,6 +294,7 @@ function capturesOf2(source, names, where) {
|
|
|
259
294
|
return captures;
|
|
260
295
|
}
|
|
261
296
|
function matchesFromJson(output, captures, cwd) {
|
|
297
|
+
const relative = repoRelativeTo(cwd);
|
|
262
298
|
const parsed = parseJson(output);
|
|
263
299
|
if (!Array.isArray(parsed)) throw new Error("output is not a JSON array of results");
|
|
264
300
|
return parsed.map((result, index) => {
|
|
@@ -267,7 +303,7 @@ function matchesFromJson(output, captures, cwd) {
|
|
|
267
303
|
if (typeof result.file !== "string") throw new Error(`${where}: "file" must be a string`);
|
|
268
304
|
if (typeof result.line !== "number") throw new Error(`${where}: "line" must be a number`);
|
|
269
305
|
return {
|
|
270
|
-
file:
|
|
306
|
+
file: relative(result.file),
|
|
271
307
|
line: result.line,
|
|
272
308
|
endLine: typeof result.endLine === "number" ? result.endLine : result.line,
|
|
273
309
|
column: typeof result.column === "number" ? result.column : 1,
|
|
@@ -277,6 +313,7 @@ function matchesFromJson(output, captures, cwd) {
|
|
|
277
313
|
});
|
|
278
314
|
}
|
|
279
315
|
function matchesFromSarif(output, captures, cwd) {
|
|
316
|
+
const relative = repoRelativeTo(cwd);
|
|
280
317
|
const parsed = parseJson(output);
|
|
281
318
|
if (!isObject(parsed) || !Array.isArray(parsed.runs)) throw new Error("output is not SARIF 2.1.0");
|
|
282
319
|
const matches = [];
|
|
@@ -296,7 +333,7 @@ function matchesFromSarif(output, captures, cwd) {
|
|
|
296
333
|
const message = isObject(result.message) && typeof result.message.text === "string" ? result.message.text : "";
|
|
297
334
|
const source = isObject(result.properties) ? { ...result, ...result.properties } : result;
|
|
298
335
|
matches.push({
|
|
299
|
-
file:
|
|
336
|
+
file: relative(artifact.uri),
|
|
300
337
|
line,
|
|
301
338
|
endLine: typeof region.endLine === "number" ? region.endLine : line,
|
|
302
339
|
column: typeof region.startColumn === "number" ? region.startColumn : 1,
|
|
@@ -325,7 +362,6 @@ var commandSchema = z2.object({
|
|
|
325
362
|
}).strict();
|
|
326
363
|
|
|
327
364
|
// src/detectors/command/detector.ts
|
|
328
|
-
var exec = promisify(execFile);
|
|
329
365
|
var FILES = "{{files}}";
|
|
330
366
|
function argvFor(run, files) {
|
|
331
367
|
if (!run.includes(FILES)) return [...run, ...files];
|
|
@@ -356,17 +392,11 @@ var commandDetector = {
|
|
|
356
392
|
run: perRule(async (rule, input) => {
|
|
357
393
|
if (rule.files.length === 0) return [];
|
|
358
394
|
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
|
-
}
|
|
395
|
+
const stdout = await runTool(command, rest, {
|
|
396
|
+
cwd: input.cwd,
|
|
397
|
+
signal: input.signal,
|
|
398
|
+
notFound: `command not found: ${command}`
|
|
399
|
+
});
|
|
370
400
|
return rule.config.output === "sarif" ? matchesFromSarif(stdout, rule.config.captures, input.cwd) : matchesFromJson(stdout, rule.config.captures, input.cwd);
|
|
371
401
|
}),
|
|
372
402
|
async check(input) {
|
|
@@ -384,10 +414,6 @@ var commandDetector = {
|
|
|
384
414
|
}
|
|
385
415
|
};
|
|
386
416
|
|
|
387
|
-
// src/detectors/linter/detector.ts
|
|
388
|
-
import { execFile as execFile2 } from "child_process";
|
|
389
|
-
import { promisify as promisify2 } from "util";
|
|
390
|
-
|
|
391
417
|
// src/detectors/linter/resolve.ts
|
|
392
418
|
import { access as access2, constants as constants2 } from "fs/promises";
|
|
393
419
|
import path3 from "path";
|
|
@@ -432,8 +458,6 @@ var linterSchema = z3.object({
|
|
|
432
458
|
}).strict();
|
|
433
459
|
|
|
434
460
|
// src/detectors/linter/tools.ts
|
|
435
|
-
import { realpathSync as realpathSync2 } from "fs";
|
|
436
|
-
import path4 from "path";
|
|
437
461
|
var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
438
462
|
function json(tool, stdout) {
|
|
439
463
|
try {
|
|
@@ -442,22 +466,6 @@ function json(tool, stdout) {
|
|
|
442
466
|
throw new Error(`${tool} output is not JSON`);
|
|
443
467
|
}
|
|
444
468
|
}
|
|
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
469
|
var number = (value, fallback) => typeof value === "number" ? value : fallback;
|
|
462
470
|
var text = (value) => typeof value === "string" ? value : "";
|
|
463
471
|
function oxlintRuleId(code) {
|
|
@@ -468,16 +476,17 @@ var TOOLS = {
|
|
|
468
476
|
ruff: {
|
|
469
477
|
events: ["edit", "verify"],
|
|
470
478
|
args: (files) => ["check", "--output-format", "json", "--force-exclude", "--", ...files],
|
|
479
|
+
stdinArgs: (file) => ["check", "--output-format", "json", "--force-exclude", "--stdin-filename", file, "-"],
|
|
471
480
|
parse(stdout, root) {
|
|
472
481
|
const parsed = json("ruff", stdout);
|
|
473
482
|
if (!Array.isArray(parsed)) throw new Error("ruff output is not a JSON array of diagnostics");
|
|
474
|
-
const
|
|
483
|
+
const relative = repoRelativeTo(root);
|
|
475
484
|
return parsed.filter(isObject2).map((result) => {
|
|
476
485
|
const start = isObject2(result.location) ? result.location : {};
|
|
477
486
|
const end = isObject2(result.end_location) ? result.end_location : {};
|
|
478
487
|
const line = number(start.row, 1);
|
|
479
488
|
return {
|
|
480
|
-
file:
|
|
489
|
+
file: relative(text(result.filename)),
|
|
481
490
|
line,
|
|
482
491
|
column: number(start.column, 1),
|
|
483
492
|
endLine: number(end.row, line),
|
|
@@ -496,13 +505,13 @@ var TOOLS = {
|
|
|
496
505
|
if (!isObject2(parsed) || !Array.isArray(parsed.diagnostics)) {
|
|
497
506
|
throw new Error("oxlint output is not JSON with diagnostics");
|
|
498
507
|
}
|
|
499
|
-
const
|
|
508
|
+
const relative = repoRelativeTo(root);
|
|
500
509
|
return parsed.diagnostics.filter(isObject2).map((diagnostic) => {
|
|
501
510
|
const label = Array.isArray(diagnostic.labels) ? diagnostic.labels[0] : void 0;
|
|
502
511
|
const span = isObject2(label) && isObject2(label.span) ? label.span : {};
|
|
503
512
|
const line = number(span.line, 1);
|
|
504
513
|
return {
|
|
505
|
-
file:
|
|
514
|
+
file: relative(text(diagnostic.filename)),
|
|
506
515
|
line,
|
|
507
516
|
column: number(span.column, 1),
|
|
508
517
|
endLine: line,
|
|
@@ -517,17 +526,18 @@ var TOOLS = {
|
|
|
517
526
|
// eslint is the slow one: spec §6 keeps it off the edit hook by default.
|
|
518
527
|
events: ["verify"],
|
|
519
528
|
args: (files) => ["--format=json", "--no-error-on-unmatched-pattern", "--", ...files],
|
|
529
|
+
stdinArgs: (file) => ["--format=json", "--stdin", "--stdin-filename", file],
|
|
520
530
|
parse(stdout, root) {
|
|
521
531
|
const parsed = json("eslint", stdout);
|
|
522
532
|
if (!Array.isArray(parsed)) throw new Error("eslint output is not a JSON array of file results");
|
|
523
|
-
const
|
|
533
|
+
const relative = repoRelativeTo(root);
|
|
524
534
|
const findings = [];
|
|
525
535
|
for (const file of parsed.filter(isObject2)) {
|
|
526
536
|
const messages = Array.isArray(file.messages) ? file.messages : [];
|
|
527
537
|
for (const message of messages.filter(isObject2)) {
|
|
528
538
|
const line = number(message.line, 1);
|
|
529
539
|
findings.push({
|
|
530
|
-
file:
|
|
540
|
+
file: relative(text(file.filePath)),
|
|
531
541
|
line,
|
|
532
542
|
column: number(message.column, 1),
|
|
533
543
|
endLine: number(message.endLine, line),
|
|
@@ -543,18 +553,6 @@ var TOOLS = {
|
|
|
543
553
|
};
|
|
544
554
|
|
|
545
555
|
// 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
556
|
function excerpt(source, finding) {
|
|
559
557
|
if (source === null) return "";
|
|
560
558
|
const starts = lineStarts(source);
|
|
@@ -568,41 +566,45 @@ function excerpt(source, finding) {
|
|
|
568
566
|
}
|
|
569
567
|
return source.slice(from, Math.max(from, to)).replace(/\n$/, "");
|
|
570
568
|
}
|
|
571
|
-
async function
|
|
569
|
+
async function runLinter(tool, files, cwd, signal) {
|
|
572
570
|
const resolved = await resolveTool(tool, cwd);
|
|
573
571
|
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
|
-
}
|
|
572
|
+
const stdout = await runTool(resolved.command, args, { cwd, signal, notFound: `${tool} is not installed` });
|
|
585
573
|
return TOOLS[tool].parse(stdout, cwd);
|
|
586
574
|
}
|
|
575
|
+
async function runLinterOnContent(tool, files, read, cwd, signal) {
|
|
576
|
+
const resolved = await resolveTool(tool, cwd);
|
|
577
|
+
const findings = [];
|
|
578
|
+
for (const file of files) {
|
|
579
|
+
const stdin = await read(file);
|
|
580
|
+
if (stdin === null) continue;
|
|
581
|
+
const args = [...resolved.prefix, ...TOOLS[tool].stdinArgs(file)];
|
|
582
|
+
const stdout = await runTool(resolved.command, args, { cwd, signal, notFound: `${tool} is not installed`, stdin });
|
|
583
|
+
findings.push(...TOOLS[tool].parse(stdout, cwd));
|
|
584
|
+
}
|
|
585
|
+
return findings;
|
|
586
|
+
}
|
|
587
587
|
var linterDetector = {
|
|
588
588
|
kind: "linter",
|
|
589
589
|
schema: linterSchema,
|
|
590
590
|
captures: () => ["message", "ruleId"],
|
|
591
591
|
// A copy: compileRule stores this as the rule's stages, and module-level data must not escape into it.
|
|
592
592
|
events: (config) => [...TOOLS[config.tool].events],
|
|
593
|
+
takesContent: (config) => TOOLS[config.tool].stdinArgs !== void 0,
|
|
593
594
|
async run(input) {
|
|
594
595
|
const result = { findings: [], errors: [] };
|
|
595
596
|
const byTool = /* @__PURE__ */ new Map();
|
|
596
597
|
for (const rule of input.rules) {
|
|
597
598
|
byTool.set(rule.config.tool, [...byTool.get(rule.config.tool) ?? [], rule]);
|
|
598
599
|
}
|
|
599
|
-
const
|
|
600
|
+
const onDisk = input.fromDisk !== false;
|
|
601
|
+
const read = onDisk ? sourceReader(input.cwd) : input.read;
|
|
600
602
|
await Promise.all(
|
|
601
603
|
[...byTool].map(async ([tool, rules]) => {
|
|
602
604
|
const files = [...new Set(rules.flatMap((rule) => rule.files))].sort();
|
|
603
605
|
if (files.length === 0) return;
|
|
604
606
|
try {
|
|
605
|
-
const findings = await
|
|
607
|
+
const findings = onDisk ? await runLinter(tool, files, input.cwd, input.signal) : await runLinterOnContent(tool, files, read, input.cwd, input.signal);
|
|
606
608
|
for (const finding of findings) {
|
|
607
609
|
const match = {
|
|
608
610
|
file: finding.file,
|
|
@@ -619,7 +621,7 @@ var linterDetector = {
|
|
|
619
621
|
}
|
|
620
622
|
}
|
|
621
623
|
} catch (error) {
|
|
622
|
-
if (input
|
|
624
|
+
if (pastDeadline(error, input)) throw error;
|
|
623
625
|
const message = errorMessage(error);
|
|
624
626
|
for (const rule of rules) result.errors.push({ rule: rule.id, message });
|
|
625
627
|
}
|
|
@@ -722,17 +724,6 @@ var llmSchema = z4.object({
|
|
|
722
724
|
}).strict();
|
|
723
725
|
|
|
724
726
|
// 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
727
|
function changedLinesOf(changes, file) {
|
|
737
728
|
const change = changes.get(file);
|
|
738
729
|
if (change === void 0) return null;
|
|
@@ -824,6 +815,15 @@ var llmDetector = {
|
|
|
824
815
|
// verify only by default (spec §6): a model call is far too slow for an edit hook. A rule opts
|
|
825
816
|
// in with `stages: [edit, verify]`.
|
|
826
817
|
events: () => ["verify"],
|
|
818
|
+
// The prompt carries the file's text, so content that is not on disk needs no scratch copy.
|
|
819
|
+
takesContent: () => true,
|
|
820
|
+
// Spec §6, Consent: an llm rule sends file contents to a third party and costs money per file.
|
|
821
|
+
// Everything the core does differently for it follows from these, not from the name "llm".
|
|
822
|
+
metered: true,
|
|
823
|
+
cost: (config) => `llm \xB7 ${config.model} \xB7 sends file contents to your provider`,
|
|
824
|
+
fileBudget: (settings) => ({ max: settings.llm.maxFilesPerVerify, setting: "llm.max_files_per_verify" }),
|
|
825
|
+
// A field trial measured a single haiku call on a 135-line file at 89 s against a 60 s default.
|
|
826
|
+
timeoutHint: "an llm rule on a large file can need 120000 or more",
|
|
827
827
|
async run(input) {
|
|
828
828
|
const result = { findings: [], errors: [] };
|
|
829
829
|
const settings = input.settings.llm;
|
|
@@ -833,13 +833,14 @@ var llmDetector = {
|
|
|
833
833
|
} catch (error) {
|
|
834
834
|
return { findings: [], errors: [{ rule: null, message: errorMessage(error) }] };
|
|
835
835
|
}
|
|
836
|
-
const
|
|
836
|
+
const read = input.fromDisk === false ? input.read : sourceReader(input.cwd);
|
|
837
|
+
const calls = await groupCalls(input.rules, input.changes, read);
|
|
837
838
|
const outcomes = await Promise.all(
|
|
838
839
|
calls.map(async (call) => {
|
|
839
840
|
try {
|
|
840
841
|
return { call, findings: await runCall(call, provider, settings, input) };
|
|
841
842
|
} catch (error) {
|
|
842
|
-
if (input
|
|
843
|
+
if (pastDeadline(error, input)) throw error;
|
|
843
844
|
return { call, error };
|
|
844
845
|
}
|
|
845
846
|
})
|
|
@@ -923,6 +924,8 @@ var pathDetector = {
|
|
|
923
924
|
captures: () => [],
|
|
924
925
|
events: () => ["edit", "verify"],
|
|
925
926
|
guards: true,
|
|
927
|
+
// The match is the file itself, and writing it is what the rule forbids.
|
|
928
|
+
wholeFile: true,
|
|
926
929
|
run: perRule(async (rule, input) => {
|
|
927
930
|
const matches = [];
|
|
928
931
|
for (const file of rule.files) {
|
|
@@ -1046,7 +1049,9 @@ var common = z7.object({
|
|
|
1046
1049
|
agent_id: z7.string().min(1).optional(),
|
|
1047
1050
|
agent_type: z7.string().optional(),
|
|
1048
1051
|
source: z7.string().optional(),
|
|
1049
|
-
prompt: z7.string().optional()
|
|
1052
|
+
prompt: z7.string().optional(),
|
|
1053
|
+
tool_name: z7.string().optional(),
|
|
1054
|
+
tool_use_id: z7.string().min(1).optional()
|
|
1050
1055
|
});
|
|
1051
1056
|
var fileTool = z7.object({
|
|
1052
1057
|
tool_name: z7.string(),
|
|
@@ -1076,7 +1081,9 @@ function parseClaudeCode(input) {
|
|
|
1076
1081
|
agent_id: agentId,
|
|
1077
1082
|
agent_type: agentType,
|
|
1078
1083
|
source,
|
|
1079
|
-
prompt
|
|
1084
|
+
prompt,
|
|
1085
|
+
tool_name: toolName,
|
|
1086
|
+
tool_use_id: toolUseId
|
|
1080
1087
|
} = head.data;
|
|
1081
1088
|
const session = agentId === void 0 ? { id } : { id, agentId };
|
|
1082
1089
|
const result = (kind, files = [], completeRead) => ({
|
|
@@ -1084,6 +1091,23 @@ function parseClaudeCode(input) {
|
|
|
1084
1091
|
event: kind === null ? null : { kind, files, cwd, session, ...completeRead === void 0 ? {} : { completeRead } },
|
|
1085
1092
|
warmup: false
|
|
1086
1093
|
});
|
|
1094
|
+
const shell = (kind, failed = false) => ({
|
|
1095
|
+
cwd,
|
|
1096
|
+
event: {
|
|
1097
|
+
kind,
|
|
1098
|
+
files: [],
|
|
1099
|
+
cwd,
|
|
1100
|
+
session,
|
|
1101
|
+
...toolUseId === void 0 ? {} : { toolUseId },
|
|
1102
|
+
...failed ? { failed } : {}
|
|
1103
|
+
},
|
|
1104
|
+
warmup: false
|
|
1105
|
+
});
|
|
1106
|
+
if (toolName === "Bash") {
|
|
1107
|
+
if (hook === "PreToolUse") return shell("shell-before");
|
|
1108
|
+
if (hook === "PostToolUse") return shell("shell-after");
|
|
1109
|
+
if (hook === "PostToolUseFailure") return shell("shell-after", true);
|
|
1110
|
+
}
|
|
1087
1111
|
switch (hook) {
|
|
1088
1112
|
case "PreToolUse": {
|
|
1089
1113
|
const tool = fileTool.safeParse(input);
|
|
@@ -1125,7 +1149,9 @@ function parseClaudeCode(input) {
|
|
|
1125
1149
|
return result(prompt?.startsWith("<task-notification>") ? null : "prompt");
|
|
1126
1150
|
case "SessionStart":
|
|
1127
1151
|
if (source === "compact") return result("reset");
|
|
1128
|
-
|
|
1152
|
+
if (source === "startup" || source === "resume") return { ...result("start"), warmup: true };
|
|
1153
|
+
if (source === "clear" || source === "fork") return result("start");
|
|
1154
|
+
return result(null);
|
|
1129
1155
|
default:
|
|
1130
1156
|
return result(null);
|
|
1131
1157
|
}
|
|
@@ -1145,10 +1171,18 @@ function hookGroups(verifyMs) {
|
|
|
1145
1171
|
{ event: "PreToolUse", matcher: "Edit|Write", timeout: 5 },
|
|
1146
1172
|
{ event: "PostToolUse", matcher: "Read", timeout: 5 },
|
|
1147
1173
|
{ event: "PostToolUse", matcher: "Edit|Write", timeout: 5 },
|
|
1174
|
+
// Around every shell call: the working tree before and after it is how rulecast sees what a
|
|
1175
|
+
// command wrote. After can run an edit event; a command that fails fires the Failure hook instead.
|
|
1176
|
+
{ event: "PreToolUse", matcher: "Bash", timeout: 5 },
|
|
1177
|
+
{ event: "PostToolUse", matcher: "Bash", timeout: 5 },
|
|
1178
|
+
{ event: "PostToolUseFailure", matcher: "Bash", timeout: 5 },
|
|
1148
1179
|
{ event: "Stop", timeout: verifyTimeout },
|
|
1149
1180
|
{ event: "SubagentStop", timeout: verifyTimeout },
|
|
1150
1181
|
{ event: "UserPromptSubmit", timeout: 5 },
|
|
1151
|
-
{ event: "SessionStart", matcher: "startup|resume|compact", timeout: 5 }
|
|
1182
|
+
{ event: "SessionStart", matcher: "startup|resume|compact", timeout: 5 },
|
|
1183
|
+
// A group of its own rather than a wider matcher on the one above: an older install's group
|
|
1184
|
+
// would then no longer match, and upgrading it would leave two hooks firing on every startup.
|
|
1185
|
+
{ event: "SessionStart", matcher: "clear|fork", timeout: 5 }
|
|
1152
1186
|
];
|
|
1153
1187
|
}
|
|
1154
1188
|
function installed(groups, matcher2) {
|
|
@@ -1162,18 +1196,38 @@ function hooksOf(settings) {
|
|
|
1162
1196
|
if (!isObject3(hooks)) throw new SettingsError('"hooks" must be an object');
|
|
1163
1197
|
return hooks;
|
|
1164
1198
|
}
|
|
1165
|
-
function mergeHooks(settings, command, verifyMs) {
|
|
1199
|
+
function mergeHooks(settings, command, verifyMs, keep) {
|
|
1166
1200
|
const merged = { ...hooksOf(settings) };
|
|
1167
1201
|
const added = [];
|
|
1202
|
+
const updated = [];
|
|
1168
1203
|
for (const { event, matcher: matcher2, timeout } of hookGroups(verifyMs)) {
|
|
1169
1204
|
const groups = merged[event] ?? [];
|
|
1170
1205
|
if (!Array.isArray(groups)) throw new SettingsError(`"hooks.${event}" must be an array`);
|
|
1171
|
-
if (installed(groups, matcher2))
|
|
1206
|
+
if (installed(groups, matcher2)) {
|
|
1207
|
+
let changed = false;
|
|
1208
|
+
const rewritten = groups.map((group) => {
|
|
1209
|
+
if (!isObject3(group) || group.matcher !== matcher2 || !Array.isArray(group.hooks)) return group;
|
|
1210
|
+
return {
|
|
1211
|
+
...group,
|
|
1212
|
+
hooks: group.hooks.map((hook2) => {
|
|
1213
|
+
const current = hook2.command;
|
|
1214
|
+
if (!isRulecastHook(hook2) || current === command || current === keep) return hook2;
|
|
1215
|
+
changed = true;
|
|
1216
|
+
return { ...hook2, command };
|
|
1217
|
+
})
|
|
1218
|
+
};
|
|
1219
|
+
});
|
|
1220
|
+
if (changed) {
|
|
1221
|
+
merged[event] = rewritten;
|
|
1222
|
+
updated.push(matcher2 === void 0 ? event : `${event} (${matcher2})`);
|
|
1223
|
+
}
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1172
1226
|
const hook = { type: "command", command, timeout };
|
|
1173
1227
|
merged[event] = [...groups, matcher2 === void 0 ? { hooks: [hook] } : { matcher: matcher2, hooks: [hook] }];
|
|
1174
1228
|
added.push(matcher2 === void 0 ? event : `${event} (${matcher2})`);
|
|
1175
1229
|
}
|
|
1176
|
-
return { settings: { ...settings, hooks: merged }, added };
|
|
1230
|
+
return { settings: { ...settings, hooks: merged }, added, updated };
|
|
1177
1231
|
}
|
|
1178
1232
|
function removeHooks(settings) {
|
|
1179
1233
|
const hooks = hooksOf(settings);
|
|
@@ -1206,9 +1260,12 @@ function removeHooks(settings) {
|
|
|
1206
1260
|
// src/adapters/claude-code/adapter.ts
|
|
1207
1261
|
var CONTEXT_LIMIT = 1e4;
|
|
1208
1262
|
var CONTEXT_BUDGET = 9e3;
|
|
1209
|
-
var BLOCK_PREAMBLE =
|
|
1263
|
+
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
1264
|
var CAP_PREAMBLE = "rulecast: the agent stopped with these findings unresolved (stop gate limit reached).";
|
|
1265
|
+
var SWEPT_PREAMBLE = "rulecast: files changed outside the agent's tool calls this session have findings (not blocking the agent).";
|
|
1211
1266
|
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.";
|
|
1267
|
+
var NOT_INSTALLED = "rulecast is configured in this project but not installed. Run npm install (or pnpm install), then rulecast doctor.";
|
|
1268
|
+
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
1269
|
function cutNotice(delivery, event) {
|
|
1213
1270
|
if (delivery.overflowPath !== null) {
|
|
1214
1271
|
return `
|
|
@@ -1238,8 +1295,12 @@ var claudeCodeAdapter = {
|
|
|
1238
1295
|
restoredFiles: 5,
|
|
1239
1296
|
parse: parseClaudeCode,
|
|
1240
1297
|
format(delivery, event, options) {
|
|
1241
|
-
const text2 = renderAgentText(delivery, options);
|
|
1242
|
-
|
|
1298
|
+
const text2 = renderAgentText(delivery, { ...options, notices: false, warnings: false });
|
|
1299
|
+
const problems = delivery.warnings.length === 0 ? [] : ["rulecast problems:", ...delivery.warnings.map((w) => ` - ${w}`)];
|
|
1300
|
+
const notices = [...delivery.notices ?? [], ...problems].join("\n");
|
|
1301
|
+
const userOnly = event.kind === "guard" ? NONE : json2({ systemMessage: notices });
|
|
1302
|
+
if (text2 === "") return notices === "" ? NONE : userOnly;
|
|
1303
|
+
const withNotices = (value) => notices === "" ? value : { ...value, systemMessage: notices };
|
|
1243
1304
|
switch (event.kind) {
|
|
1244
1305
|
case "guard":
|
|
1245
1306
|
return json2({
|
|
@@ -1253,23 +1314,58 @@ ${text2}`, delivery, event)
|
|
|
1253
1314
|
});
|
|
1254
1315
|
case "touch":
|
|
1255
1316
|
case "edit":
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1317
|
+
case "shell-after":
|
|
1318
|
+
return json2(
|
|
1319
|
+
withNotices({
|
|
1320
|
+
hookSpecificOutput: {
|
|
1321
|
+
// A command that failed fired PostToolUseFailure, which takes context only under its own name.
|
|
1322
|
+
hookEventName: event.failed === true ? "PostToolUseFailure" : "PostToolUse",
|
|
1323
|
+
additionalContext: withinLimit(text2, delivery, event)
|
|
1324
|
+
}
|
|
1325
|
+
})
|
|
1326
|
+
);
|
|
1259
1327
|
case "reset":
|
|
1260
|
-
return json2(
|
|
1261
|
-
|
|
1262
|
-
|
|
1328
|
+
return json2(
|
|
1329
|
+
withNotices({
|
|
1330
|
+
hookSpecificOutput: {
|
|
1331
|
+
hookEventName: "SessionStart",
|
|
1332
|
+
additionalContext: withinLimit(text2, delivery, event)
|
|
1333
|
+
}
|
|
1334
|
+
})
|
|
1335
|
+
);
|
|
1263
1336
|
case "verify":
|
|
1264
1337
|
if (delivery.stop === "block")
|
|
1265
|
-
return json2(
|
|
1338
|
+
return json2(
|
|
1339
|
+
withNotices({ decision: "block", reason: withinLimit(`${BLOCK_PREAMBLE}
|
|
1266
1340
|
|
|
1267
|
-
${text2}`, delivery, event) })
|
|
1268
|
-
|
|
1269
|
-
|
|
1341
|
+
${text2}`, delivery, event) })
|
|
1342
|
+
);
|
|
1343
|
+
if (delivery.stop === "capReached") {
|
|
1344
|
+
const cap = withinLimit(`${CAP_PREAMBLE}
|
|
1270
1345
|
|
|
1271
|
-
${text2}`, delivery, event)
|
|
1272
|
-
|
|
1346
|
+
${text2}`, delivery, event);
|
|
1347
|
+
return json2({ systemMessage: notices === "" ? cap : `${notices}
|
|
1348
|
+
|
|
1349
|
+
${cap}` });
|
|
1350
|
+
}
|
|
1351
|
+
if ((delivery.swept ?? []).length > 0) {
|
|
1352
|
+
const swept = new Set(delivery.swept);
|
|
1353
|
+
const onlySwept = {
|
|
1354
|
+
...emptyDelivery(),
|
|
1355
|
+
findings: delivery.findings.filter((finding) => swept.has(finding.file)),
|
|
1356
|
+
templates: delivery.templates,
|
|
1357
|
+
omitted: { ...delivery.omitted, findings: delivery.omitted.findings.filter((cut) => cut.swept === true) },
|
|
1358
|
+
swept: delivery.swept
|
|
1359
|
+
};
|
|
1360
|
+
const sweptText = renderAgentText(onlySwept, { ...options, notices: false, warnings: false });
|
|
1361
|
+
const outside = withinLimit(`${SWEPT_PREAMBLE}
|
|
1362
|
+
|
|
1363
|
+
${sweptText}`, delivery, event);
|
|
1364
|
+
return json2({ systemMessage: notices === "" ? outside : `${notices}
|
|
1365
|
+
|
|
1366
|
+
${outside}` });
|
|
1367
|
+
}
|
|
1368
|
+
return notices === "" ? NONE : json2({ systemMessage: notices });
|
|
1273
1369
|
default:
|
|
1274
1370
|
return NONE;
|
|
1275
1371
|
}
|
|
@@ -1280,8 +1376,11 @@ ${text2}`, delivery, event) });
|
|
|
1280
1376
|
{ scope: "shared", file: ".claude/settings.json" },
|
|
1281
1377
|
{ scope: "personal", file: ".claude/settings.local.json" }
|
|
1282
1378
|
],
|
|
1283
|
-
command: (local) => local ?
|
|
1284
|
-
|
|
1379
|
+
command: (local) => local ? LOCAL_COMMAND : "rulecast hook claude-code",
|
|
1380
|
+
// The guarded command is never rewritten to the bare one. It is right with or without
|
|
1381
|
+
// node_modules, and `install` in a clone that has not installed yet would otherwise swap the
|
|
1382
|
+
// committed command for one that exits 127 on every hook.
|
|
1383
|
+
merge: (settings, command, verifyMs) => mergeHooks(settings, command, verifyMs, LOCAL_COMMAND),
|
|
1285
1384
|
remove: removeHooks
|
|
1286
1385
|
}
|
|
1287
1386
|
};
|
|
@@ -1342,6 +1441,7 @@ function renderBacklog(summary, options) {
|
|
|
1342
1441
|
const rest = summary.files.length - shown.length;
|
|
1343
1442
|
if (rest > 0) out.push(` \u2026and ${plural(rest, "more file")}`);
|
|
1344
1443
|
}
|
|
1444
|
+
if (options.note === false) return out.join("\n");
|
|
1345
1445
|
out.push(
|
|
1346
1446
|
"",
|
|
1347
1447
|
"This is the stock, not a rate. Enforcement on edits does not reduce it.",
|
|
@@ -1352,6 +1452,9 @@ function renderBacklog(summary, options) {
|
|
|
1352
1452
|
|
|
1353
1453
|
export {
|
|
1354
1454
|
createRegistry,
|
|
1455
|
+
runTool,
|
|
1456
|
+
repoRelativeTo,
|
|
1457
|
+
repoRelative,
|
|
1355
1458
|
builtinDetectors,
|
|
1356
1459
|
claudeCodeAdapter,
|
|
1357
1460
|
ADAPTERS,
|