@syv-ai/rulecast 0.1.1 → 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/dist/cli.js CHANGED
@@ -1,15 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ADAPTERS,
4
+ adapterByName,
5
+ builtinDetectors,
6
+ createRegistry,
7
+ renderBacklog,
8
+ summarise
9
+ } from "./chunk-KOUHBT7F.js";
10
+ import {
4
11
  CONFIG_FILE,
5
12
  LockTimeoutError,
6
13
  MANIFEST_FILE,
7
14
  NO_PROMPT,
8
15
  RepoFetchError,
9
- VERSION,
10
- adapterByName,
11
16
  allFiles,
12
- builtinDetectors,
13
17
  cacheHome,
14
18
  cachedRepo,
15
19
  cachedRepos,
@@ -18,37 +22,45 @@ import {
18
22
  checkDetectors,
19
23
  compile,
20
24
  compileManifest,
21
- createRegistry,
25
+ createReferenceResolver,
22
26
  debugLogger,
23
27
  defaultConfig,
24
- detectorCacheDir,
25
28
  diagnosticText,
26
- diskCache,
27
29
  ensureProjectState,
28
30
  ensureRepo,
29
- errorMessage,
30
31
  fetchCheckout,
31
32
  fetchingRepos,
32
33
  fixedRepo,
33
34
  git,
34
- isNotFound,
35
- memoryCache,
36
35
  mergeBase,
37
36
  parseConfig,
38
- parseVersion,
39
37
  projectStateDir,
40
38
  readConfigData,
41
39
  readManifest,
42
- readSourceFile,
43
- renderAgentText,
44
40
  repoLabel,
41
+ resolveRuleContext,
42
+ rulesOfKind,
45
43
  runDetection,
46
44
  runPipeline,
47
- scanHeadings,
48
45
  stagedFiles,
49
- tagsOf,
50
46
  withLock
51
- } from "./chunk-3HRTZULM.js";
47
+ } from "./chunk-7KUPYT35.js";
48
+ import {
49
+ VERSION,
50
+ detectorCacheDir,
51
+ diskCache,
52
+ errorMessage,
53
+ isDetectorRule,
54
+ isNotFound,
55
+ memoryCache,
56
+ onPath,
57
+ parseVersion,
58
+ readSourceFile,
59
+ renderAgentText,
60
+ renderTemplate,
61
+ scanHeadings,
62
+ tagsOf
63
+ } from "./chunk-HZUATJ53.js";
52
64
  import {
53
65
  Cancelled
54
66
  } from "./chunk-K37BHCEK.js";
@@ -196,6 +208,9 @@ function inFile(file, transform) {
196
208
  throw new Error(`${file}: ${errorMessage(error)}`);
197
209
  }
198
210
  }
211
+ async function hookCommandResolves(root, signal) {
212
+ return existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")) || await onPath("rulecast", signal);
213
+ }
199
214
  async function hooksInstalled(root, adapter, verifyMs) {
200
215
  const install = installOf(adapter);
201
216
  const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
@@ -434,12 +449,14 @@ async function doctorCommand(root, args, registry, io) {
434
449
  }
435
450
  }
436
451
  io.stdout("\nhooks\n");
452
+ const resolves = await hookCommandResolves(root);
437
453
  for (const adapter of ADAPTERS) {
438
454
  if (adapter.install === null) continue;
439
455
  const file = await hooksInstalled(root, adapter, project.config.timeouts.verifyMs);
440
- const level = file === null ? "warning" : "ok";
456
+ const level = file === null || !resolves ? "warning" : "ok";
441
457
  count(level);
442
- 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));
443
460
  }
444
461
  io.stdout("\ncache\n");
445
462
  io.stdout(` home ${home}
@@ -477,7 +494,7 @@ async function dryRun(project, registry) {
477
494
  }
478
495
  const lines = [];
479
496
  for (const rule of project.rules) {
480
- if (rule.detector === null) {
497
+ if (!isDetectorRule(rule)) {
481
498
  lines.push({ rule: rule.id, level: "ok", detail: "context only, nothing to run" });
482
499
  continue;
483
500
  }
@@ -542,7 +559,7 @@ async function warmDetectors(options) {
542
559
  await Promise.all(
543
560
  kinds.map(async (kind) => {
544
561
  const detector = registry.get(kind);
545
- const rules = project.rules.filter((rule) => rule.detector?.kind === kind).map((rule) => ({ id: rule.id, config: rule.detector.config }));
562
+ const rules = rulesOfKind(project.rules, kind);
546
563
  try {
547
564
  await withLock(
548
565
  path4.join(stateDir, "warm", kind),
@@ -1237,15 +1254,25 @@ import { parseArgs as parseArgs5 } from "util";
1237
1254
 
1238
1255
  // src/adapters/cli/format.ts
1239
1256
  var CLI_FORMATS = ["terminal", "agent", "json", "sarif"];
1257
+ var TOP_FILES = 10;
1240
1258
  var plural3 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1241
1259
  function terminal(delivery, options) {
1242
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
+ }
1243
1268
  const shown = /* @__PURE__ */ new Map();
1244
1269
  const hidden = /* @__PURE__ */ new Map();
1245
1270
  for (const finding of delivery.findings) {
1246
1271
  const count = shown.get(finding.rule) ?? 0;
1247
1272
  if (count >= options.maxMatchesPerRule) {
1248
- hidden.set(finding.rule, [...hidden.get(finding.rule) ?? [], finding]);
1273
+ const rest = hidden.get(finding.rule);
1274
+ if (rest === void 0) hidden.set(finding.rule, [finding]);
1275
+ else rest.push(finding);
1249
1276
  continue;
1250
1277
  }
1251
1278
  shown.set(finding.rule, count + 1);
@@ -1256,8 +1283,12 @@ function terminal(delivery, options) {
1256
1283
  }
1257
1284
  for (const [rule, rest] of hidden) out.push(`\u2026and ${rest.length} more for ${rule}`);
1258
1285
  if (out.length > 0) out.push("");
1259
- for (const summary of delivery.preexistingSummary) {
1260
- 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");
1261
1292
  }
1262
1293
  if (delivery.references.length > 0) {
1263
1294
  out.push(`conventions: ${delivery.references.map((reference) => reference.ref).join(", ")}`);
@@ -1271,8 +1302,26 @@ function terminal(delivery, options) {
1271
1302
  out.push(
1272
1303
  delivery.findings.length === 0 ? "no findings" : `${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`
1273
1304
  );
1305
+ if (options.backlog === true) out.push("", renderBacklog(summarise(delivery), { topFiles: TOP_FILES }));
1274
1306
  return out.join("\n");
1275
1307
  }
1308
+ function json(delivery) {
1309
+ return JSON.stringify(
1310
+ {
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),
1315
+ preexistingSummary: delivery.preexistingSummary,
1316
+ references: delivery.references,
1317
+ touches: delivery.touches,
1318
+ warnings: delivery.warnings,
1319
+ stop: delivery.stop
1320
+ },
1321
+ null,
1322
+ 2
1323
+ );
1324
+ }
1276
1325
  function sarif(delivery) {
1277
1326
  const rules = [...new Set(delivery.findings.map((finding) => finding.rule))].map((id) => ({ id }));
1278
1327
  return JSON.stringify(
@@ -1309,7 +1358,7 @@ function formatDelivery(delivery, format, options) {
1309
1358
  case "agent":
1310
1359
  return renderAgentText(delivery, options);
1311
1360
  case "json":
1312
- return JSON.stringify(delivery, null, 2);
1361
+ return json(delivery);
1313
1362
  case "sarif":
1314
1363
  return sarif(delivery);
1315
1364
  }
@@ -1328,6 +1377,7 @@ function parseRunArgs(args) {
1328
1377
  options: {
1329
1378
  files: { type: "boolean", default: false },
1330
1379
  "all-files": { type: "boolean", default: false },
1380
+ summary: { type: "boolean", default: false },
1331
1381
  "from-ref": { type: "string" },
1332
1382
  "to-ref": { type: "string" },
1333
1383
  format: { type: "string", default: "terminal" },
@@ -1356,6 +1406,7 @@ function parseRunArgs(args) {
1356
1406
  if (values.files && files.length === 0) throw new UsageError("--files needs at least one file");
1357
1407
  if (values.files && values["all-files"]) throw new UsageError("--all-files and --files cannot be combined");
1358
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");
1359
1410
  if (fromRef !== null && (values.files || values["all-files"])) {
1360
1411
  throw new UsageError("--from-ref cannot be combined with --all-files or --files");
1361
1412
  }
@@ -1363,6 +1414,7 @@ function parseRunArgs(args) {
1363
1414
  leading,
1364
1415
  files: values.files ? files : null,
1365
1416
  allFiles: values["all-files"],
1417
+ summary: values.summary,
1366
1418
  fromRef,
1367
1419
  toRef,
1368
1420
  format,
@@ -1406,7 +1458,11 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
1406
1458
  skipDetectorKinds: run2.noLlm ? /* @__PURE__ */ new Set(["llm"]) : void 0,
1407
1459
  onlyRules: ruleId === null ? void 0 : /* @__PURE__ */ new Set([ruleId])
1408
1460
  });
1409
- 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
+ });
1410
1466
  if (text) io.stdout(`${text}
1411
1467
  `);
1412
1468
  return exitCodeFor(result.delivery, result.failed);
@@ -1420,13 +1476,238 @@ async function runCommand(root, args, registry, io) {
1420
1476
  return executeRun({ project, ruleId: run2.leading[0] ?? null, run: run2, registry, io });
1421
1477
  }
1422
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
+
1423
1704
  // src/commands/try-repo.ts
1424
1705
  import { existsSync as existsSync5, statSync as statSync2 } from "fs";
1425
- import { mkdtemp, rm as rm2 } from "fs/promises";
1706
+ import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
1426
1707
  import { tmpdir as tmpdir2 } from "os";
1427
- import path7 from "path";
1708
+ import path8 from "path";
1428
1709
  var isDirectory = (file) => existsSync5(file) && statSync2(file).isDirectory();
1429
- 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"));
1430
1711
  async function baseConfigData(root) {
1431
1712
  if (!hasProject(root)) return {};
1432
1713
  const data = await readConfigData(root);
@@ -1438,16 +1719,16 @@ async function tryRepoCommand(root, args, registry, io) {
1438
1719
  const [repo, ruleId, ...extra] = run2.leading;
1439
1720
  if (repo === void 0) throw new UsageError("try-repo needs a repository path or URL");
1440
1721
  if (extra.length > 0) throw new UsageError(`unexpected arguments: ${extra.join(" ")}`);
1441
- const local = path7.resolve(io.cwd, repo);
1722
+ const local = path8.resolve(io.cwd, repo);
1442
1723
  const directory = isDirectory(local);
1443
1724
  if (directory && run2.ref !== null && !isGitRepo(local)) throw new UsageError("--ref only applies to URLs");
1444
1725
  const fromDirectory = directory && run2.ref === null;
1445
1726
  const rev = fromDirectory ? "working-tree" : run2.ref ?? "HEAD";
1446
- 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-"));
1447
1728
  try {
1448
1729
  const dir = temp ?? local;
1449
1730
  if (temp !== null) await fetchCheckout(directory ? local : repo, rev, temp);
1450
- const label = fromDirectory ? `${path7.basename(local)}@${rev}` : repoLabel(repo, rev);
1731
+ const label = fromDirectory ? `${path8.basename(local)}@${rev}` : repoLabel(repo, rev);
1451
1732
  const manifest = await readManifest(dir);
1452
1733
  if (!manifest.ok) throw new Error(`${label}: ${manifest.message}`);
1453
1734
  const ids = manifest.value.flatMap((rule) => {
@@ -1462,29 +1743,29 @@ async function tryRepoCommand(root, args, registry, io) {
1462
1743
  const project = await compile({ root, registry, repos: fixedRepo(dir, label), configData });
1463
1744
  return await executeRun({ project, ruleId: null, run: run2, registry, io });
1464
1745
  } finally {
1465
- if (temp !== null) await rm2(temp, { recursive: true, force: true });
1746
+ if (temp !== null) await rm3(temp, { recursive: true, force: true });
1466
1747
  }
1467
1748
  }
1468
1749
 
1469
1750
  // src/commands/validate.ts
1470
1751
  import { existsSync as existsSync6 } from "fs";
1471
- import path8 from "path";
1752
+ import path9 from "path";
1472
1753
  function formatDiagnostic(diagnostic) {
1473
1754
  return `${diagnostic.level === "warning" ? "warning: " : ""}${diagnosticText(diagnostic)}`;
1474
1755
  }
1475
1756
  async function validateCommand(root, args, registry, io) {
1476
- 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) }));
1477
1758
  if (targets.length === 0) throw new Error(`nothing to validate: no ${CONFIG_FILE} or ${MANIFEST_FILE}`);
1478
1759
  for (const { shown, full } of targets) {
1479
- const name = path8.basename(full);
1760
+ const name = path9.basename(full);
1480
1761
  if (name !== CONFIG_FILE && name !== MANIFEST_FILE) {
1481
1762
  throw new UsageError(`${shown}: not a ${CONFIG_FILE} or ${MANIFEST_FILE}`);
1482
1763
  }
1483
1764
  }
1484
1765
  let failed = false;
1485
1766
  for (const { shown, full } of targets) {
1486
- const dir = path8.dirname(full);
1487
- 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);
1488
1769
  for (const diagnostic of result.diagnostics) io.stdout(`${shown}: ${formatDiagnostic(diagnostic)}
1489
1770
  `);
1490
1771
  if (result.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
@@ -1499,10 +1780,10 @@ async function validateCommand(root, args, registry, io) {
1499
1780
  }
1500
1781
 
1501
1782
  // src/commands/warm.ts
1502
- import { parseArgs as parseArgs6 } from "util";
1783
+ import { parseArgs as parseArgs7 } from "util";
1503
1784
  var WARM_TIMEOUT_MS = 5 * 6e4;
1504
1785
  async function warmCommand(root, args, registry, io) {
1505
- const { values } = parseArgs6({ args, options: { detector: { type: "string", multiple: true } } });
1786
+ const { values } = parseArgs7({ args, options: { detector: { type: "string", multiple: true } } });
1506
1787
  if (!hasProject(root)) throw new Error(`no .rulecast-config.yaml in ${root} or its parents (run rulecast init)`);
1507
1788
  const home = cacheHome(io.env);
1508
1789
  const stateDir = ensureProjectState(home, root);
@@ -1536,9 +1817,10 @@ var USAGE = `usage:
1536
1817
  rulecast init [--rules id,id | --no-rules] [--agent <name>]... [--scope shared|personal] [--yes]
1537
1818
  rulecast install [--agent <name>]... [--scope shared|personal]
1538
1819
  rulecast uninstall [--agent <name>]...
1539
- 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]
1540
1821
  rulecast autoupdate [--freeze] [--repo URL]...
1541
1822
  rulecast try-repo <path|url> [RULE_ID] [--ref REV] [run flags]
1823
+ rulecast test [RULE_ID]
1542
1824
  rulecast validate [file...]
1543
1825
  rulecast clean [--project]
1544
1826
  rulecast hook <adapter>
@@ -1563,6 +1845,8 @@ async function main(argv, io) {
1563
1845
  return await autoupdateCommand(root, args, io);
1564
1846
  case "try-repo":
1565
1847
  return await tryRepoCommand(root, args, registry, io);
1848
+ case "test":
1849
+ return await testCommand(root, args, registry, io);
1566
1850
  case "validate":
1567
1851
  return await validateCommand(root, args, registry, io);
1568
1852
  case "clean":
@@ -1603,7 +1887,7 @@ function spawnDetached(command, args, cwd) {
1603
1887
  // src/init/clipboard.ts
1604
1888
  import { spawn as spawn2 } from "child_process";
1605
1889
  import { accessSync, constants, statSync as statSync3 } from "fs";
1606
- import path9 from "path";
1890
+ import path10 from "path";
1607
1891
  var CANDIDATES = [
1608
1892
  { command: "pbcopy", args: [] },
1609
1893
  { command: "wl-copy", args: [] },
@@ -1620,9 +1904,9 @@ function isExecutable(file) {
1620
1904
  }
1621
1905
  }
1622
1906
  function findOnPath(name, env) {
1623
- for (const dir of (env.PATH ?? "").split(path9.delimiter)) {
1907
+ for (const dir of (env.PATH ?? "").split(path10.delimiter)) {
1624
1908
  if (dir === "") continue;
1625
- const candidate = path9.join(dir, name);
1909
+ const candidate = path10.join(dir, name);
1626
1910
  if (isExecutable(candidate)) return candidate;
1627
1911
  }
1628
1912
  return null;