@syv-ai/rulecast 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6,13 +6,14 @@ import {
6
6
  createRegistry,
7
7
  renderBacklog,
8
8
  summarise
9
- } from "./chunk-KOUHBT7F.js";
9
+ } from "./chunk-IKONOTME.js";
10
10
  import {
11
11
  CONFIG_FILE,
12
12
  LockTimeoutError,
13
13
  MANIFEST_FILE,
14
14
  NO_PROMPT,
15
15
  RepoFetchError,
16
+ WORKTREE,
16
17
  allFiles,
17
18
  cacheHome,
18
19
  cachedRepo,
@@ -25,6 +26,7 @@ import {
25
26
  createReferenceResolver,
26
27
  debugLogger,
27
28
  defaultConfig,
29
+ detectionFor,
28
30
  diagnosticText,
29
31
  ensureProjectState,
30
32
  ensureRepo,
@@ -32,19 +34,21 @@ import {
32
34
  fetchingRepos,
33
35
  fixedRepo,
34
36
  git,
37
+ headCommit,
35
38
  mergeBase,
36
39
  parseConfig,
37
40
  projectStateDir,
38
41
  readConfigData,
39
42
  readManifest,
43
+ readYamlFile,
40
44
  repoLabel,
41
- resolveRuleContext,
42
45
  rulesOfKind,
43
46
  runDetection,
44
47
  runPipeline,
45
48
  stagedFiles,
46
- withLock
47
- } from "./chunk-7KUPYT35.js";
49
+ withLock,
50
+ withScratchTree
51
+ } from "./chunk-WUFU4LW2.js";
48
52
  import {
49
53
  VERSION,
50
54
  detectorCacheDir,
@@ -60,7 +64,7 @@ import {
60
64
  renderTemplate,
61
65
  scanHeadings,
62
66
  tagsOf
63
- } from "./chunk-HZUATJ53.js";
67
+ } from "./chunk-DQUCE2IK.js";
64
68
  import {
65
69
  Cancelled
66
70
  } from "./chunk-K37BHCEK.js";
@@ -211,21 +215,28 @@ function inFile(file, transform) {
211
215
  async function hookCommandResolves(root, signal) {
212
216
  return existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")) || await onPath("rulecast", signal);
213
217
  }
214
- async function hooksInstalled(root, adapter, verifyMs) {
218
+ async function hookState(root, adapter, verifyMs) {
215
219
  const install = installOf(adapter);
216
220
  const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
217
221
  for (const { file } of install.scopes) {
218
222
  const current = await readSettings(root, file);
219
223
  if (current === null) continue;
220
- if (inFile(file, () => install.merge(current.value, command, verifyMs)).added.length === 0) return file;
224
+ const merged = inFile(file, () => install.merge(current.value, command, verifyMs));
225
+ if (merged.added.length === 0) return { file, stale: merged.updated ?? [] };
221
226
  }
222
227
  return null;
223
228
  }
224
229
  async function installHooks(root, adapter, scope, verifyMs) {
225
230
  const install = installOf(adapter);
226
231
  const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
227
- const already = await hooksInstalled(root, adapter, verifyMs);
228
- if (already !== null) return { file: already, added: [] };
232
+ const already = await hookState(root, adapter, verifyMs);
233
+ if (already !== null && already.stale.length === 0) return { file: already.file, added: [] };
234
+ if (already !== null) {
235
+ const current2 = (await readSettings(root, already.file))?.value ?? {};
236
+ const merged2 = inFile(already.file, () => install.merge(current2, command, verifyMs));
237
+ await writeSettings(root, already.file, merged2.settings);
238
+ return { file: already.file, added: [], updated: merged2.updated ?? [] };
239
+ }
229
240
  const target = install.scopes.find((candidate) => candidate.scope === scope);
230
241
  if (!target) throw new UsageError(`${adapter.name} has no ${scope} settings`);
231
242
  const current = (await readSettings(root, target.file))?.value ?? {};
@@ -262,8 +273,10 @@ async function loadProjectConfig(root, command) {
262
273
  return config.value;
263
274
  }
264
275
  function printInstall(io, adapter, result) {
276
+ const updated = result.updated ?? [];
265
277
  io.stdout(
266
- result.added.length === 0 ? `${adapter.label} hooks already installed in ${result.file}
278
+ updated.length > 0 ? `updated ${adapter.label} hooks in ${result.file} to the current command: ${updated.join(", ")}
279
+ ` : result.added.length === 0 ? `${adapter.label} hooks already installed in ${result.file}
267
280
  ` : `installed ${adapter.label} hooks in ${result.file}: ${result.added.join(", ")}
268
281
  `
269
282
  );
@@ -452,10 +465,12 @@ async function doctorCommand(root, args, registry, io) {
452
465
  const resolves = await hookCommandResolves(root);
453
466
  for (const adapter of ADAPTERS) {
454
467
  if (adapter.install === null) continue;
455
- const file = await hooksInstalled(root, adapter, project.config.timeouts.verifyMs);
456
- const level = file === null || !resolves ? "warning" : "ok";
468
+ const state = await hookState(root, adapter, project.config.timeouts.verifyMs);
469
+ const file = state?.file ?? null;
470
+ const stale = (state?.stale ?? []).length > 0;
471
+ const level = file === null || !resolves || stale ? "warning" : "ok";
457
472
  count(level);
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.`;
473
+ const detail = file === null ? "not installed (run rulecast install)" : stale ? `${file} \u2014 but the hooks use an old command; 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
474
  io.stdout(line(level, adapter.label, detail));
460
475
  }
461
476
  io.stdout("\ncache\n");
@@ -471,12 +486,14 @@ async function doctorCommand(root, args, registry, io) {
471
486
  `);
472
487
  }
473
488
  io.stdout("\ndry run\n");
474
- const dry = await dryRun(project, registry);
489
+ const dry = await dryRun(project, detectionFor(project, registry, stateDir, createReferenceResolver(root)));
475
490
  if (dry.length === 0) io.stdout(" nothing to run\n");
476
491
  for (const result of dry) {
477
492
  count(result.level);
478
493
  io.stdout(line(result.level, result.rule, result.detail));
479
494
  }
495
+ if (dry.some((result) => result.tried)) io.stdout(` ${DRY_RUN_FOOTER}
496
+ `);
480
497
  io.stdout(
481
498
  `
482
499
  ${errors + warnings === 0 ? "no problems found" : [plural(errors, "error"), plural(warnings, "warning")].join(", ")}
@@ -484,7 +501,8 @@ ${errors + warnings === 0 ? "no problems found" : [plural(errors, "error"), plur
484
501
  );
485
502
  return errors > 0 ? 2 : 0;
486
503
  }
487
- async function dryRun(project, registry) {
504
+ var DRY_RUN_FOOTER = "the dry run tries one file per rule; rulecast run --all-files checks them all";
505
+ async function dryRun(project, detection) {
488
506
  if (project.rules.length === 0) return [];
489
507
  let files;
490
508
  try {
@@ -494,31 +512,38 @@ async function dryRun(project, registry) {
494
512
  }
495
513
  const lines = [];
496
514
  for (const rule of project.rules) {
515
+ if (!rule.enabled) {
516
+ lines.push({ rule: rule.id, level: "skipped", detail: "disabled" });
517
+ continue;
518
+ }
497
519
  if (!isDetectorRule(rule)) {
498
520
  lines.push({ rule: rule.id, level: "ok", detail: "context only, nothing to run" });
499
521
  continue;
500
522
  }
501
- if (rule.detector.kind === "llm") {
502
- lines.push({ rule: rule.id, level: "skipped", detail: "llm rules are not dry-run (a model call costs money)" });
523
+ if (detection.registry.get(rule.detector.kind)?.metered === true) {
524
+ lines.push({
525
+ rule: rule.id,
526
+ level: "skipped",
527
+ detail: `${rule.detector.kind} rules are not dry-run (each run costs money)`
528
+ });
503
529
  continue;
504
530
  }
505
- const file = files.find((candidate) => rule.matches(candidate));
531
+ const matching = files.filter((candidate) => rule.matches(candidate));
532
+ const file = matching[0];
506
533
  if (file === void 0) {
507
534
  lines.push({ rule: rule.id, level: "warning", detail: "no file in the project matches this rule" });
508
535
  continue;
509
536
  }
510
537
  const timeoutMs = project.config.timeouts.verifyMs;
511
538
  const output = await runDetection({
512
- root: project.root,
539
+ // Spread over the project's context, so the two deviations read as deviations. The memory
540
+ // cache is the reason given in this function's own comment; context is unused because only
541
+ // the llm detector reads it and llm rules never reach here.
542
+ detection: { ...detection, cacheFor: () => memoryCache(), contextFor: async () => [] },
513
543
  event: "verify",
514
544
  selections: [{ rule, files: [file] }],
515
545
  changes: /* @__PURE__ */ new Map(),
516
546
  read: (name) => readSourceFile(project.root, name),
517
- registry,
518
- cacheFor: () => memoryCache(),
519
- // Only the llm detector reads context, and llm rules never reach here.
520
- contextFor: async () => [],
521
- settings: { llm: project.config.llm },
522
547
  timeoutMs
523
548
  });
524
549
  const failure = output.errors[0];
@@ -534,12 +559,150 @@ async function dryRun(project, registry) {
534
559
  lines.push({
535
560
  rule: rule.id,
536
561
  level: "ok",
537
- detail: `${file}, ${matches === 0 ? "no match" : matches === 1 ? "1 match" : `${matches} matches`}`
562
+ detail: `tried ${file} (1 of ${plural(matching.length, "matching file")}): ${matches === 0 ? "no match" : matches === 1 ? "1 match" : `${matches} matches`}`,
563
+ tried: true
538
564
  });
539
565
  }
540
566
  return lines;
541
567
  }
542
568
 
569
+ // src/commands/help.ts
570
+ var COMMANDS = [
571
+ {
572
+ name: "init",
573
+ synopsis: "rulecast init [--rules id,id | --no-rules] [--agent <name>... | --no-agents] [--scope shared|personal] [--yes]",
574
+ summary: "Set rulecast up: config, catalog rules, agent hooks",
575
+ flags: [
576
+ ["--rules id,id", "Install these catalog rules instead of the ones that apply to your files"],
577
+ ["--no-rules", "Install no catalog rules"],
578
+ ["--agent <name>", "Install hooks for this agent (claude-code); repeatable"],
579
+ ["--no-agents", "Install no agent hooks"],
580
+ ["--scope shared|personal", "Write hooks to the shared or the personal settings file"],
581
+ ["--yes", "Take every default and write without asking"]
582
+ ]
583
+ },
584
+ {
585
+ name: "install",
586
+ synopsis: "rulecast install [--agent <name>]... [--scope shared|personal]",
587
+ summary: "Add the agent hooks, or upgrade them, and fetch missing rule repos",
588
+ flags: [
589
+ ["--agent <name>", "Only this agent; repeatable. Default: every supported agent"],
590
+ ["--scope shared|personal", "Write to the shared or the personal settings file"]
591
+ ]
592
+ },
593
+ {
594
+ name: "uninstall",
595
+ synopsis: "rulecast uninstall [--agent <name>]...",
596
+ summary: "Remove the agent hooks rulecast added",
597
+ flags: [["--agent <name>", "Only this agent; repeatable"]]
598
+ },
599
+ {
600
+ name: "run",
601
+ synopsis: "rulecast run [RULE_ID] [--all-files | --files F...] [--from-ref A [--to-ref B]] [--summary] [--format terminal|agent|json|sarif] [--session <id>] [--llm | --no-llm]",
602
+ summary: "Check staged files (the default), changed files, or everything",
603
+ flags: [
604
+ ["RULE_ID", "Run only this rule"],
605
+ ["--all-files", "Every file git knows about, with the adoption backlog"],
606
+ ["--files F...", "These files"],
607
+ ["--from-ref A", "Files changed since the merge base with A, judged against it"],
608
+ ["--to-ref B", "With --from-ref: up to B, read at B (pre-push, CI)"],
609
+ ["--summary", "Print the backlog instead of every finding"],
610
+ ["--format", "terminal, agent, json or sarif"],
611
+ ["--session <id>", "The files an agent session edited, as at its Stop"],
612
+ ["--llm", "Include metered (llm) rules in a staged run, which skips them by default"],
613
+ ["--no-llm", "Skip metered (llm) rules in any run"]
614
+ ]
615
+ },
616
+ {
617
+ name: "list",
618
+ synopsis: "rulecast list [RULE_ID] [--format terminal|json]",
619
+ summary: "Show every configured rule: where it comes from, what it matches, what it cites",
620
+ flags: [
621
+ ["RULE_ID", "Only this rule, with its message and the text of the sections it cites"],
622
+ ["--format", "terminal or json"]
623
+ ]
624
+ },
625
+ {
626
+ name: "test",
627
+ synopsis: "rulecast test [RULE_ID] [--against PATH...]",
628
+ summary: "Run each rule's good/bad examples; --against says how much it would flag",
629
+ flags: [
630
+ ["RULE_ID", "Only this rule. Needed for llm rules, which cost money per example"],
631
+ ["--against PATH...", "With RULE_ID: fire the rule over real files and count what it finds"]
632
+ ]
633
+ },
634
+ {
635
+ name: "validate",
636
+ synopsis: "rulecast validate [file...]",
637
+ summary: "Check the config (and a rules manifest) and print diagnostics",
638
+ flags: [["file...", "A config (repos:) or a manifest (a list of rules), whatever its name"]]
639
+ },
640
+ {
641
+ name: "doctor",
642
+ synopsis: "rulecast doctor",
643
+ summary: "Compile, check the environment and hooks, dry-run every rule",
644
+ flags: []
645
+ },
646
+ {
647
+ name: "autoupdate",
648
+ synopsis: "rulecast autoupdate [--freeze] [--repo URL]...",
649
+ summary: "Move pinned rule repos to their latest tag",
650
+ flags: [
651
+ ["--freeze", "Pin the commit SHA, with the tag in a comment"],
652
+ ["--repo URL", "Only this repo; repeatable"]
653
+ ]
654
+ },
655
+ {
656
+ name: "try-repo",
657
+ synopsis: "rulecast try-repo <path|url> [RULE_ID] [--ref REV] [run flags]",
658
+ summary: "Run a rule repo against your project without configuring it",
659
+ flags: [["--ref REV", "The revision of a URL repo (default HEAD)"]]
660
+ },
661
+ {
662
+ name: "clean",
663
+ synopsis: "rulecast clean [--project]",
664
+ summary: "Delete the cache",
665
+ flags: [["--project", "Only this project's directory"]]
666
+ },
667
+ {
668
+ name: "warm",
669
+ synopsis: "rulecast warm [--detector <kind>]...",
670
+ summary: "Build detector caches ahead of time",
671
+ flags: [["--detector <kind>", "Only this detector kind; repeatable"]]
672
+ },
673
+ {
674
+ name: "hook",
675
+ synopsis: "rulecast hook <adapter>",
676
+ summary: "Answer an agent hook on stdin (the agent runs this, not you)",
677
+ flags: []
678
+ }
679
+ ];
680
+ var NAME_WIDTH = Math.max(...COMMANDS.map((command) => command.name.length));
681
+ function usage() {
682
+ return [
683
+ `rulecast ${VERSION}`,
684
+ "",
685
+ "usage: rulecast <command> [flags]",
686
+ "",
687
+ ...COMMANDS.map((command) => ` ${command.name.padEnd(NAME_WIDTH)} ${command.summary}`),
688
+ "",
689
+ "rulecast <command> --help shows a command's flags.",
690
+ ""
691
+ ].join("\n");
692
+ }
693
+ function commandHelp(name) {
694
+ const command = COMMANDS.find((candidate) => candidate.name === name);
695
+ if (command === void 0) return null;
696
+ const width = Math.max(0, ...command.flags.map(([flag]) => flag.length));
697
+ return [
698
+ command.synopsis,
699
+ "",
700
+ command.summary,
701
+ ...command.flags.length === 0 ? [] : ["", ...command.flags.map(([flag, text]) => ` ${flag.padEnd(width)} ${text}`)],
702
+ ""
703
+ ].join("\n");
704
+ }
705
+
543
706
  // src/core/detection/warm.ts
544
707
  import path4 from "path";
545
708
  var WARM_LOCK = { waitMs: 0, staleMs: 10 * 6e4, retryMs: 20 };
@@ -775,6 +938,26 @@ function detectionSummary(detection) {
775
938
  }
776
939
  return parts.length === 0 ? "nothing yet" : parts.join(" \xB7 ");
777
940
  }
941
+ var STOPWORDS = /* @__PURE__ */ new Set(["the", "a", "an", "in", "of", "and", "or", "to", "for", "md", "mdx", "readme"]);
942
+ var words = (text) => new Set(
943
+ text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w !== "" && !STOPWORDS.has(w))
944
+ );
945
+ function matchHeading(spec, docs) {
946
+ const stem = path5.basename(spec.path).replace(/\.[^.]+$/, "");
947
+ const wanted = words(`${stem} ${spec.anchor ?? ""}`);
948
+ let best = null;
949
+ for (const doc of docs) {
950
+ for (const heading of doc.headings) {
951
+ if (heading.slug === "") continue;
952
+ const score = [...words(heading.text)].filter((word) => wanted.has(word)).length;
953
+ if (score === 0) continue;
954
+ if (best === null || score > best.score || score === best.score && heading.level < best.level) {
955
+ best = { value: `@${doc.file}#${heading.slug}`, score, level: heading.level };
956
+ }
957
+ }
958
+ }
959
+ return best?.value ?? null;
960
+ }
778
961
  function docChoices(doc) {
779
962
  const choices = [{ value: `@${doc.file}`, label: doc.file }];
780
963
  const trail = [];
@@ -869,13 +1052,13 @@ function addCatalogRules(text, catalog, rules) {
869
1052
  const entry = repos.items.find((item) => isMap2(item) && item.get("repo") === catalog.url);
870
1053
  let edit;
871
1054
  if (entry) {
872
- const listed = entry.get("rules", true);
1055
+ const listed2 = entry.get("rules", true);
873
1056
  const rulesKey = pairKey(entry, "rules");
874
- if (!isSeq2(listed) || rulesKey === null) throw new ConfigTextError(`${catalog.url} has no rules list`);
875
- const present = new Set(listed.items.map((item) => isMap2(item) ? item.get("id") : void 0));
1057
+ if (!isSeq2(listed2) || rulesKey === null) throw new ConfigTextError(`${catalog.url} has no rules list`);
1058
+ const present = new Set(listed2.items.map((item) => isMap2(item) ? item.get("id") : void 0));
876
1059
  const added = rules.filter((rule) => !present.has(rule.id));
877
1060
  if (added.length === 0) return text;
878
- edit = appendToSeq(text, listed, rulesKey, added.flatMap(ruleLines));
1061
+ edit = appendToSeq(text, listed2, rulesKey, added.flatMap(ruleLines));
879
1062
  } else {
880
1063
  if (rules.length === 0) return text;
881
1064
  edit = appendToSeq(text, repos, reposKey, repoLines(catalog, rules));
@@ -928,7 +1111,19 @@ async function planAgent(choice, context) {
928
1111
  };
929
1112
  for (const { file } of install.scopes) {
930
1113
  const text2 = await context.readText(file);
931
- if (text2 !== null && merge(parseSettings(text2, file), file).added.length === 0) return null;
1114
+ if (text2 === null) continue;
1115
+ const merged2 = merge(parseSettings(text2, file), file);
1116
+ if (merged2.added.length > 0) continue;
1117
+ const updated = merged2.updated ?? [];
1118
+ if (updated.length === 0) return null;
1119
+ return {
1120
+ file,
1121
+ content: `${JSON.stringify(merged2.settings, null, 2)}
1122
+ `,
1123
+ created: false,
1124
+ summary: `~${plural2(updated.length, "hook")} updated (${choice.adapter.label})`,
1125
+ commit: install.scopes.find((entry) => entry.file === file)?.scope === "shared"
1126
+ };
932
1127
  }
933
1128
  const target = install.scopes.find((entry) => entry.scope === choice.scope);
934
1129
  if (target === void 0) throw new PlanError(`${choice.adapter.label} has no ${choice.scope} settings`);
@@ -959,6 +1154,7 @@ function reviewText(changes) {
959
1154
  }
960
1155
 
961
1156
  // src/commands/init.ts
1157
+ var NO_HOOKS = "No agent hooks: rulecast will not run in your agent until you run rulecast install --agent <name>.";
962
1158
  var DEFAULT_CATALOG = "https://github.com/syv-ai/rulecast";
963
1159
  var SCOPES = ["shared", "personal"];
964
1160
  var installable = () => ADAPTERS.filter((adapter) => adapter.install !== null);
@@ -969,11 +1165,13 @@ function parseFlags(args) {
969
1165
  rules: { type: "string" },
970
1166
  "no-rules": { type: "boolean", default: false },
971
1167
  agent: { type: "string", multiple: true },
1168
+ "no-agents": { type: "boolean", default: false },
972
1169
  scope: { type: "string" },
973
1170
  yes: { type: "boolean", default: false }
974
1171
  }
975
1172
  });
976
1173
  if (values.rules !== void 0 && values["no-rules"]) throw new UsageError("use --rules or --no-rules, not both");
1174
+ if (values.agent !== void 0 && values["no-agents"]) throw new UsageError("use --agent or --no-agents, not both");
977
1175
  const scope = values.scope ?? null;
978
1176
  if (scope !== null && !SCOPES.includes(scope)) {
979
1177
  throw new UsageError(`unknown scope "${scope}" (use shared or personal)`);
@@ -987,7 +1185,14 @@ function parseFlags(args) {
987
1185
  return adapter;
988
1186
  });
989
1187
  const rules = values.rules === void 0 ? null : values.rules.split(",").map((id) => id.trim()).filter(Boolean);
990
- return { rules, noRules: values["no-rules"], agents, scope, yes: values.yes };
1188
+ return {
1189
+ rules,
1190
+ noRules: values["no-rules"],
1191
+ agents,
1192
+ noAgents: values["no-agents"],
1193
+ scope,
1194
+ yes: values.yes
1195
+ };
991
1196
  }
992
1197
  function projectRoot(cwd) {
993
1198
  const found = findRoot(cwd);
@@ -1035,7 +1240,8 @@ var idOf = (entry) => {
1035
1240
  return typeof id === "string" ? id : null;
1036
1241
  };
1037
1242
  var groupOf = (id) => id.includes("/") ? id.slice(0, id.indexOf("/")) : "general";
1038
- async function chooseRules(catalog, installed, files, flags, ui) {
1243
+ async function chooseRules(catalog, installed, files, flags, ui, registry) {
1244
+ const metered = (rule) => rule.detector !== null && registry.get(rule.detector.kind)?.metered === true;
1039
1245
  const available = catalog.rules.filter((rule) => !installed.has(rule.id));
1040
1246
  if (flags.noRules) return [];
1041
1247
  if (flags.rules !== null) {
@@ -1044,7 +1250,7 @@ async function chooseRules(catalog, installed, files, flags, ui) {
1044
1250
  if (unknown !== void 0) throw new UsageError(`"${unknown}" is not a rule in ${catalog.label}`);
1045
1251
  return available.filter((rule) => flags.rules.includes(rule.id));
1046
1252
  }
1047
- const preselected = available.filter((rule) => rule.detector?.kind !== "llm" && files.some((file) => rule.matches(file))).map((rule) => rule.id);
1253
+ const preselected = available.filter((rule) => !metered(rule) && files.some((file) => rule.matches(file))).map((rule) => rule.id);
1048
1254
  if (ui.prompter === null) return available.filter((rule) => preselected.includes(rule.id));
1049
1255
  if (available.length === 0) {
1050
1256
  ui.say(`Every rule from ${catalog.label} is already configured.`, "Rules");
@@ -1058,7 +1264,7 @@ async function chooseRules(catalog, installed, files, flags, ui) {
1058
1264
  choices.push({
1059
1265
  value: rule.id,
1060
1266
  label: group === "general" ? rule.id : rule.id.slice(group.length + 1),
1061
- hint: done ? "installed" : hintFor(rule),
1267
+ hint: done ? "installed" : hintFor(rule, registry),
1062
1268
  disabled: done
1063
1269
  });
1064
1270
  groups[group] = choices;
@@ -1070,11 +1276,10 @@ async function chooseRules(catalog, installed, files, flags, ui) {
1070
1276
  });
1071
1277
  return available.filter((rule) => chosen.includes(rule.id));
1072
1278
  }
1073
- function hintFor(rule) {
1279
+ function hintFor(rule, registry) {
1074
1280
  const description = rule.description ?? rule.name;
1075
- if (rule.detector?.kind !== "llm") return description;
1076
- const model = rule.detector.config.model ?? "a model";
1077
- return `llm \xB7 ${model} \xB7 sends file contents to your provider \u2014 ${description}`;
1281
+ const cost = rule.detector === null ? void 0 : registry.get(rule.detector.kind)?.cost?.(rule.detector.config);
1282
+ return cost === void 0 ? description : `${cost} \u2014 ${description}`;
1078
1283
  }
1079
1284
  async function chooseConventions(rules, detection, ui) {
1080
1285
  const choices = detection.docs.flatMap(docChoices);
@@ -1089,20 +1294,27 @@ async function chooseConventions(rules, detection, ui) {
1089
1294
  label: "keep the package's doc",
1090
1295
  hint: rule.context.map((spec) => spec.ref).join(", ")
1091
1296
  };
1297
+ const matched = rule.context.map((spec) => matchHeading(spec, detection.docs)).find((value) => value !== null);
1092
1298
  const answer = await ui.prompter.select({
1093
1299
  message: `Conventions for ${rule.id}`,
1094
1300
  choices: [keep, ...choices],
1095
- initialValue: ""
1301
+ initialValue: matched !== void 0 && choices.some((choice) => choice.value === matched) ? matched : ""
1096
1302
  });
1097
1303
  selections.push({ id: rule.id, context: answer === "" ? null : [answer] });
1098
1304
  }
1099
1305
  return selections;
1100
1306
  }
1307
+ function defaultAgents(detected, adapters) {
1308
+ const found = adapters.filter((adapter) => detected.includes(adapter.name));
1309
+ return found.length > 0 ? found : [...adapters];
1310
+ }
1101
1311
  async function chooseAgents(detection, flags, ui) {
1102
1312
  const detected = detection.agents.filter((agent) => agent.adapter !== null).map((agent) => agent.name);
1313
+ const defaults = defaultAgents(detected, installable());
1103
1314
  let chosen;
1104
- if (flags.agents.length > 0) chosen = flags.agents;
1105
- else if (ui.prompter === null) chosen = installable().filter((adapter) => detected.includes(adapter.name));
1315
+ if (flags.noAgents) chosen = [];
1316
+ else if (flags.agents.length > 0) chosen = flags.agents;
1317
+ else if (ui.prompter === null) chosen = defaults;
1106
1318
  else {
1107
1319
  const choices = installable().map((adapter) => ({
1108
1320
  value: adapter.name,
@@ -1119,12 +1331,13 @@ async function chooseAgents(detection, flags, ui) {
1119
1331
  });
1120
1332
  }
1121
1333
  }
1122
- const names = await ui.prompter.multiselect({ message: "Install hooks for", choices, initialValues: detected });
1334
+ const names = await ui.prompter.multiselect({
1335
+ message: "Install hooks for",
1336
+ choices,
1337
+ initialValues: defaults.map((adapter) => adapter.name)
1338
+ });
1123
1339
  chosen = installable().filter((adapter) => names.includes(adapter.name));
1124
1340
  }
1125
- if (chosen.length === 0) {
1126
- ui.say("No agent hooks will be installed. Add them later with rulecast install --agent <name>.", "Agents");
1127
- }
1128
1341
  const agents = [];
1129
1342
  for (const adapter of chosen) {
1130
1343
  let scope = flags.scope ?? "shared";
@@ -1183,7 +1396,7 @@ async function run(root, flags, registry, io, ui) {
1183
1396
  const catalog = typeof loaded === "string" ? null : loaded;
1184
1397
  if (typeof loaded === "string") ui.say(`${loaded}. Continuing without catalog rules.`, "Catalog unavailable");
1185
1398
  const installed = new Set((configured?.rules ?? []).map(idOf).filter((id) => id !== null));
1186
- const rules = catalog === null ? [] : await chooseRules(catalog, installed, files, flags, ui);
1399
+ const rules = catalog === null ? [] : await chooseRules(catalog, installed, files, flags, ui, registry);
1187
1400
  const selections = await chooseConventions(rules, detection, ui);
1188
1401
  const agents = await chooseAgents(detection, flags, ui);
1189
1402
  const changes = await planInit(
@@ -1195,9 +1408,10 @@ async function run(root, flags, registry, io, ui) {
1195
1408
  readText: (file) => readSourceFile(root, file)
1196
1409
  }
1197
1410
  );
1198
- if (changes.length === 0) ui.say("Nothing to change.", "Review");
1411
+ const noHooks = agents.length === 0 ? [NO_HOOKS] : [];
1412
+ if (changes.length === 0) ui.say(["Nothing to change.", ...noHooks].join("\n"), "Review");
1199
1413
  else {
1200
- ui.say(reviewText(changes), "Review");
1414
+ ui.say([reviewText(changes), ...noHooks].join("\n"), "Review");
1201
1415
  if (ui.prompter !== null) {
1202
1416
  if (!await ui.prompter.confirm({ message: "Write?", initialValue: true })) {
1203
1417
  ui.prompter.outro("Nothing written.");
@@ -1249,20 +1463,158 @@ async function initCommand(args, registry, io) {
1249
1463
  }
1250
1464
  }
1251
1465
 
1252
- // src/commands/run.ts
1466
+ // src/commands/list.ts
1253
1467
  import { parseArgs as parseArgs5 } from "util";
1468
+ import { stringify } from "yaml";
1469
+ function listed(rule, files) {
1470
+ return {
1471
+ id: rule.id,
1472
+ name: rule.name,
1473
+ source: rule.source,
1474
+ enabled: rule.enabled,
1475
+ stages: [...rule.stages],
1476
+ detector: rule.detector?.kind ?? null,
1477
+ config: rule.detector?.config ?? null,
1478
+ severity: rule.severity,
1479
+ files: rule.patterns.files,
1480
+ exclude: rule.patterns.exclude,
1481
+ matchingFiles: files === null ? null : files.filter((file) => rule.matches(file)).length,
1482
+ context: rule.context.map((spec) => spec.ref)
1483
+ };
1484
+ }
1485
+ async function projectFiles(root) {
1486
+ try {
1487
+ return await allFiles(root);
1488
+ } catch {
1489
+ return null;
1490
+ }
1491
+ }
1492
+ var MATCHING_SHOWN = 20;
1493
+ function matchingText(count) {
1494
+ if (count === null) return "";
1495
+ if (count === 0) return " \u2014 matches no file in this project";
1496
+ return ` \u2014 matches ${count} ${count === 1 ? "file" : "files"}`;
1497
+ }
1498
+ async function listCommand(root, args, registry, io) {
1499
+ const { values, positionals } = parseArgs5({
1500
+ args,
1501
+ allowPositionals: true,
1502
+ options: { format: { type: "string", default: "terminal" } }
1503
+ });
1504
+ if (positionals.length > 1) throw new UsageError(`unexpected arguments: ${positionals.slice(1).join(" ")}`);
1505
+ if (values.format !== "terminal" && values.format !== "json") {
1506
+ throw new UsageError(`unknown format "${values.format}" (use terminal or json)`);
1507
+ }
1508
+ if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents`);
1509
+ const ruleId = positionals[0] ?? null;
1510
+ const project = await compile({ root, registry, repos: fetchingRepos(cacheHome(io.env)) });
1511
+ const compiled = project.rules.filter((rule) => ruleId === null || rule.id === ruleId);
1512
+ if (ruleId !== null && compiled.length === 0) throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
1513
+ const files = await projectFiles(root);
1514
+ const rules = compiled.map((rule) => listed(rule, files));
1515
+ if (ruleId !== null) {
1516
+ const rule = compiled[0];
1517
+ const resolver = createReferenceResolver(root);
1518
+ rules[0].matching = (files ?? []).filter((file) => rule.matches(file));
1519
+ rules[0].message = rule.message;
1520
+ rules[0].sections = await Promise.all(
1521
+ rule.context.map(async (spec) => {
1522
+ const resolved = await resolver.resolve(spec);
1523
+ return { ref: spec.ref, content: resolved.found ? resolved.content : null };
1524
+ })
1525
+ );
1526
+ }
1527
+ if (values.format === "json") {
1528
+ io.stdout(`${JSON.stringify(rules, null, 2)}
1529
+ `);
1530
+ return 0;
1531
+ }
1532
+ const out = [];
1533
+ for (const rule of rules) {
1534
+ const what = rule.detector === null ? "touch" : `${rule.detector} \xB7 ${rule.stages.join(", ")} \xB7 ${rule.severity}`;
1535
+ out.push(`${rule.id}${rule.enabled ? "" : " (disabled)"} ${what} [${rule.source}]`);
1536
+ out.push(` ${rule.name}`);
1537
+ const scope = `files ${rule.files === "" ? "(all)" : rule.files}${rule.exclude === "^$" ? "" : `, exclude ${rule.exclude}`}`;
1538
+ out.push(` ${scope}${matchingText(rule.matchingFiles)}`);
1539
+ if (rule.config !== null) {
1540
+ out.push(
1541
+ " detects:",
1542
+ ...stringify(rule.config).trimEnd().split("\n").map((line2) => ` ${line2}`)
1543
+ );
1544
+ }
1545
+ if (rule.matching !== void 0 && rule.matching.length > 0) {
1546
+ const shown = rule.matching.slice(0, MATCHING_SHOWN);
1547
+ const more = rule.matching.length - shown.length;
1548
+ out.push(` matching ${shown.join(", ")}${more > 0 ? ` and ${more} more` : ""}`);
1549
+ }
1550
+ if (rule.message !== void 0 && rule.message !== null) out.push(` message ${rule.message}`);
1551
+ if (rule.context.length > 0) out.push(` cites ${rule.context.join(", ")}`);
1552
+ out.push("");
1553
+ for (const section of rule.sections ?? []) {
1554
+ if (section.content === null) out.push(`--- ${section.ref} --- not found`, "");
1555
+ else out.push(`--- ${section.ref} ---`, section.content, "");
1556
+ }
1557
+ }
1558
+ if (ruleId === null) {
1559
+ const errors = project.diagnostics.filter((diagnostic) => diagnostic.level === "error");
1560
+ for (const diagnostic of errors) out.push(`not compiled: ${diagnosticText(diagnostic)}`);
1561
+ out.push(
1562
+ `${rules.length} ${rules.length === 1 ? "rule" : "rules"}${errors.length > 0 ? `, ${errors.length} not compiled (rulecast validate)` : ""}`,
1563
+ ""
1564
+ );
1565
+ out.push("rulecast list <rule-id> shows one rule with the text of the sections it cites.");
1566
+ }
1567
+ io.stdout(`${out.join("\n").trimEnd()}
1568
+ `);
1569
+ return 0;
1570
+ }
1571
+
1572
+ // src/commands/run.ts
1573
+ import { parseArgs as parseArgs6 } from "util";
1254
1574
 
1255
1575
  // src/adapters/cli/format.ts
1256
1576
  var CLI_FORMATS = ["terminal", "agent", "json", "sarif"];
1577
+ function ignoredLine(ignored) {
1578
+ return `ignored: ${plural3(ignored.length, "finding")} by rulecast-ignore comments (rulecast run --format json lists them)`;
1579
+ }
1580
+ function skippedLine(skipped) {
1581
+ const rules = skipped.length === 1 ? "metered rule" : "metered rules";
1582
+ return `skipped ${skipped.length} ${rules} (${skipped.join(", ")}): costs money per file. Pass --llm to include ${skipped.length === 1 ? "it" : "them"}.`;
1583
+ }
1257
1584
  var TOP_FILES = 10;
1258
1585
  var plural3 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1586
+ function checkedLine(checked) {
1587
+ switch (checked.selection) {
1588
+ case "staged":
1589
+ return `checked ${plural3(checked.files, "staged file")}`;
1590
+ case "range":
1591
+ return `checked ${plural3(checked.files, "changed file")}`;
1592
+ case "all":
1593
+ return `checked all ${plural3(checked.files, "file")}`;
1594
+ case "files":
1595
+ return `checked ${plural3(checked.files, "file")}`;
1596
+ case "session":
1597
+ return "checked the session's edited files";
1598
+ }
1599
+ }
1600
+ var NOTHING_STAGED = "nothing staged: 0 files checked (rulecast run --all-files checks everything)";
1601
+ function problemsBlock(warnings) {
1602
+ return warnings.length === 0 ? [] : ["rulecast problems:", ...warnings.map((warning) => ` - ${warning}`), ""];
1603
+ }
1259
1604
  function terminal(delivery, options) {
1260
1605
  const out = [];
1606
+ if (options.checked?.selection === "staged" && options.checked.files === 0 && delivery.warnings.length === 0) {
1607
+ return NOTHING_STAGED;
1608
+ }
1261
1609
  if (options.findings === false) {
1262
- if (delivery.warnings.length > 0) {
1263
- out.push("warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`), "");
1264
- }
1610
+ out.push(...problemsBlock(delivery.warnings));
1265
1611
  if (options.backlog === true) out.push(renderBacklog(summarise(delivery), { topFiles: TOP_FILES }));
1612
+ if (options.backlog === true && (options.ignored ?? []).length > 0) out.push("", ignoredLine(options.ignored));
1613
+ const tail = [
1614
+ ...(options.skipped ?? []).length > 0 ? [skippedLine(options.skipped)] : [],
1615
+ ...options.checked !== void 0 ? [checkedLine(options.checked)] : []
1616
+ ];
1617
+ if (tail.length > 0) out.push(...out.length > 0 ? [""] : [], ...tail);
1266
1618
  return out.join("\n");
1267
1619
  }
1268
1620
  const shown = /* @__PURE__ */ new Map();
@@ -1294,18 +1646,21 @@ function terminal(delivery, options) {
1294
1646
  out.push(`conventions: ${delivery.references.map((reference) => reference.ref).join(", ")}`);
1295
1647
  }
1296
1648
  if (delivery.preexistingSummary.length > 0 || delivery.references.length > 0) out.push("");
1297
- if (delivery.warnings.length > 0) {
1298
- out.push("warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`), "");
1299
- }
1649
+ out.push(...problemsBlock(delivery.warnings));
1650
+ for (const notice of delivery.notices ?? []) out.push(notice, "");
1651
+ if ((options.skipped ?? []).length > 0) out.push(skippedLine(options.skipped));
1652
+ if (options.checked !== void 0) out.push(checkedLine(options.checked));
1300
1653
  const errors = delivery.findings.filter((finding) => finding.severity === "error").length;
1301
1654
  const warningsCount = delivery.findings.length - errors;
1655
+ const problems = delivery.warnings.length === 0 ? "" : ` \xB7 rulecast: ${plural3(delivery.warnings.length, "problem")} (see above)`;
1302
1656
  out.push(
1303
- delivery.findings.length === 0 ? "no findings" : `${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`
1657
+ (delivery.findings.length === 0 ? "no findings" : `findings: ${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`) + problems
1304
1658
  );
1305
- if (options.backlog === true) out.push("", renderBacklog(summarise(delivery), { topFiles: TOP_FILES }));
1659
+ if (options.backlog === true) out.push("", renderBacklog(summarise(delivery), { topFiles: TOP_FILES, note: false }));
1660
+ if (options.backlog === true && (options.ignored ?? []).length > 0) out.push("", ignoredLine(options.ignored));
1306
1661
  return out.join("\n");
1307
1662
  }
1308
- function json(delivery) {
1663
+ function json(delivery, options) {
1309
1664
  return JSON.stringify(
1310
1665
  {
1311
1666
  findings: delivery.findings,
@@ -1316,6 +1671,10 @@ function json(delivery) {
1316
1671
  references: delivery.references,
1317
1672
  touches: delivery.touches,
1318
1673
  warnings: delivery.warnings,
1674
+ skipped: options.skipped ?? [],
1675
+ checked: options.checked ?? null,
1676
+ ignored: options.ignored ?? [],
1677
+ notices: delivery.notices ?? [],
1319
1678
  stop: delivery.stop
1320
1679
  },
1321
1680
  null,
@@ -1358,7 +1717,7 @@ function formatDelivery(delivery, format, options) {
1358
1717
  case "agent":
1359
1718
  return renderAgentText(delivery, options);
1360
1719
  case "json":
1361
- return json(delivery);
1720
+ return json(delivery, options);
1362
1721
  case "sarif":
1363
1722
  return sarif(delivery);
1364
1723
  }
@@ -1370,7 +1729,7 @@ function exitCodeFor(delivery, failed) {
1370
1729
 
1371
1730
  // src/commands/run.ts
1372
1731
  function parseRunArgs(args) {
1373
- const { values, tokens } = parseArgs5({
1732
+ const { values, tokens } = parseArgs6({
1374
1733
  args,
1375
1734
  allowPositionals: true,
1376
1735
  tokens: true,
@@ -1383,6 +1742,7 @@ function parseRunArgs(args) {
1383
1742
  format: { type: "string", default: "terminal" },
1384
1743
  session: { type: "string" },
1385
1744
  "no-llm": { type: "boolean", default: false },
1745
+ llm: { type: "boolean", default: false },
1386
1746
  ref: { type: "string" }
1387
1747
  }
1388
1748
  });
@@ -1407,6 +1767,7 @@ function parseRunArgs(args) {
1407
1767
  if (values.files && values["all-files"]) throw new UsageError("--all-files and --files cannot be combined");
1408
1768
  if (toRef !== null && fromRef === null) throw new UsageError("--to-ref needs --from-ref");
1409
1769
  if (values.summary && format === "sarif") throw new UsageError("--summary cannot be combined with --format sarif");
1770
+ if (values.llm && values["no-llm"]) throw new UsageError("--llm and --no-llm cannot be combined");
1410
1771
  if (fromRef !== null && (values.files || values["all-files"])) {
1411
1772
  throw new UsageError("--from-ref cannot be combined with --all-files or --files");
1412
1773
  }
@@ -1420,22 +1781,35 @@ function parseRunArgs(args) {
1420
1781
  format,
1421
1782
  session: values.session ?? null,
1422
1783
  noLlm: values["no-llm"],
1784
+ llm: values.llm,
1423
1785
  ref: values.ref ?? null
1424
1786
  };
1425
1787
  }
1426
1788
  async function selectFiles(root, cwd, run2) {
1427
1789
  if (run2.files !== null) {
1428
1790
  const files = run2.files.map((file) => toProjectPath(root, cwd, file)).filter((file) => file !== null);
1429
- return { files, baseCommit: null };
1791
+ return { selection: "files", files, baseCommit: null, content: WORKTREE };
1430
1792
  }
1431
1793
  if (run2.fromRef !== null) {
1432
1794
  const base = await mergeBase(root, run2.fromRef, run2.toRef ?? "HEAD");
1433
- const files = run2.toRef === null ? await changedFilesSince(root, base) : await changedFilesBetween(root, base, run2.toRef);
1434
- return { files, baseCommit: base };
1795
+ if (run2.toRef === null) {
1796
+ return { selection: "range", files: await changedFilesSince(root, base), baseCommit: base, content: WORKTREE };
1797
+ }
1798
+ return {
1799
+ selection: "range",
1800
+ files: await changedFilesBetween(root, base, run2.toRef),
1801
+ baseCommit: base,
1802
+ content: { kind: "commit", ref: run2.toRef }
1803
+ };
1435
1804
  }
1436
- if (run2.allFiles) return { files: await allFiles(root), baseCommit: null };
1437
- if (run2.session !== null) return { files: [], baseCommit: null };
1438
- return { files: await stagedFiles(root), baseCommit: null };
1805
+ if (run2.allFiles) return { selection: "all", files: await allFiles(root), baseCommit: null, content: WORKTREE };
1806
+ if (run2.session !== null) return { selection: "session", files: [], baseCommit: null, content: WORKTREE };
1807
+ return {
1808
+ selection: "staged",
1809
+ files: await stagedFiles(root),
1810
+ baseCommit: await headCommit(root),
1811
+ content: { kind: "index" }
1812
+ };
1439
1813
  }
1440
1814
  async function executeRun({ project, ruleId, run: run2, registry, io }) {
1441
1815
  const root = project.root;
@@ -1443,6 +1817,9 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
1443
1817
  throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
1444
1818
  }
1445
1819
  const selected = await selectFiles(root, io.cwd, run2);
1820
+ const skipMetered = run2.noLlm || selected.selection === "staged" && !run2.llm;
1821
+ const meteredKinds = new Set(registry.kinds().filter((kind) => registry.get(kind)?.metered === true));
1822
+ const skipped = skipMetered ? project.rules.filter((rule) => rule.enabled && rule.detector !== null && meteredKinds.has(rule.detector.kind)).filter((rule) => ruleId === null || rule.id === ruleId).filter((rule) => rule.stages.includes("verify") && selected.files.some((file) => rule.matches(file))).map((rule) => rule.id) : [];
1446
1823
  const result = await runPipeline({
1447
1824
  project,
1448
1825
  stateDir: ensureProjectState(cacheHome(io.env), root),
@@ -1450,18 +1827,27 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
1450
1827
  kind: "verify",
1451
1828
  files: selected.files,
1452
1829
  baseCommit: selected.baseCommit ?? void 0,
1830
+ content: selected.content,
1453
1831
  session: run2.session === null ? void 0 : { id: run2.session },
1454
1832
  cwd: root
1455
1833
  },
1456
1834
  registry,
1457
1835
  maxContextChars: null,
1458
- skipDetectorKinds: run2.noLlm ? /* @__PURE__ */ new Set(["llm"]) : void 0,
1836
+ skipDetectorKinds: skipMetered ? meteredKinds : void 0,
1459
1837
  onlyRules: ruleId === null ? void 0 : /* @__PURE__ */ new Set([ruleId])
1460
1838
  });
1461
1839
  const text = formatDelivery(result.delivery, run2.format, {
1462
1840
  maxMatchesPerRule: project.config.maxMatchesPerRule,
1463
1841
  findings: !run2.summary,
1464
- backlog: run2.allFiles || run2.summary
1842
+ backlog: run2.allFiles || run2.summary,
1843
+ skipped,
1844
+ checked: { files: selected.files.length, selection: selected.selection },
1845
+ ignored: (result.ignored ?? []).map(({ rule, match, reason }) => ({
1846
+ rule: rule.id,
1847
+ file: match.file,
1848
+ line: match.line,
1849
+ reason
1850
+ }))
1465
1851
  });
1466
1852
  if (text) io.stdout(`${text}
1467
1853
  `);
@@ -1477,11 +1863,10 @@ async function runCommand(root, args, registry, io) {
1477
1863
  }
1478
1864
 
1479
1865
  // src/commands/test.ts
1480
- import { parseArgs as parseArgs6 } from "util";
1866
+ import path7 from "path";
1867
+ import { parseArgs as parseArgs7 } from "util";
1481
1868
 
1482
1869
  // 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
1870
  var EXAMPLES_DIR = ".rulecast-examples-";
1486
1871
  function skippedResult(rule) {
1487
1872
  return { ...emptyResult(rule), skipped: true };
@@ -1506,16 +1891,16 @@ async function runExamples(input) {
1506
1891
  ...rule.examples.bad.map((example, index) => ({ kind: "bad", index, example }))
1507
1892
  ];
1508
1893
  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 });
1894
+ for (const { kind, index, example } of cases) {
1895
+ const outcome = await withScratchTree(
1896
+ input.detection.root,
1897
+ EXAMPLES_DIR,
1898
+ /* @__PURE__ */ new Map([[example.path, example.code]]),
1899
+ (scratch) => runOne(input, scratch, example)
1900
+ );
1901
+ const fired2 = outcome.findings.length > 0;
1902
+ const passed = outcome.error === void 0 && fired2 === (kind === "bad");
1903
+ result.outcomes.push({ kind, index, path: example.path, ...outcome, passed });
1519
1904
  }
1520
1905
  const fired = (outcome) => outcome.findings.length > 0;
1521
1906
  const bad = result.outcomes.filter((outcome) => outcome.kind === "bad");
@@ -1526,21 +1911,14 @@ async function runExamples(input) {
1526
1911
  return result;
1527
1912
  }
1528
1913
  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);
1914
+ const relative = `${scratch}/${example.path}`;
1533
1915
  const output = await runDetection({
1534
- root: input.root,
1916
+ detection: input.detection,
1535
1917
  event: "verify",
1536
1918
  selections: [{ rule: input.rule, files: [relative] }],
1537
1919
  // No baseline: an example is entirely new, which is what an author is asking about.
1538
1920
  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,
1921
+ read: (name) => readSourceFile(input.detection.root, name),
1544
1922
  timeoutMs: input.timeoutMs
1545
1923
  });
1546
1924
  const failure = output.errors[0]?.message ?? (output.timedOut.length > 0 ? "timed out" : void 0);
@@ -1548,6 +1926,7 @@ async function runOne(input, scratch, example) {
1548
1926
  const findings = output.findings.map(({ match }) => ({
1549
1927
  line: match.line,
1550
1928
  column: match.column,
1929
+ text: match.text,
1551
1930
  message: renderTemplate(input.rule.message, {
1552
1931
  ...match.captures,
1553
1932
  file: example.path,
@@ -1562,7 +1941,7 @@ async function runOne(input, scratch, example) {
1562
1941
 
1563
1942
  // src/commands/test.ts
1564
1943
  function parseTestArgs(args) {
1565
- const { values, positionals } = parseArgs6({
1944
+ const { values, positionals } = parseArgs7({
1566
1945
  args,
1567
1946
  allowPositionals: true,
1568
1947
  options: { against: { type: "string", multiple: true } }
@@ -1573,14 +1952,35 @@ function parseTestArgs(args) {
1573
1952
  if (against.length > 0 && ruleId === null) throw new UsageError("--against needs a RULE_ID");
1574
1953
  return { ruleId, against };
1575
1954
  }
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
- ];
1955
+ var FEW_VIOLATIONS = 25;
1956
+ var SMALL_SAMPLE = 25;
1957
+ function againstNote(violations, violatingFiles, matchingFiles) {
1958
+ if (matchingFiles < SMALL_SAMPLE) {
1959
+ return [
1960
+ ` Only ${matchingFiles} matching ${matchingFiles === 1 ? "file" : "files"}: too few for these numbers to say how often the rule is broken.`,
1961
+ " Judge the rule by the doc it enforces."
1962
+ ];
1963
+ }
1964
+ if (violations === 0) {
1965
+ return [
1966
+ " Nothing to fix today is not the same as nothing to catch: this counts the",
1967
+ " stock, not how often an edit would break the rule."
1968
+ ];
1969
+ }
1970
+ if (violatingFiles * 2 > matchingFiles) {
1971
+ return [" Most matching files violate it: check it is a convention, not a description of the code."];
1972
+ }
1973
+ if (violations < FEW_VIOLATIONS) {
1974
+ return [
1975
+ " Few violations: a rule nobody breaks costs an agent context and catches little",
1976
+ ` (8 of 15 measured conventions were under ${FEW_VIOLATIONS}).`
1977
+ ];
1978
+ }
1979
+ return [];
1980
+ }
1981
+ var plural4 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1982
+ var touchRule = (id) => `${id} is a touch rule: it delivers context and has nothing to test
1983
+ `;
1584
1984
  var ratio = (part, whole) => whole === 0 ? " \u2014 " : (part / whole).toFixed(2);
1585
1985
  function summaryLine(rule, result) {
1586
1986
  const id = rule.id.padEnd(36);
@@ -1590,6 +1990,11 @@ function summaryLine(rule, result) {
1590
1990
  const score = `${passed}/${result.outcomes.length}`.padEnd(7);
1591
1991
  return ` ${id}${score} P ${ratio(result.precision.correct, result.precision.total)} R ${ratio(result.recall.matched, result.recall.total)}`;
1592
1992
  }
1993
+ var MATCH_PREVIEW = 60;
1994
+ function matchedLine(text) {
1995
+ const shown = text.length > MATCH_PREVIEW ? `${text.slice(0, MATCH_PREVIEW)}\u2026` : text;
1996
+ return `matched ${JSON.stringify(shown)}${/\r?\n/.test(text) ? ", across a line break" : ""}`;
1997
+ }
1593
1998
  function failureLines(rule, result) {
1594
1999
  const out = [];
1595
2000
  for (const outcome of result.outcomes) {
@@ -1606,16 +2011,18 @@ function failureLines(rule, result) {
1606
2011
  const source = (rule.examples?.good[outcome.index]?.code ?? "").split(/\r?\n/);
1607
2012
  for (const finding of outcome.findings) {
1608
2013
  out.push(` ${name} \u2014 fired at line ${finding.line}`, ` ${source[finding.line - 1]?.trim() ?? ""}`);
2014
+ out.push(` ${matchedLine(finding.text)}`);
1609
2015
  }
1610
2016
  }
1611
2017
  return out;
1612
2018
  }
1613
2019
  var AGAINST_TOP_FILES = 10;
1614
- async function reportAgainst(root, rule, paths, detection, io) {
2020
+ async function reportAgainst(root, rule, paths, detection, timeoutMs, io) {
1615
2021
  const prefixes = paths.map((given) => {
2022
+ if (path7.resolve(io.cwd, given) === path7.resolve(root)) return "";
1616
2023
  const resolved = toProjectPath(root, io.cwd, given);
1617
2024
  if (resolved === null) throw new UsageError(`${given} is outside the project`);
1618
- return resolved === "." ? "" : resolved;
2025
+ return resolved;
1619
2026
  });
1620
2027
  const under = (file) => prefixes.some((prefix) => prefix === "" || file === prefix || file.startsWith(`${prefix}/`));
1621
2028
  const files = (await allFiles(root)).filter((file) => under(file) && rule.matches(file));
@@ -1627,12 +2034,12 @@ async function reportAgainst(root, rule, paths, detection, io) {
1627
2034
  return 0;
1628
2035
  }
1629
2036
  const output = await runDetection({
1630
- ...detection,
1631
- root,
2037
+ detection,
1632
2038
  event: "verify",
1633
2039
  selections: [{ rule, files }],
1634
2040
  changes: /* @__PURE__ */ new Map(),
1635
- read: (file) => readSourceFile(root, file)
2041
+ read: (file) => readSourceFile(root, file),
2042
+ timeoutMs
1636
2043
  });
1637
2044
  const failure = output.errors[0];
1638
2045
  if (failure !== void 0) throw new Error(`${rule.detector.kind} detector failed: ${failure.message}`);
@@ -1640,13 +2047,14 @@ async function reportAgainst(root, rule, paths, detection, io) {
1640
2047
  for (const { match } of output.findings) byFile.set(match.file, (byFile.get(match.file) ?? 0) + 1);
1641
2048
  const worst = [...byFile].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, AGAINST_TOP_FILES);
1642
2049
  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`];
2050
+ const violations = output.findings.length;
2051
+ const out = ["", ` ${plural4(violations, "violation")} in ${byFile.size} of ${files.length} matching files`];
1644
2052
  if (worst.length > 0) {
1645
2053
  out.push("", ...worst.map(([file, count]) => ` ${file.padEnd(width + 2)}${count}`));
1646
2054
  if (byFile.size > worst.length) out.push(` \u2026and ${byFile.size - worst.length} more files`);
1647
2055
  }
1648
- out.push("", ...AGAINST_NOTES);
1649
- if (output.findings.length === 0) out.push(...ZERO_NOTES);
2056
+ const note = againstNote(violations, byFile.size, files.length);
2057
+ if (note.length > 0) out.push("", ...note);
1650
2058
  out.push("");
1651
2059
  io.stdout(out.join("\n"));
1652
2060
  return 0;
@@ -1659,28 +2067,34 @@ async function testCommand(root, args, registry, io) {
1659
2067
  throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
1660
2068
  }
1661
2069
  const rules = project.rules.filter(isDetectorRule).filter((rule) => ruleId === null || rule.id === ruleId);
2070
+ if (ruleId !== null && rules.length === 0) {
2071
+ io.stdout(touchRule(ruleId));
2072
+ return 0;
2073
+ }
1662
2074
  const stateDir = ensureProjectState(cacheHome(io.env), root);
1663
2075
  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);
2076
+ const detection = detectionFor(project, registry, stateDir, resolver);
2077
+ const timeoutMs = project.config.timeouts.verifyMs;
2078
+ if (against.length > 0) return await reportAgainst(root, rules[0], against, detection, timeoutMs, io);
1672
2079
  const lines = [""];
1673
2080
  const noExamples = [];
2081
+ const notRun = [];
1674
2082
  let failedRules = 0;
1675
2083
  let ranRules = 0;
1676
2084
  for (const rule of rules) {
1677
- const result = ruleId === null && rule.detector.kind === "llm" ? skippedResult(rule.id) : await runExamples({ ...detection, root, rule });
2085
+ const result = (
2086
+ // Spec §6: a metered rule runs only when asked for by id — never as part of "test them all".
2087
+ ruleId === null && registry.get(rule.detector.kind)?.metered === true ? skippedResult(rule.id) : await runExamples({ detection, rule, timeoutMs })
2088
+ );
1678
2089
  lines.push(summaryLine(rule, result));
1679
2090
  if (result.missing || result.empty) {
1680
2091
  noExamples.push(rule.id);
1681
2092
  continue;
1682
2093
  }
1683
- if (result.skipped) continue;
2094
+ if (result.skipped) {
2095
+ notRun.push(rule.id);
2096
+ continue;
2097
+ }
1684
2098
  ranRules++;
1685
2099
  if (result.outcomes.some((outcome) => !outcome.passed)) {
1686
2100
  failedRules++;
@@ -1692,6 +2106,9 @@ async function testCommand(root, args, registry, io) {
1692
2106
  return 0;
1693
2107
  }
1694
2108
  if (noExamples.length > 0) lines.push("", ` no examples: ${noExamples.join(", ")}`);
2109
+ if (notRun.length > 0) {
2110
+ lines.push("", ` not run: ${notRun.join(", ")} (rulecast test <id> runs one; it costs money per example)`);
2111
+ }
1695
2112
  lines.push(
1696
2113
  "",
1697
2114
  failedRules === 0 ? ranRules === 0 ? " nothing to run" : ` ${ranRules} ${ranRules === 1 ? "rule" : "rules"} passed` : ` ${failedRules} of ${ranRules} rules failed`,
@@ -1703,7 +2120,7 @@ async function testCommand(root, args, registry, io) {
1703
2120
 
1704
2121
  // src/commands/try-repo.ts
1705
2122
  import { existsSync as existsSync5, statSync as statSync2 } from "fs";
1706
- import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
2123
+ import { mkdtemp, rm as rm2 } from "fs/promises";
1707
2124
  import { tmpdir as tmpdir2 } from "os";
1708
2125
  import path8 from "path";
1709
2126
  var isDirectory = (file) => existsSync5(file) && statSync2(file).isDirectory();
@@ -1724,7 +2141,7 @@ async function tryRepoCommand(root, args, registry, io) {
1724
2141
  if (directory && run2.ref !== null && !isGitRepo(local)) throw new UsageError("--ref only applies to URLs");
1725
2142
  const fromDirectory = directory && run2.ref === null;
1726
2143
  const rev = fromDirectory ? "working-tree" : run2.ref ?? "HEAD";
1727
- const temp = fromDirectory ? null : await mkdtemp2(path8.join(tmpdir2(), "rulecast-try-repo-"));
2144
+ const temp = fromDirectory ? null : await mkdtemp(path8.join(tmpdir2(), "rulecast-try-repo-"));
1728
2145
  try {
1729
2146
  const dir = temp ?? local;
1730
2147
  if (temp !== null) await fetchCheckout(directory ? local : repo, rev, temp);
@@ -1743,7 +2160,7 @@ async function tryRepoCommand(root, args, registry, io) {
1743
2160
  const project = await compile({ root, registry, repos: fixedRepo(dir, label), configData });
1744
2161
  return await executeRun({ project, ruleId: null, run: run2, registry, io });
1745
2162
  } finally {
1746
- if (temp !== null) await rm3(temp, { recursive: true, force: true });
2163
+ if (temp !== null) await rm2(temp, { recursive: true, force: true });
1747
2164
  }
1748
2165
  }
1749
2166
 
@@ -1753,19 +2170,38 @@ import path9 from "path";
1753
2170
  function formatDiagnostic(diagnostic) {
1754
2171
  return `${diagnostic.level === "warning" ? "warning: " : ""}${diagnosticText(diagnostic)}`;
1755
2172
  }
2173
+ function kindOf(full, data) {
2174
+ const name = path9.basename(full);
2175
+ if (name === CONFIG_FILE) return "config";
2176
+ if (name === MANIFEST_FILE) return "manifest";
2177
+ if (!data.ok) return null;
2178
+ if (Array.isArray(data.value)) return "manifest";
2179
+ if (typeof data.value === "object" && data.value !== null && "repos" in data.value) return "config";
2180
+ return null;
2181
+ }
1756
2182
  async function validateCommand(root, args, registry, io) {
1757
2183
  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) }));
1758
2184
  if (targets.length === 0) throw new Error(`nothing to validate: no ${CONFIG_FILE} or ${MANIFEST_FILE}`);
1759
- for (const { shown, full } of targets) {
1760
- const name = path9.basename(full);
1761
- if (name !== CONFIG_FILE && name !== MANIFEST_FILE) {
1762
- throw new UsageError(`${shown}: not a ${CONFIG_FILE} or ${MANIFEST_FILE}`);
1763
- }
1764
- }
2185
+ const read = await Promise.all(
2186
+ targets.map(async (target) => {
2187
+ const data = await readYamlFile(target.full);
2188
+ const unparsedYaml = !data.ok && /\.ya?ml$/.test(target.full);
2189
+ const kind = kindOf(target.full, data) ?? (unparsedYaml ? "manifest" : null);
2190
+ if (kind === null) {
2191
+ throw new UsageError(`${target.shown}: neither a config (repos:) nor a manifest (a list of rules)`);
2192
+ }
2193
+ return { ...target, data, kind };
2194
+ })
2195
+ );
1765
2196
  let failed = false;
1766
- for (const { shown, full } of targets) {
2197
+ for (const { shown, full, data, kind } of read) {
1767
2198
  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);
2199
+ const result = kind === "config" ? await compile({
2200
+ root: dir,
2201
+ registry,
2202
+ repos: fetchingRepos(cacheHome(io.env)),
2203
+ configData: data.ok ? data.value : void 0
2204
+ }) : await compileManifest(dir, registry, full);
1769
2205
  for (const diagnostic of result.diagnostics) io.stdout(`${shown}: ${formatDiagnostic(diagnostic)}
1770
2206
  `);
1771
2207
  if (result.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
@@ -1780,10 +2216,10 @@ async function validateCommand(root, args, registry, io) {
1780
2216
  }
1781
2217
 
1782
2218
  // src/commands/warm.ts
1783
- import { parseArgs as parseArgs7 } from "util";
2219
+ import { parseArgs as parseArgs8 } from "util";
1784
2220
  var WARM_TIMEOUT_MS = 5 * 6e4;
1785
2221
  async function warmCommand(root, args, registry, io) {
1786
- const { values } = parseArgs7({ args, options: { detector: { type: "string", multiple: true } } });
2222
+ const { values } = parseArgs8({ args, options: { detector: { type: "string", multiple: true } } });
1787
2223
  if (!hasProject(root)) throw new Error(`no .rulecast-config.yaml in ${root} or its parents (run rulecast init)`);
1788
2224
  const home = cacheHome(io.env);
1789
2225
  const stateDir = ensureProjectState(home, root);
@@ -1813,24 +2249,23 @@ async function warmCommand(root, args, registry, io) {
1813
2249
  }
1814
2250
 
1815
2251
  // src/commands/main.ts
1816
- var USAGE = `usage:
1817
- rulecast init [--rules id,id | --no-rules] [--agent <name>]... [--scope shared|personal] [--yes]
1818
- rulecast install [--agent <name>]... [--scope shared|personal]
1819
- rulecast uninstall [--agent <name>]...
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]
1821
- rulecast autoupdate [--freeze] [--repo URL]...
1822
- rulecast try-repo <path|url> [RULE_ID] [--ref REV] [run flags]
1823
- rulecast test [RULE_ID]
1824
- rulecast validate [file...]
1825
- rulecast clean [--project]
1826
- rulecast hook <adapter>
1827
- rulecast warm [--detector <kind>]...
1828
- rulecast doctor
1829
- `;
1830
2252
  async function main(argv, io) {
1831
2253
  const [command, ...args] = argv;
1832
2254
  const registry = createRegistry([...builtinDetectors]);
1833
2255
  const root = findRoot(io.cwd);
2256
+ if (command === "--version" || command === "-v" || command === "version") {
2257
+ io.stdout(`${VERSION}
2258
+ `);
2259
+ return 0;
2260
+ }
2261
+ const help = command === "help" && args[0] !== void 0 ? args[0] : args.includes("--help") || args.includes("-h") ? command : null;
2262
+ if (help !== null && help !== void 0) {
2263
+ const text = commandHelp(help);
2264
+ if (text !== null) {
2265
+ io.stdout(text);
2266
+ return 0;
2267
+ }
2268
+ }
1834
2269
  try {
1835
2270
  switch (command) {
1836
2271
  case "init":
@@ -1841,6 +2276,8 @@ async function main(argv, io) {
1841
2276
  return await uninstallCommand(root, args, io);
1842
2277
  case "run":
1843
2278
  return await runCommand(root, args, registry, io);
2279
+ case "list":
2280
+ return await listCommand(root, args, registry, io);
1844
2281
  case "autoupdate":
1845
2282
  return await autoupdateCommand(root, args, io);
1846
2283
  case "try-repo":
@@ -1860,7 +2297,8 @@ async function main(argv, io) {
1860
2297
  case void 0:
1861
2298
  case "help":
1862
2299
  case "--help":
1863
- io.stdout(USAGE);
2300
+ case "-h":
2301
+ io.stdout(usage());
1864
2302
  return command === void 0 ? 2 : 0;
1865
2303
  default:
1866
2304
  throw new UsageError(`unknown command "${command}"`);
@@ -1869,7 +2307,8 @@ async function main(argv, io) {
1869
2307
  io.stderr(`rulecast: ${errorMessage(error)}
1870
2308
  `);
1871
2309
  if (error instanceof UsageError || error.code?.startsWith("ERR_PARSE_ARGS")) {
1872
- io.stderr(USAGE);
2310
+ io.stderr(`
2311
+ ${command !== void 0 && commandHelp(command) || usage()}`);
1873
2312
  }
1874
2313
  return 2;
1875
2314
  }