@syv-ai/rulecast 0.2.0 → 0.3.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 +49 -2
- package/dist/{chunk-RODZRI7B.js → chunk-7KUPYT35.js} +299 -138
- package/dist/{chunk-ZEFEQKG5.js → chunk-HZUATJ53.js} +18 -8
- package/dist/{chunk-C6C5EER7.js → chunk-KOUHBT7F.js} +62 -2
- package/dist/cli.js +289 -27
- package/dist/index.d.ts +40 -4
- package/dist/index.js +8 -4
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +2 -2
- package/dist/{types-2oz2AlwA.d.ts → types-C8b7hrnG.d.ts} +105 -1
- package/package.json +1 -1
|
@@ -404,7 +404,7 @@ function emptyDelivery() {
|
|
|
404
404
|
}
|
|
405
405
|
|
|
406
406
|
// src/core/version.ts
|
|
407
|
-
var VERSION2 = "0.
|
|
407
|
+
var VERSION2 = "0.3.0";
|
|
408
408
|
function parseVersion(text) {
|
|
409
409
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
|
|
410
410
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
@@ -457,6 +457,7 @@ function templateBindings(template) {
|
|
|
457
457
|
var GROUP_FROM = 3;
|
|
458
458
|
var GROUP_MIN_LITERAL = 40;
|
|
459
459
|
var MAX_LOCATION_PAD = 40;
|
|
460
|
+
var BACKLOG_HINT = " see all of it: rulecast run --all-files --summary";
|
|
460
461
|
var WRAP = 96;
|
|
461
462
|
var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
462
463
|
function header(delivery) {
|
|
@@ -562,13 +563,14 @@ function renderAgentText(delivery, options) {
|
|
|
562
563
|
out.push(...ruleBlock(rule, findings, delivery.templates[rule], omittedFor(delivery.omitted, rule), options));
|
|
563
564
|
out.push("");
|
|
564
565
|
}
|
|
565
|
-
|
|
566
|
-
out.push(
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
566
|
+
if (delivery.preexistingSummary.length > 0 || delivery.omitted.preexisting > 0) {
|
|
567
|
+
out.push("backlog in files you touched (not from your edit):");
|
|
568
|
+
for (const summary of delivery.preexistingSummary) {
|
|
569
|
+
out.push(` ${summary.rule} \xD7${summary.count} in ${summary.file}`);
|
|
570
|
+
}
|
|
571
|
+
if (delivery.omitted.preexisting > 0) out.push(` \u2026and ${delivery.omitted.preexisting} more`);
|
|
572
|
+
out.push(BACKLOG_HINT, "");
|
|
570
573
|
}
|
|
571
|
-
if (delivery.preexistingSummary.length > 0 || delivery.omitted.preexisting > 0) out.push("");
|
|
572
574
|
for (const reference of delivery.references) out.push(...referenceLines(reference));
|
|
573
575
|
if (out.length > 0 && out.at(-1) !== "") out.push("");
|
|
574
576
|
if (delivery.warnings.length > 0) {
|
|
@@ -788,6 +790,8 @@ async function compileRule(input, context) {
|
|
|
788
790
|
if (unknown) return `unknown template variable "${unknown}"`;
|
|
789
791
|
} else {
|
|
790
792
|
if (data.refuse_write) return "refuse_write needs detect: there is nothing to refuse a write for";
|
|
793
|
+
if (data.scope) return "scope needs detect: there is nothing to classify";
|
|
794
|
+
if (data.examples) return "examples need detect: there is nothing to run them against";
|
|
791
795
|
if (stages.some((stage) => stage !== "touch")) return "rules without detect need stages: [touch]";
|
|
792
796
|
if (data.message !== void 0) return "message needs detect";
|
|
793
797
|
if (!data.context?.length) return "rules without detect need context";
|
|
@@ -800,6 +804,9 @@ async function compileRule(input, context) {
|
|
|
800
804
|
excludeTypes: data.exclude_types ?? []
|
|
801
805
|
});
|
|
802
806
|
if (typeof filter === "string") return filter;
|
|
807
|
+
for (const example of [...data.examples?.good ?? [], ...data.examples?.bad ?? []]) {
|
|
808
|
+
if (!filter(example.path)) return `example path "${example.path}" does not match this rule's files`;
|
|
809
|
+
}
|
|
803
810
|
const references = [];
|
|
804
811
|
for (const reference of data.context ?? []) {
|
|
805
812
|
let spec;
|
|
@@ -823,12 +830,14 @@ async function compileRule(input, context) {
|
|
|
823
830
|
description: data.description ?? null,
|
|
824
831
|
source: input.source,
|
|
825
832
|
severity: data.severity ?? "error",
|
|
833
|
+
scope: data.scope ?? "instance",
|
|
826
834
|
refuseWrite: data.refuse_write === true,
|
|
827
835
|
stages,
|
|
828
836
|
matches: (file) => global(file) && filter(file),
|
|
829
837
|
detector,
|
|
830
838
|
message: data.message ?? null,
|
|
831
|
-
context: references
|
|
839
|
+
context: references,
|
|
840
|
+
examples: data.examples ?? null
|
|
832
841
|
};
|
|
833
842
|
}
|
|
834
843
|
|
|
@@ -921,6 +930,7 @@ export {
|
|
|
921
930
|
VERSION2 as VERSION,
|
|
922
931
|
parseVersion,
|
|
923
932
|
isOlder,
|
|
933
|
+
BACKLOG_HINT,
|
|
924
934
|
measureRuleBlock,
|
|
925
935
|
renderAgentText,
|
|
926
936
|
tagsOf,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
readSourceFile,
|
|
15
15
|
renderAgentText,
|
|
16
16
|
resolveModel
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-HZUATJ53.js";
|
|
18
18
|
|
|
19
19
|
// src/core/detection/registry.ts
|
|
20
20
|
function createRegistry(detectors) {
|
|
@@ -1292,10 +1292,70 @@ function adapterByName(name) {
|
|
|
1292
1292
|
return ADAPTERS.find((adapter) => adapter.name === name);
|
|
1293
1293
|
}
|
|
1294
1294
|
|
|
1295
|
+
// src/adapters/cli/backlog.ts
|
|
1296
|
+
function summarise(delivery) {
|
|
1297
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
1298
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1299
|
+
const add = (rule, file, count) => {
|
|
1300
|
+
const entry = byRule.get(rule) ?? { violations: 0, files: /* @__PURE__ */ new Set() };
|
|
1301
|
+
entry.violations += count;
|
|
1302
|
+
entry.files.add(file);
|
|
1303
|
+
byRule.set(rule, entry);
|
|
1304
|
+
byFile.set(file, (byFile.get(file) ?? 0) + count);
|
|
1305
|
+
};
|
|
1306
|
+
for (const finding of delivery.findings) add(finding.rule, finding.file, finding.count);
|
|
1307
|
+
for (const summary of delivery.preexistingSummary) add(summary.rule, summary.file, summary.count);
|
|
1308
|
+
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));
|
|
1309
|
+
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));
|
|
1310
|
+
return {
|
|
1311
|
+
rules,
|
|
1312
|
+
files,
|
|
1313
|
+
totalViolations: rules.reduce((sum, entry) => sum + entry.violations, 0),
|
|
1314
|
+
totalFiles: files.length
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
1318
|
+
function table(header, rows) {
|
|
1319
|
+
const widths = header.map((_, column) => Math.max(...[header, ...rows].map((row) => row[column].length)));
|
|
1320
|
+
const line = (row) => ` ${row.map((cell, column) => column === 0 ? cell.padEnd(widths[column]) : cell.padStart(widths[column])).join(" ")}`;
|
|
1321
|
+
return [line(header), ...rows.map(line)];
|
|
1322
|
+
}
|
|
1323
|
+
function renderBacklog(summary, options) {
|
|
1324
|
+
if (summary.totalViolations === 0) return "backlog: no violations";
|
|
1325
|
+
const out = [
|
|
1326
|
+
`backlog: ${plural(summary.totalViolations, "violation")} in ${plural(summary.totalFiles, "file")}`,
|
|
1327
|
+
"",
|
|
1328
|
+
...table(
|
|
1329
|
+
["rule", "violations", "files"],
|
|
1330
|
+
summary.rules.map((entry) => [entry.rule, String(entry.violations), String(entry.files)])
|
|
1331
|
+
)
|
|
1332
|
+
];
|
|
1333
|
+
const shown = summary.files.slice(0, options.topFiles);
|
|
1334
|
+
if (shown.length > 0) {
|
|
1335
|
+
out.push(
|
|
1336
|
+
"",
|
|
1337
|
+
...table(
|
|
1338
|
+
["worst files", "violations"],
|
|
1339
|
+
shown.map((entry) => [entry.file, String(entry.violations)])
|
|
1340
|
+
)
|
|
1341
|
+
);
|
|
1342
|
+
const rest = summary.files.length - shown.length;
|
|
1343
|
+
if (rest > 0) out.push(` \u2026and ${plural(rest, "more file")}`);
|
|
1344
|
+
}
|
|
1345
|
+
out.push(
|
|
1346
|
+
"",
|
|
1347
|
+
"This is the stock, not a rate. Enforcement on edits does not reduce it.",
|
|
1348
|
+
"Remediate a file at a time: a file that already follows a rule rarely breaks it."
|
|
1349
|
+
);
|
|
1350
|
+
return out.join("\n");
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1295
1353
|
export {
|
|
1296
1354
|
createRegistry,
|
|
1297
1355
|
builtinDetectors,
|
|
1298
1356
|
claudeCodeAdapter,
|
|
1299
1357
|
ADAPTERS,
|
|
1300
|
-
adapterByName
|
|
1358
|
+
adapterByName,
|
|
1359
|
+
summarise,
|
|
1360
|
+
renderBacklog
|
|
1301
1361
|
};
|
package/dist/cli.js
CHANGED
|
@@ -3,8 +3,10 @@ import {
|
|
|
3
3
|
ADAPTERS,
|
|
4
4
|
adapterByName,
|
|
5
5
|
builtinDetectors,
|
|
6
|
-
createRegistry
|
|
7
|
-
|
|
6
|
+
createRegistry,
|
|
7
|
+
renderBacklog,
|
|
8
|
+
summarise
|
|
9
|
+
} from "./chunk-KOUHBT7F.js";
|
|
8
10
|
import {
|
|
9
11
|
CONFIG_FILE,
|
|
10
12
|
LockTimeoutError,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
checkDetectors,
|
|
21
23
|
compile,
|
|
22
24
|
compileManifest,
|
|
25
|
+
createReferenceResolver,
|
|
23
26
|
debugLogger,
|
|
24
27
|
defaultConfig,
|
|
25
28
|
diagnosticText,
|
|
@@ -35,12 +38,13 @@ import {
|
|
|
35
38
|
readConfigData,
|
|
36
39
|
readManifest,
|
|
37
40
|
repoLabel,
|
|
41
|
+
resolveRuleContext,
|
|
38
42
|
rulesOfKind,
|
|
39
43
|
runDetection,
|
|
40
44
|
runPipeline,
|
|
41
45
|
stagedFiles,
|
|
42
46
|
withLock
|
|
43
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-7KUPYT35.js";
|
|
44
48
|
import {
|
|
45
49
|
VERSION,
|
|
46
50
|
detectorCacheDir,
|
|
@@ -49,12 +53,14 @@ import {
|
|
|
49
53
|
isDetectorRule,
|
|
50
54
|
isNotFound,
|
|
51
55
|
memoryCache,
|
|
56
|
+
onPath,
|
|
52
57
|
parseVersion,
|
|
53
58
|
readSourceFile,
|
|
54
59
|
renderAgentText,
|
|
60
|
+
renderTemplate,
|
|
55
61
|
scanHeadings,
|
|
56
62
|
tagsOf
|
|
57
|
-
} from "./chunk-
|
|
63
|
+
} from "./chunk-HZUATJ53.js";
|
|
58
64
|
import {
|
|
59
65
|
Cancelled
|
|
60
66
|
} from "./chunk-K37BHCEK.js";
|
|
@@ -202,6 +208,9 @@ function inFile(file, transform) {
|
|
|
202
208
|
throw new Error(`${file}: ${errorMessage(error)}`);
|
|
203
209
|
}
|
|
204
210
|
}
|
|
211
|
+
async function hookCommandResolves(root, signal) {
|
|
212
|
+
return existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")) || await onPath("rulecast", signal);
|
|
213
|
+
}
|
|
205
214
|
async function hooksInstalled(root, adapter, verifyMs) {
|
|
206
215
|
const install = installOf(adapter);
|
|
207
216
|
const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
|
|
@@ -440,12 +449,14 @@ async function doctorCommand(root, args, registry, io) {
|
|
|
440
449
|
}
|
|
441
450
|
}
|
|
442
451
|
io.stdout("\nhooks\n");
|
|
452
|
+
const resolves = await hookCommandResolves(root);
|
|
443
453
|
for (const adapter of ADAPTERS) {
|
|
444
454
|
if (adapter.install === null) continue;
|
|
445
455
|
const file = await hooksInstalled(root, adapter, project.config.timeouts.verifyMs);
|
|
446
|
-
const level = file === null ? "warning" : "ok";
|
|
456
|
+
const level = file === null || !resolves ? "warning" : "ok";
|
|
447
457
|
count(level);
|
|
448
|
-
|
|
458
|
+
const detail = file === null ? "not installed (run rulecast install)" : resolves ? file : `${file} \u2014 but "rulecast" is on neither the PATH nor node_modules/.bin, so the hooks do nothing. Add it to the project (pnpm add -D @syv-ai/rulecast) and run rulecast install again.`;
|
|
459
|
+
io.stdout(line(level, adapter.label, detail));
|
|
449
460
|
}
|
|
450
461
|
io.stdout("\ncache\n");
|
|
451
462
|
io.stdout(` home ${home}
|
|
@@ -1243,9 +1254,17 @@ import { parseArgs as parseArgs5 } from "util";
|
|
|
1243
1254
|
|
|
1244
1255
|
// src/adapters/cli/format.ts
|
|
1245
1256
|
var CLI_FORMATS = ["terminal", "agent", "json", "sarif"];
|
|
1257
|
+
var TOP_FILES = 10;
|
|
1246
1258
|
var plural3 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
1247
1259
|
function terminal(delivery, options) {
|
|
1248
1260
|
const out = [];
|
|
1261
|
+
if (options.findings === false) {
|
|
1262
|
+
if (delivery.warnings.length > 0) {
|
|
1263
|
+
out.push("warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`), "");
|
|
1264
|
+
}
|
|
1265
|
+
if (options.backlog === true) out.push(renderBacklog(summarise(delivery), { topFiles: TOP_FILES }));
|
|
1266
|
+
return out.join("\n");
|
|
1267
|
+
}
|
|
1249
1268
|
const shown = /* @__PURE__ */ new Map();
|
|
1250
1269
|
const hidden = /* @__PURE__ */ new Map();
|
|
1251
1270
|
for (const finding of delivery.findings) {
|
|
@@ -1264,8 +1283,12 @@ function terminal(delivery, options) {
|
|
|
1264
1283
|
}
|
|
1265
1284
|
for (const [rule, rest] of hidden) out.push(`\u2026and ${rest.length} more for ${rule}`);
|
|
1266
1285
|
if (out.length > 0) out.push("");
|
|
1267
|
-
|
|
1268
|
-
out.push(
|
|
1286
|
+
if (delivery.preexistingSummary.length > 0) {
|
|
1287
|
+
out.push("backlog in the files checked (not from your change):");
|
|
1288
|
+
for (const summary of delivery.preexistingSummary) {
|
|
1289
|
+
out.push(` ${summary.rule} \xD7${summary.count} in ${summary.file}`);
|
|
1290
|
+
}
|
|
1291
|
+
if (options.backlog !== true) out.push(" see all of it: rulecast run --all-files --summary");
|
|
1269
1292
|
}
|
|
1270
1293
|
if (delivery.references.length > 0) {
|
|
1271
1294
|
out.push(`conventions: ${delivery.references.map((reference) => reference.ref).join(", ")}`);
|
|
@@ -1279,12 +1302,16 @@ function terminal(delivery, options) {
|
|
|
1279
1302
|
out.push(
|
|
1280
1303
|
delivery.findings.length === 0 ? "no findings" : `${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`
|
|
1281
1304
|
);
|
|
1305
|
+
if (options.backlog === true) out.push("", renderBacklog(summarise(delivery), { topFiles: TOP_FILES }));
|
|
1282
1306
|
return out.join("\n");
|
|
1283
1307
|
}
|
|
1284
1308
|
function json(delivery) {
|
|
1285
1309
|
return JSON.stringify(
|
|
1286
1310
|
{
|
|
1287
1311
|
findings: delivery.findings,
|
|
1312
|
+
// Always, whatever flags were given: a consumer that did not ask for it can ignore a key, and
|
|
1313
|
+
// one that wanted it cannot conjure it. This is the number a team tracks over time.
|
|
1314
|
+
backlog: summarise(delivery),
|
|
1288
1315
|
preexistingSummary: delivery.preexistingSummary,
|
|
1289
1316
|
references: delivery.references,
|
|
1290
1317
|
touches: delivery.touches,
|
|
@@ -1350,6 +1377,7 @@ function parseRunArgs(args) {
|
|
|
1350
1377
|
options: {
|
|
1351
1378
|
files: { type: "boolean", default: false },
|
|
1352
1379
|
"all-files": { type: "boolean", default: false },
|
|
1380
|
+
summary: { type: "boolean", default: false },
|
|
1353
1381
|
"from-ref": { type: "string" },
|
|
1354
1382
|
"to-ref": { type: "string" },
|
|
1355
1383
|
format: { type: "string", default: "terminal" },
|
|
@@ -1378,6 +1406,7 @@ function parseRunArgs(args) {
|
|
|
1378
1406
|
if (values.files && files.length === 0) throw new UsageError("--files needs at least one file");
|
|
1379
1407
|
if (values.files && values["all-files"]) throw new UsageError("--all-files and --files cannot be combined");
|
|
1380
1408
|
if (toRef !== null && fromRef === null) throw new UsageError("--to-ref needs --from-ref");
|
|
1409
|
+
if (values.summary && format === "sarif") throw new UsageError("--summary cannot be combined with --format sarif");
|
|
1381
1410
|
if (fromRef !== null && (values.files || values["all-files"])) {
|
|
1382
1411
|
throw new UsageError("--from-ref cannot be combined with --all-files or --files");
|
|
1383
1412
|
}
|
|
@@ -1385,6 +1414,7 @@ function parseRunArgs(args) {
|
|
|
1385
1414
|
leading,
|
|
1386
1415
|
files: values.files ? files : null,
|
|
1387
1416
|
allFiles: values["all-files"],
|
|
1417
|
+
summary: values.summary,
|
|
1388
1418
|
fromRef,
|
|
1389
1419
|
toRef,
|
|
1390
1420
|
format,
|
|
@@ -1428,7 +1458,11 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
|
|
|
1428
1458
|
skipDetectorKinds: run2.noLlm ? /* @__PURE__ */ new Set(["llm"]) : void 0,
|
|
1429
1459
|
onlyRules: ruleId === null ? void 0 : /* @__PURE__ */ new Set([ruleId])
|
|
1430
1460
|
});
|
|
1431
|
-
const text = formatDelivery(result.delivery, run2.format, {
|
|
1461
|
+
const text = formatDelivery(result.delivery, run2.format, {
|
|
1462
|
+
maxMatchesPerRule: project.config.maxMatchesPerRule,
|
|
1463
|
+
findings: !run2.summary,
|
|
1464
|
+
backlog: run2.allFiles || run2.summary
|
|
1465
|
+
});
|
|
1432
1466
|
if (text) io.stdout(`${text}
|
|
1433
1467
|
`);
|
|
1434
1468
|
return exitCodeFor(result.delivery, result.failed);
|
|
@@ -1442,13 +1476,238 @@ async function runCommand(root, args, registry, io) {
|
|
|
1442
1476
|
return executeRun({ project, ruleId: run2.leading[0] ?? null, run: run2, registry, io });
|
|
1443
1477
|
}
|
|
1444
1478
|
|
|
1479
|
+
// src/commands/test.ts
|
|
1480
|
+
import { parseArgs as parseArgs6 } from "util";
|
|
1481
|
+
|
|
1482
|
+
// src/core/examples.ts
|
|
1483
|
+
import { mkdir as mkdir3, mkdtemp, rm as rm2, writeFile as writeFile4 } from "fs/promises";
|
|
1484
|
+
import path7 from "path";
|
|
1485
|
+
var EXAMPLES_DIR = ".rulecast-examples-";
|
|
1486
|
+
function skippedResult(rule) {
|
|
1487
|
+
return { ...emptyResult(rule), skipped: true };
|
|
1488
|
+
}
|
|
1489
|
+
function emptyResult(rule) {
|
|
1490
|
+
return {
|
|
1491
|
+
rule,
|
|
1492
|
+
outcomes: [],
|
|
1493
|
+
recall: { matched: 0, total: 0 },
|
|
1494
|
+
precision: { correct: 0, total: 0 },
|
|
1495
|
+
missing: false,
|
|
1496
|
+
empty: false,
|
|
1497
|
+
skipped: false
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
async function runExamples(input) {
|
|
1501
|
+
const { rule } = input;
|
|
1502
|
+
const result = emptyResult(rule.id);
|
|
1503
|
+
if (rule.examples === null) return { ...result, missing: true };
|
|
1504
|
+
const cases = [
|
|
1505
|
+
...rule.examples.good.map((example, index) => ({ kind: "good", index, example })),
|
|
1506
|
+
...rule.examples.bad.map((example, index) => ({ kind: "bad", index, example }))
|
|
1507
|
+
];
|
|
1508
|
+
if (cases.length === 0) return { ...result, empty: true };
|
|
1509
|
+
const scratch = path7.basename(await mkdtemp(path7.join(input.root, EXAMPLES_DIR)));
|
|
1510
|
+
try {
|
|
1511
|
+
for (const { kind, index, example } of cases) {
|
|
1512
|
+
const outcome = await runOne(input, scratch, example);
|
|
1513
|
+
const fired2 = outcome.findings.length > 0;
|
|
1514
|
+
const passed = outcome.error === void 0 && fired2 === (kind === "bad");
|
|
1515
|
+
result.outcomes.push({ kind, index, path: example.path, ...outcome, passed });
|
|
1516
|
+
}
|
|
1517
|
+
} finally {
|
|
1518
|
+
await rm2(path7.join(input.root, scratch), { recursive: true, force: true });
|
|
1519
|
+
}
|
|
1520
|
+
const fired = (outcome) => outcome.findings.length > 0;
|
|
1521
|
+
const bad = result.outcomes.filter((outcome) => outcome.kind === "bad");
|
|
1522
|
+
const goodFired = result.outcomes.filter((outcome) => outcome.kind === "good" && fired(outcome));
|
|
1523
|
+
const badFired = bad.filter(fired);
|
|
1524
|
+
result.recall = { matched: badFired.length, total: bad.length };
|
|
1525
|
+
result.precision = { correct: badFired.length, total: badFired.length + goodFired.length };
|
|
1526
|
+
return result;
|
|
1527
|
+
}
|
|
1528
|
+
async function runOne(input, scratch, example) {
|
|
1529
|
+
const relative = path7.join(scratch, example.path);
|
|
1530
|
+
const file = path7.join(input.root, relative);
|
|
1531
|
+
await mkdir3(path7.dirname(file), { recursive: true });
|
|
1532
|
+
await writeFile4(file, example.code);
|
|
1533
|
+
const output = await runDetection({
|
|
1534
|
+
root: input.root,
|
|
1535
|
+
event: "verify",
|
|
1536
|
+
selections: [{ rule: input.rule, files: [relative] }],
|
|
1537
|
+
// No baseline: an example is entirely new, which is what an author is asking about.
|
|
1538
|
+
changes: /* @__PURE__ */ new Map(),
|
|
1539
|
+
read: (name) => readSourceFile(input.root, name),
|
|
1540
|
+
registry: input.registry,
|
|
1541
|
+
cacheFor: input.cacheFor,
|
|
1542
|
+
contextFor: input.contextFor,
|
|
1543
|
+
settings: input.settings,
|
|
1544
|
+
timeoutMs: input.timeoutMs
|
|
1545
|
+
});
|
|
1546
|
+
const failure = output.errors[0]?.message ?? (output.timedOut.length > 0 ? "timed out" : void 0);
|
|
1547
|
+
if (failure !== void 0) return { findings: [], error: failure };
|
|
1548
|
+
const findings = output.findings.map(({ match }) => ({
|
|
1549
|
+
line: match.line,
|
|
1550
|
+
column: match.column,
|
|
1551
|
+
message: renderTemplate(input.rule.message, {
|
|
1552
|
+
...match.captures,
|
|
1553
|
+
file: example.path,
|
|
1554
|
+
line: String(match.line),
|
|
1555
|
+
column: String(match.column),
|
|
1556
|
+
text: match.text,
|
|
1557
|
+
rule: input.rule.id
|
|
1558
|
+
})
|
|
1559
|
+
}));
|
|
1560
|
+
return { findings };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/commands/test.ts
|
|
1564
|
+
function parseTestArgs(args) {
|
|
1565
|
+
const { values, positionals } = parseArgs6({
|
|
1566
|
+
args,
|
|
1567
|
+
allowPositionals: true,
|
|
1568
|
+
options: { against: { type: "string", multiple: true } }
|
|
1569
|
+
});
|
|
1570
|
+
if (positionals.length > 1) throw new UsageError(`unexpected arguments: ${positionals.slice(1).join(" ")}`);
|
|
1571
|
+
const against = values.against ?? [];
|
|
1572
|
+
const ruleId = positionals[0] ?? null;
|
|
1573
|
+
if (against.length > 0 && ruleId === null) throw new UsageError("--against needs a RULE_ID");
|
|
1574
|
+
return { ruleId, against };
|
|
1575
|
+
}
|
|
1576
|
+
var AGAINST_NOTES = [
|
|
1577
|
+
" A rule this common is usually true by definition rather than a convention.",
|
|
1578
|
+
" A rule with almost no violations is usually not worth an agent's context."
|
|
1579
|
+
];
|
|
1580
|
+
var ZERO_NOTES = [
|
|
1581
|
+
" Nothing to fix today is not the same as nothing to catch: this counts the",
|
|
1582
|
+
" stock, not how often an edit would break the rule."
|
|
1583
|
+
];
|
|
1584
|
+
var ratio = (part, whole) => whole === 0 ? " \u2014 " : (part / whole).toFixed(2);
|
|
1585
|
+
function summaryLine(rule, result) {
|
|
1586
|
+
const id = rule.id.padEnd(36);
|
|
1587
|
+
if (result.skipped) return ` ${id}skipped (llm; name the rule to run it)`;
|
|
1588
|
+
if (result.missing || result.empty) return ` ${id}no examples`;
|
|
1589
|
+
const passed = result.outcomes.filter((outcome) => outcome.passed).length;
|
|
1590
|
+
const score = `${passed}/${result.outcomes.length}`.padEnd(7);
|
|
1591
|
+
return ` ${id}${score} P ${ratio(result.precision.correct, result.precision.total)} R ${ratio(result.recall.matched, result.recall.total)}`;
|
|
1592
|
+
}
|
|
1593
|
+
function failureLines(rule, result) {
|
|
1594
|
+
const out = [];
|
|
1595
|
+
for (const outcome of result.outcomes) {
|
|
1596
|
+
if (outcome.passed) continue;
|
|
1597
|
+
const name = `${outcome.kind}[${outcome.index}] ${outcome.path}`;
|
|
1598
|
+
if (outcome.error !== void 0) {
|
|
1599
|
+
out.push(` ${name} \u2014 ${rule.detector.kind} failed: ${outcome.error}`);
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
if (outcome.kind === "bad") {
|
|
1603
|
+
out.push(` ${name} \u2014 no finding`);
|
|
1604
|
+
continue;
|
|
1605
|
+
}
|
|
1606
|
+
const source = (rule.examples?.good[outcome.index]?.code ?? "").split(/\r?\n/);
|
|
1607
|
+
for (const finding of outcome.findings) {
|
|
1608
|
+
out.push(` ${name} \u2014 fired at line ${finding.line}`, ` ${source[finding.line - 1]?.trim() ?? ""}`);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
return out;
|
|
1612
|
+
}
|
|
1613
|
+
var AGAINST_TOP_FILES = 10;
|
|
1614
|
+
async function reportAgainst(root, rule, paths, detection, io) {
|
|
1615
|
+
const prefixes = paths.map((given) => {
|
|
1616
|
+
const resolved = toProjectPath(root, io.cwd, given);
|
|
1617
|
+
if (resolved === null) throw new UsageError(`${given} is outside the project`);
|
|
1618
|
+
return resolved === "." ? "" : resolved;
|
|
1619
|
+
});
|
|
1620
|
+
const under = (file) => prefixes.some((prefix) => prefix === "" || file === prefix || file.startsWith(`${prefix}/`));
|
|
1621
|
+
const files = (await allFiles(root)).filter((file) => under(file) && rule.matches(file));
|
|
1622
|
+
if (files.length === 0) {
|
|
1623
|
+
io.stdout(`
|
|
1624
|
+
no files under ${paths.join(", ")} match ${rule.id}
|
|
1625
|
+
|
|
1626
|
+
`);
|
|
1627
|
+
return 0;
|
|
1628
|
+
}
|
|
1629
|
+
const output = await runDetection({
|
|
1630
|
+
...detection,
|
|
1631
|
+
root,
|
|
1632
|
+
event: "verify",
|
|
1633
|
+
selections: [{ rule, files }],
|
|
1634
|
+
changes: /* @__PURE__ */ new Map(),
|
|
1635
|
+
read: (file) => readSourceFile(root, file)
|
|
1636
|
+
});
|
|
1637
|
+
const failure = output.errors[0];
|
|
1638
|
+
if (failure !== void 0) throw new Error(`${rule.detector.kind} detector failed: ${failure.message}`);
|
|
1639
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1640
|
+
for (const { match } of output.findings) byFile.set(match.file, (byFile.get(match.file) ?? 0) + 1);
|
|
1641
|
+
const worst = [...byFile].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, AGAINST_TOP_FILES);
|
|
1642
|
+
const width = Math.max(...worst.map(([file]) => file.length), 0);
|
|
1643
|
+
const out = ["", ` ${output.findings.length} violations in ${byFile.size} of ${files.length} matching files`];
|
|
1644
|
+
if (worst.length > 0) {
|
|
1645
|
+
out.push("", ...worst.map(([file, count]) => ` ${file.padEnd(width + 2)}${count}`));
|
|
1646
|
+
if (byFile.size > worst.length) out.push(` \u2026and ${byFile.size - worst.length} more files`);
|
|
1647
|
+
}
|
|
1648
|
+
out.push("", ...AGAINST_NOTES);
|
|
1649
|
+
if (output.findings.length === 0) out.push(...ZERO_NOTES);
|
|
1650
|
+
out.push("");
|
|
1651
|
+
io.stdout(out.join("\n"));
|
|
1652
|
+
return 0;
|
|
1653
|
+
}
|
|
1654
|
+
async function testCommand(root, args, registry, io) {
|
|
1655
|
+
const { ruleId, against } = parseTestArgs(args);
|
|
1656
|
+
if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents`);
|
|
1657
|
+
const project = await compile({ root, registry, repos: fetchingRepos(cacheHome(io.env)) });
|
|
1658
|
+
if (ruleId !== null && !project.rules.some((rule) => rule.id === ruleId)) {
|
|
1659
|
+
throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
|
|
1660
|
+
}
|
|
1661
|
+
const rules = project.rules.filter(isDetectorRule).filter((rule) => ruleId === null || rule.id === ruleId);
|
|
1662
|
+
const stateDir = ensureProjectState(cacheHome(io.env), root);
|
|
1663
|
+
const resolver = createReferenceResolver(root);
|
|
1664
|
+
const detection = {
|
|
1665
|
+
registry,
|
|
1666
|
+
settings: { llm: project.config.llm },
|
|
1667
|
+
contextFor: (one) => resolveRuleContext(resolver, one.context),
|
|
1668
|
+
cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
|
|
1669
|
+
timeoutMs: project.config.timeouts.verifyMs
|
|
1670
|
+
};
|
|
1671
|
+
if (against.length > 0) return await reportAgainst(root, rules[0], against, detection, io);
|
|
1672
|
+
const lines = [""];
|
|
1673
|
+
const noExamples = [];
|
|
1674
|
+
let failedRules = 0;
|
|
1675
|
+
let ranRules = 0;
|
|
1676
|
+
for (const rule of rules) {
|
|
1677
|
+
const result = ruleId === null && rule.detector.kind === "llm" ? skippedResult(rule.id) : await runExamples({ ...detection, root, rule });
|
|
1678
|
+
lines.push(summaryLine(rule, result));
|
|
1679
|
+
if (result.missing || result.empty) {
|
|
1680
|
+
noExamples.push(rule.id);
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
if (result.skipped) continue;
|
|
1684
|
+
ranRules++;
|
|
1685
|
+
if (result.outcomes.some((outcome) => !outcome.passed)) {
|
|
1686
|
+
failedRules++;
|
|
1687
|
+
lines.push(...failureLines(rule, result));
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
if (rules.length === 0) {
|
|
1691
|
+
io.stdout("no rules with a detector\n");
|
|
1692
|
+
return 0;
|
|
1693
|
+
}
|
|
1694
|
+
if (noExamples.length > 0) lines.push("", ` no examples: ${noExamples.join(", ")}`);
|
|
1695
|
+
lines.push(
|
|
1696
|
+
"",
|
|
1697
|
+
failedRules === 0 ? ranRules === 0 ? " nothing to run" : ` ${ranRules} ${ranRules === 1 ? "rule" : "rules"} passed` : ` ${failedRules} of ${ranRules} rules failed`,
|
|
1698
|
+
""
|
|
1699
|
+
);
|
|
1700
|
+
io.stdout(lines.join("\n"));
|
|
1701
|
+
return failedRules === 0 ? 0 : 1;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1445
1704
|
// src/commands/try-repo.ts
|
|
1446
1705
|
import { existsSync as existsSync5, statSync as statSync2 } from "fs";
|
|
1447
|
-
import { mkdtemp, rm as
|
|
1706
|
+
import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
|
|
1448
1707
|
import { tmpdir as tmpdir2 } from "os";
|
|
1449
|
-
import
|
|
1708
|
+
import path8 from "path";
|
|
1450
1709
|
var isDirectory = (file) => existsSync5(file) && statSync2(file).isDirectory();
|
|
1451
|
-
var isGitRepo = (dir) => existsSync5(
|
|
1710
|
+
var isGitRepo = (dir) => existsSync5(path8.join(dir, ".git")) || existsSync5(path8.join(dir, "HEAD"));
|
|
1452
1711
|
async function baseConfigData(root) {
|
|
1453
1712
|
if (!hasProject(root)) return {};
|
|
1454
1713
|
const data = await readConfigData(root);
|
|
@@ -1460,16 +1719,16 @@ async function tryRepoCommand(root, args, registry, io) {
|
|
|
1460
1719
|
const [repo, ruleId, ...extra] = run2.leading;
|
|
1461
1720
|
if (repo === void 0) throw new UsageError("try-repo needs a repository path or URL");
|
|
1462
1721
|
if (extra.length > 0) throw new UsageError(`unexpected arguments: ${extra.join(" ")}`);
|
|
1463
|
-
const local =
|
|
1722
|
+
const local = path8.resolve(io.cwd, repo);
|
|
1464
1723
|
const directory = isDirectory(local);
|
|
1465
1724
|
if (directory && run2.ref !== null && !isGitRepo(local)) throw new UsageError("--ref only applies to URLs");
|
|
1466
1725
|
const fromDirectory = directory && run2.ref === null;
|
|
1467
1726
|
const rev = fromDirectory ? "working-tree" : run2.ref ?? "HEAD";
|
|
1468
|
-
const temp = fromDirectory ? null : await
|
|
1727
|
+
const temp = fromDirectory ? null : await mkdtemp2(path8.join(tmpdir2(), "rulecast-try-repo-"));
|
|
1469
1728
|
try {
|
|
1470
1729
|
const dir = temp ?? local;
|
|
1471
1730
|
if (temp !== null) await fetchCheckout(directory ? local : repo, rev, temp);
|
|
1472
|
-
const label = fromDirectory ? `${
|
|
1731
|
+
const label = fromDirectory ? `${path8.basename(local)}@${rev}` : repoLabel(repo, rev);
|
|
1473
1732
|
const manifest = await readManifest(dir);
|
|
1474
1733
|
if (!manifest.ok) throw new Error(`${label}: ${manifest.message}`);
|
|
1475
1734
|
const ids = manifest.value.flatMap((rule) => {
|
|
@@ -1484,29 +1743,29 @@ async function tryRepoCommand(root, args, registry, io) {
|
|
|
1484
1743
|
const project = await compile({ root, registry, repos: fixedRepo(dir, label), configData });
|
|
1485
1744
|
return await executeRun({ project, ruleId: null, run: run2, registry, io });
|
|
1486
1745
|
} finally {
|
|
1487
|
-
if (temp !== null) await
|
|
1746
|
+
if (temp !== null) await rm3(temp, { recursive: true, force: true });
|
|
1488
1747
|
}
|
|
1489
1748
|
}
|
|
1490
1749
|
|
|
1491
1750
|
// src/commands/validate.ts
|
|
1492
1751
|
import { existsSync as existsSync6 } from "fs";
|
|
1493
|
-
import
|
|
1752
|
+
import path9 from "path";
|
|
1494
1753
|
function formatDiagnostic(diagnostic) {
|
|
1495
1754
|
return `${diagnostic.level === "warning" ? "warning: " : ""}${diagnosticText(diagnostic)}`;
|
|
1496
1755
|
}
|
|
1497
1756
|
async function validateCommand(root, args, registry, io) {
|
|
1498
|
-
const targets = args.length > 0 ? args.map((file) => ({ shown: file, full:
|
|
1757
|
+
const targets = args.length > 0 ? args.map((file) => ({ shown: file, full: path9.resolve(io.cwd, file) })) : [CONFIG_FILE, MANIFEST_FILE].filter((name) => existsSync6(path9.join(root, name))).map((name) => ({ shown: name, full: path9.join(root, name) }));
|
|
1499
1758
|
if (targets.length === 0) throw new Error(`nothing to validate: no ${CONFIG_FILE} or ${MANIFEST_FILE}`);
|
|
1500
1759
|
for (const { shown, full } of targets) {
|
|
1501
|
-
const name =
|
|
1760
|
+
const name = path9.basename(full);
|
|
1502
1761
|
if (name !== CONFIG_FILE && name !== MANIFEST_FILE) {
|
|
1503
1762
|
throw new UsageError(`${shown}: not a ${CONFIG_FILE} or ${MANIFEST_FILE}`);
|
|
1504
1763
|
}
|
|
1505
1764
|
}
|
|
1506
1765
|
let failed = false;
|
|
1507
1766
|
for (const { shown, full } of targets) {
|
|
1508
|
-
const dir =
|
|
1509
|
-
const result =
|
|
1767
|
+
const dir = path9.dirname(full);
|
|
1768
|
+
const result = path9.basename(full) === CONFIG_FILE ? await compile({ root: dir, registry, repos: fetchingRepos(cacheHome(io.env)) }) : await compileManifest(dir, registry);
|
|
1510
1769
|
for (const diagnostic of result.diagnostics) io.stdout(`${shown}: ${formatDiagnostic(diagnostic)}
|
|
1511
1770
|
`);
|
|
1512
1771
|
if (result.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
|
|
@@ -1521,10 +1780,10 @@ async function validateCommand(root, args, registry, io) {
|
|
|
1521
1780
|
}
|
|
1522
1781
|
|
|
1523
1782
|
// src/commands/warm.ts
|
|
1524
|
-
import { parseArgs as
|
|
1783
|
+
import { parseArgs as parseArgs7 } from "util";
|
|
1525
1784
|
var WARM_TIMEOUT_MS = 5 * 6e4;
|
|
1526
1785
|
async function warmCommand(root, args, registry, io) {
|
|
1527
|
-
const { values } =
|
|
1786
|
+
const { values } = parseArgs7({ args, options: { detector: { type: "string", multiple: true } } });
|
|
1528
1787
|
if (!hasProject(root)) throw new Error(`no .rulecast-config.yaml in ${root} or its parents (run rulecast init)`);
|
|
1529
1788
|
const home = cacheHome(io.env);
|
|
1530
1789
|
const stateDir = ensureProjectState(home, root);
|
|
@@ -1558,9 +1817,10 @@ var USAGE = `usage:
|
|
|
1558
1817
|
rulecast init [--rules id,id | --no-rules] [--agent <name>]... [--scope shared|personal] [--yes]
|
|
1559
1818
|
rulecast install [--agent <name>]... [--scope shared|personal]
|
|
1560
1819
|
rulecast uninstall [--agent <name>]...
|
|
1561
|
-
rulecast run [RULE_ID] [--all-files | --files F...] [--from-ref A [--to-ref B]] [--format terminal|agent|json|sarif] [--session <id>] [--no-llm]
|
|
1820
|
+
rulecast run [RULE_ID] [--all-files | --files F...] [--from-ref A [--to-ref B]] [--summary] [--format terminal|agent|json|sarif] [--session <id>] [--no-llm]
|
|
1562
1821
|
rulecast autoupdate [--freeze] [--repo URL]...
|
|
1563
1822
|
rulecast try-repo <path|url> [RULE_ID] [--ref REV] [run flags]
|
|
1823
|
+
rulecast test [RULE_ID]
|
|
1564
1824
|
rulecast validate [file...]
|
|
1565
1825
|
rulecast clean [--project]
|
|
1566
1826
|
rulecast hook <adapter>
|
|
@@ -1585,6 +1845,8 @@ async function main(argv, io) {
|
|
|
1585
1845
|
return await autoupdateCommand(root, args, io);
|
|
1586
1846
|
case "try-repo":
|
|
1587
1847
|
return await tryRepoCommand(root, args, registry, io);
|
|
1848
|
+
case "test":
|
|
1849
|
+
return await testCommand(root, args, registry, io);
|
|
1588
1850
|
case "validate":
|
|
1589
1851
|
return await validateCommand(root, args, registry, io);
|
|
1590
1852
|
case "clean":
|
|
@@ -1625,7 +1887,7 @@ function spawnDetached(command, args, cwd) {
|
|
|
1625
1887
|
// src/init/clipboard.ts
|
|
1626
1888
|
import { spawn as spawn2 } from "child_process";
|
|
1627
1889
|
import { accessSync, constants, statSync as statSync3 } from "fs";
|
|
1628
|
-
import
|
|
1890
|
+
import path10 from "path";
|
|
1629
1891
|
var CANDIDATES = [
|
|
1630
1892
|
{ command: "pbcopy", args: [] },
|
|
1631
1893
|
{ command: "wl-copy", args: [] },
|
|
@@ -1642,9 +1904,9 @@ function isExecutable(file) {
|
|
|
1642
1904
|
}
|
|
1643
1905
|
}
|
|
1644
1906
|
function findOnPath(name, env) {
|
|
1645
|
-
for (const dir of (env.PATH ?? "").split(
|
|
1907
|
+
for (const dir of (env.PATH ?? "").split(path10.delimiter)) {
|
|
1646
1908
|
if (dir === "") continue;
|
|
1647
|
-
const candidate =
|
|
1909
|
+
const candidate = path10.join(dir, name);
|
|
1648
1910
|
if (isExecutable(candidate)) return candidate;
|
|
1649
1911
|
}
|
|
1650
1912
|
return null;
|