@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.
@@ -404,7 +404,7 @@ function emptyDelivery() {
404
404
  }
405
405
 
406
406
  // src/core/version.ts
407
- var VERSION2 = "0.2.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
- for (const summary of delivery.preexistingSummary) {
566
- out.push(`pre-existing (not blocking): ${summary.rule} \xD7${summary.count} in ${summary.file}`);
567
- }
568
- if (delivery.omitted.preexisting > 0) {
569
- out.push(`\u2026and ${delivery.omitted.preexisting} more pre-existing (not blocking)`);
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-ZEFEQKG5.js";
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
- } from "./chunk-C6C5EER7.js";
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-RODZRI7B.js";
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-ZEFEQKG5.js";
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
- io.stdout(line(level, adapter.label, file ?? "not installed (run rulecast install)"));
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
- for (const summary of delivery.preexistingSummary) {
1268
- out.push(`pre-existing (not blocking): ${summary.rule} \xD7${summary.count} in ${summary.file}`);
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, { maxMatchesPerRule: project.config.maxMatchesPerRule });
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 rm2 } from "fs/promises";
1706
+ import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
1448
1707
  import { tmpdir as tmpdir2 } from "os";
1449
- import path7 from "path";
1708
+ import path8 from "path";
1450
1709
  var isDirectory = (file) => existsSync5(file) && statSync2(file).isDirectory();
1451
- var isGitRepo = (dir) => existsSync5(path7.join(dir, ".git")) || existsSync5(path7.join(dir, "HEAD"));
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 = path7.resolve(io.cwd, repo);
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 mkdtemp(path7.join(tmpdir2(), "rulecast-try-repo-"));
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 ? `${path7.basename(local)}@${rev}` : repoLabel(repo, rev);
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 rm2(temp, { recursive: true, force: true });
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 path8 from "path";
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: path8.resolve(io.cwd, file) })) : [CONFIG_FILE, MANIFEST_FILE].filter((name) => existsSync6(path8.join(root, name))).map((name) => ({ shown: name, full: path8.join(root, name) }));
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 = path8.basename(full);
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 = path8.dirname(full);
1509
- const result = path8.basename(full) === CONFIG_FILE ? await compile({ root: dir, registry, repos: fetchingRepos(cacheHome(io.env)) }) : await compileManifest(dir, registry);
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 parseArgs6 } from "util";
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 } = parseArgs6({ args, options: { detector: { type: "string", multiple: true } } });
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 path9 from "path";
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(path9.delimiter)) {
1907
+ for (const dir of (env.PATH ?? "").split(path10.delimiter)) {
1646
1908
  if (dir === "") continue;
1647
- const candidate = path9.join(dir, name);
1909
+ const candidate = path10.join(dir, name);
1648
1910
  if (isExecutable(candidate)) return candidate;
1649
1911
  }
1650
1912
  return null;