@syv-ai/rulecast 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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-MB4F7ZXC.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-Y3FXJRZB.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-4XS34SYW.js";
64
68
  import {
65
69
  Cancelled
66
70
  } from "./chunk-K37BHCEK.js";
@@ -211,21 +215,33 @@ 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")));
221
+ let partial = null;
217
222
  for (const { file } of install.scopes) {
218
223
  const current = await readSettings(root, file);
219
224
  if (current === null) continue;
220
- if (inFile(file, () => install.merge(current.value, command, verifyMs)).added.length === 0) return file;
225
+ const merged = inFile(file, () => install.merge(current.value, command, verifyMs));
226
+ if (merged.added.length === 0) return { file, stale: merged.updated ?? [], missing: [] };
227
+ const ours = inFile(file, () => install.remove(current.value)).removed.length > 0;
228
+ if (ours && partial === null) partial = { file, stale: merged.updated ?? [], missing: merged.added };
221
229
  }
222
- return null;
230
+ return partial;
223
231
  }
224
232
  async function installHooks(root, adapter, scope, verifyMs) {
225
233
  const install = installOf(adapter);
226
234
  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: [] };
235
+ const already = await hookState(root, adapter, verifyMs);
236
+ if (already !== null && already.stale.length === 0 && already.missing.length === 0) {
237
+ return { file: already.file, added: [] };
238
+ }
239
+ if (already !== null) {
240
+ const current2 = (await readSettings(root, already.file))?.value ?? {};
241
+ const merged2 = inFile(already.file, () => install.merge(current2, command, verifyMs));
242
+ await writeSettings(root, already.file, merged2.settings);
243
+ return { file: already.file, added: merged2.added, updated: merged2.updated ?? [] };
244
+ }
229
245
  const target = install.scopes.find((candidate) => candidate.scope === scope);
230
246
  if (!target) throw new UsageError(`${adapter.name} has no ${scope} settings`);
231
247
  const current = (await readSettings(root, target.file))?.value ?? {};
@@ -262,11 +278,19 @@ async function loadProjectConfig(root, command) {
262
278
  return config.value;
263
279
  }
264
280
  function printInstall(io, adapter, result) {
265
- io.stdout(
266
- result.added.length === 0 ? `${adapter.label} hooks already installed in ${result.file}
267
- ` : `installed ${adapter.label} hooks in ${result.file}: ${result.added.join(", ")}
268
- `
269
- );
281
+ const updated = result.updated ?? [];
282
+ if (updated.length > 0) {
283
+ io.stdout(`updated ${adapter.label} hooks in ${result.file} to the current command: ${updated.join(", ")}
284
+ `);
285
+ }
286
+ if (result.added.length > 0) {
287
+ io.stdout(`installed ${adapter.label} hooks in ${result.file}: ${result.added.join(", ")}
288
+ `);
289
+ }
290
+ if (updated.length === 0 && result.added.length === 0) {
291
+ io.stdout(`${adapter.label} hooks already installed in ${result.file}
292
+ `);
293
+ }
270
294
  }
271
295
  async function fetchMissingRepos(config, io) {
272
296
  const home = cacheHome(io.env);
@@ -399,6 +423,10 @@ function naming(rules) {
399
423
  if (rules.length <= 3) return ` (${rules.join(", ")})`;
400
424
  return ` (${rules.slice(0, 3).join(", ")} and ${rules.length - 3} more)`;
401
425
  }
426
+ function missingNames(groups) {
427
+ const names = groups.map((group) => /ToolUse/.test(group) ? /\((.*)\)$/.exec(group)?.[1] ?? group : group);
428
+ return [...new Set(names)].join(", ");
429
+ }
402
430
  function plural(count, noun) {
403
431
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
404
432
  }
@@ -452,10 +480,13 @@ async function doctorCommand(root, args, registry, io) {
452
480
  const resolves = await hookCommandResolves(root);
453
481
  for (const adapter of ADAPTERS) {
454
482
  if (adapter.install === null) continue;
455
- const file = await hooksInstalled(root, adapter, project.config.timeouts.verifyMs);
456
- const level = file === null || !resolves ? "warning" : "ok";
483
+ const state = await hookState(root, adapter, project.config.timeouts.verifyMs);
484
+ const file = state?.file ?? null;
485
+ const stale = (state?.stale ?? []).length > 0;
486
+ const missing = state?.missing ?? [];
487
+ const level = file === null || !resolves || stale || missing.length > 0 ? "warning" : "ok";
457
488
  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.`;
489
+ const detail = file === null ? "not installed (run rulecast install)" : missing.length > 0 ? `${file} \u2014 but hooks for ${missingNames(missing)} missing; 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
490
  io.stdout(line(level, adapter.label, detail));
460
491
  }
461
492
  io.stdout("\ncache\n");
@@ -471,12 +502,14 @@ async function doctorCommand(root, args, registry, io) {
471
502
  `);
472
503
  }
473
504
  io.stdout("\ndry run\n");
474
- const dry = await dryRun(project, registry);
505
+ const dry = await dryRun(project, detectionFor(project, registry, stateDir, createReferenceResolver(root)));
475
506
  if (dry.length === 0) io.stdout(" nothing to run\n");
476
507
  for (const result of dry) {
477
508
  count(result.level);
478
509
  io.stdout(line(result.level, result.rule, result.detail));
479
510
  }
511
+ if (dry.some((result) => result.tried)) io.stdout(` ${DRY_RUN_FOOTER}
512
+ `);
480
513
  io.stdout(
481
514
  `
482
515
  ${errors + warnings === 0 ? "no problems found" : [plural(errors, "error"), plural(warnings, "warning")].join(", ")}
@@ -484,7 +517,8 @@ ${errors + warnings === 0 ? "no problems found" : [plural(errors, "error"), plur
484
517
  );
485
518
  return errors > 0 ? 2 : 0;
486
519
  }
487
- async function dryRun(project, registry) {
520
+ var DRY_RUN_FOOTER = "the dry run tries one file per rule; rulecast run --all-files checks them all";
521
+ async function dryRun(project, detection) {
488
522
  if (project.rules.length === 0) return [];
489
523
  let files;
490
524
  try {
@@ -494,31 +528,38 @@ async function dryRun(project, registry) {
494
528
  }
495
529
  const lines = [];
496
530
  for (const rule of project.rules) {
531
+ if (!rule.enabled) {
532
+ lines.push({ rule: rule.id, level: "skipped", detail: "disabled" });
533
+ continue;
534
+ }
497
535
  if (!isDetectorRule(rule)) {
498
536
  lines.push({ rule: rule.id, level: "ok", detail: "context only, nothing to run" });
499
537
  continue;
500
538
  }
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)" });
539
+ if (detection.registry.get(rule.detector.kind)?.metered === true) {
540
+ lines.push({
541
+ rule: rule.id,
542
+ level: "skipped",
543
+ detail: `${rule.detector.kind} rules are not dry-run (each run costs money)`
544
+ });
503
545
  continue;
504
546
  }
505
- const file = files.find((candidate) => rule.matches(candidate));
547
+ const matching = files.filter((candidate) => rule.matches(candidate));
548
+ const file = matching[0];
506
549
  if (file === void 0) {
507
550
  lines.push({ rule: rule.id, level: "warning", detail: "no file in the project matches this rule" });
508
551
  continue;
509
552
  }
510
553
  const timeoutMs = project.config.timeouts.verifyMs;
511
554
  const output = await runDetection({
512
- root: project.root,
555
+ // Spread over the project's context, so the two deviations read as deviations. The memory
556
+ // cache is the reason given in this function's own comment; context is unused because only
557
+ // the llm detector reads it and llm rules never reach here.
558
+ detection: { ...detection, cacheFor: () => memoryCache(), contextFor: async () => [] },
513
559
  event: "verify",
514
560
  selections: [{ rule, files: [file] }],
515
561
  changes: /* @__PURE__ */ new Map(),
516
562
  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
563
  timeoutMs
523
564
  });
524
565
  const failure = output.errors[0];
@@ -534,12 +575,150 @@ async function dryRun(project, registry) {
534
575
  lines.push({
535
576
  rule: rule.id,
536
577
  level: "ok",
537
- detail: `${file}, ${matches === 0 ? "no match" : matches === 1 ? "1 match" : `${matches} matches`}`
578
+ detail: `tried ${file} (1 of ${plural(matching.length, "matching file")}): ${matches === 0 ? "no match" : matches === 1 ? "1 match" : `${matches} matches`}`,
579
+ tried: true
538
580
  });
539
581
  }
540
582
  return lines;
541
583
  }
542
584
 
585
+ // src/commands/help.ts
586
+ var COMMANDS = [
587
+ {
588
+ name: "init",
589
+ synopsis: "rulecast init [--rules id,id | --no-rules] [--agent <name>... | --no-agents] [--scope shared|personal] [--yes]",
590
+ summary: "Set rulecast up: config, catalog rules, agent hooks",
591
+ flags: [
592
+ ["--rules id,id", "Install these catalog rules instead of the ones that apply to your files"],
593
+ ["--no-rules", "Install no catalog rules"],
594
+ ["--agent <name>", "Install hooks for this agent (claude-code); repeatable"],
595
+ ["--no-agents", "Install no agent hooks"],
596
+ ["--scope shared|personal", "Write hooks to the shared or the personal settings file"],
597
+ ["--yes", "Take every default and write without asking"]
598
+ ]
599
+ },
600
+ {
601
+ name: "install",
602
+ synopsis: "rulecast install [--agent <name>]... [--scope shared|personal]",
603
+ summary: "Add the agent hooks, or upgrade them, and fetch missing rule repos",
604
+ flags: [
605
+ ["--agent <name>", "Only this agent; repeatable. Default: every supported agent"],
606
+ ["--scope shared|personal", "Write to the shared or the personal settings file"]
607
+ ]
608
+ },
609
+ {
610
+ name: "uninstall",
611
+ synopsis: "rulecast uninstall [--agent <name>]...",
612
+ summary: "Remove the agent hooks rulecast added",
613
+ flags: [["--agent <name>", "Only this agent; repeatable"]]
614
+ },
615
+ {
616
+ name: "run",
617
+ 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]",
618
+ summary: "Check staged files (the default), changed files, or everything",
619
+ flags: [
620
+ ["RULE_ID", "Run only this rule"],
621
+ ["--all-files", "Every file git knows about, with the adoption backlog"],
622
+ ["--files F...", "These files"],
623
+ ["--from-ref A", "Files changed since the merge base with A, judged against it"],
624
+ ["--to-ref B", "With --from-ref: up to B, read at B (pre-push, CI)"],
625
+ ["--summary", "Print the backlog instead of every finding"],
626
+ ["--format", "terminal, agent, json or sarif"],
627
+ ["--session <id>", "The files an agent session edited, as at its Stop"],
628
+ ["--llm", "Include metered (llm) rules in a staged run, which skips them by default"],
629
+ ["--no-llm", "Skip metered (llm) rules in any run"]
630
+ ]
631
+ },
632
+ {
633
+ name: "list",
634
+ synopsis: "rulecast list [RULE_ID] [--format terminal|json]",
635
+ summary: "Show every configured rule: where it comes from, what it matches, what it cites",
636
+ flags: [
637
+ ["RULE_ID", "Only this rule, with its message and the text of the sections it cites"],
638
+ ["--format", "terminal or json"]
639
+ ]
640
+ },
641
+ {
642
+ name: "test",
643
+ synopsis: "rulecast test [RULE_ID] [--against PATH...]",
644
+ summary: "Run each rule's good/bad examples; --against says how much it would flag",
645
+ flags: [
646
+ ["RULE_ID", "Only this rule. Needed for llm rules, which cost money per example"],
647
+ ["--against PATH...", "With RULE_ID: fire the rule over real files and count what it finds"]
648
+ ]
649
+ },
650
+ {
651
+ name: "validate",
652
+ synopsis: "rulecast validate [file...]",
653
+ summary: "Check the config (and a rules manifest) and print diagnostics",
654
+ flags: [["file...", "A config (repos:) or a manifest (a list of rules), whatever its name"]]
655
+ },
656
+ {
657
+ name: "doctor",
658
+ synopsis: "rulecast doctor",
659
+ summary: "Compile, check the environment and hooks, dry-run every rule",
660
+ flags: []
661
+ },
662
+ {
663
+ name: "autoupdate",
664
+ synopsis: "rulecast autoupdate [--freeze] [--repo URL]...",
665
+ summary: "Move pinned rule repos to their latest tag",
666
+ flags: [
667
+ ["--freeze", "Pin the commit SHA, with the tag in a comment"],
668
+ ["--repo URL", "Only this repo; repeatable"]
669
+ ]
670
+ },
671
+ {
672
+ name: "try-repo",
673
+ synopsis: "rulecast try-repo <path|url> [RULE_ID] [--ref REV] [run flags]",
674
+ summary: "Run a rule repo against your project without configuring it",
675
+ flags: [["--ref REV", "The revision of a URL repo (default HEAD)"]]
676
+ },
677
+ {
678
+ name: "clean",
679
+ synopsis: "rulecast clean [--project]",
680
+ summary: "Delete the cache",
681
+ flags: [["--project", "Only this project's directory"]]
682
+ },
683
+ {
684
+ name: "warm",
685
+ synopsis: "rulecast warm [--detector <kind>]...",
686
+ summary: "Build detector caches ahead of time",
687
+ flags: [["--detector <kind>", "Only this detector kind; repeatable"]]
688
+ },
689
+ {
690
+ name: "hook",
691
+ synopsis: "rulecast hook <adapter>",
692
+ summary: "Answer an agent hook on stdin (the agent runs this, not you)",
693
+ flags: []
694
+ }
695
+ ];
696
+ var NAME_WIDTH = Math.max(...COMMANDS.map((command) => command.name.length));
697
+ function usage() {
698
+ return [
699
+ `rulecast ${VERSION}`,
700
+ "",
701
+ "usage: rulecast <command> [flags]",
702
+ "",
703
+ ...COMMANDS.map((command) => ` ${command.name.padEnd(NAME_WIDTH)} ${command.summary}`),
704
+ "",
705
+ "rulecast <command> --help shows a command's flags.",
706
+ ""
707
+ ].join("\n");
708
+ }
709
+ function commandHelp(name) {
710
+ const command = COMMANDS.find((candidate) => candidate.name === name);
711
+ if (command === void 0) return null;
712
+ const width = Math.max(0, ...command.flags.map(([flag]) => flag.length));
713
+ return [
714
+ command.synopsis,
715
+ "",
716
+ command.summary,
717
+ ...command.flags.length === 0 ? [] : ["", ...command.flags.map(([flag, text]) => ` ${flag.padEnd(width)} ${text}`)],
718
+ ""
719
+ ].join("\n");
720
+ }
721
+
543
722
  // src/core/detection/warm.ts
544
723
  import path4 from "path";
545
724
  var WARM_LOCK = { waitMs: 0, staleMs: 10 * 6e4, retryMs: 20 };
@@ -775,6 +954,26 @@ function detectionSummary(detection) {
775
954
  }
776
955
  return parts.length === 0 ? "nothing yet" : parts.join(" \xB7 ");
777
956
  }
957
+ var STOPWORDS = /* @__PURE__ */ new Set(["the", "a", "an", "in", "of", "and", "or", "to", "for", "md", "mdx", "readme"]);
958
+ var words = (text) => new Set(
959
+ text.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w !== "" && !STOPWORDS.has(w))
960
+ );
961
+ function matchHeading(spec, docs) {
962
+ const stem = path5.basename(spec.path).replace(/\.[^.]+$/, "");
963
+ const wanted = words(`${stem} ${spec.anchor ?? ""}`);
964
+ let best = null;
965
+ for (const doc of docs) {
966
+ for (const heading of doc.headings) {
967
+ if (heading.slug === "") continue;
968
+ const score = [...words(heading.text)].filter((word) => wanted.has(word)).length;
969
+ if (score === 0) continue;
970
+ if (best === null || score > best.score || score === best.score && heading.level < best.level) {
971
+ best = { value: `@${doc.file}#${heading.slug}`, score, level: heading.level };
972
+ }
973
+ }
974
+ }
975
+ return best?.value ?? null;
976
+ }
778
977
  function docChoices(doc) {
779
978
  const choices = [{ value: `@${doc.file}`, label: doc.file }];
780
979
  const trail = [];
@@ -869,13 +1068,13 @@ function addCatalogRules(text, catalog, rules) {
869
1068
  const entry = repos.items.find((item) => isMap2(item) && item.get("repo") === catalog.url);
870
1069
  let edit;
871
1070
  if (entry) {
872
- const listed = entry.get("rules", true);
1071
+ const listed2 = entry.get("rules", true);
873
1072
  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));
1073
+ if (!isSeq2(listed2) || rulesKey === null) throw new ConfigTextError(`${catalog.url} has no rules list`);
1074
+ const present = new Set(listed2.items.map((item) => isMap2(item) ? item.get("id") : void 0));
876
1075
  const added = rules.filter((rule) => !present.has(rule.id));
877
1076
  if (added.length === 0) return text;
878
- edit = appendToSeq(text, listed, rulesKey, added.flatMap(ruleLines));
1077
+ edit = appendToSeq(text, listed2, rulesKey, added.flatMap(ruleLines));
879
1078
  } else {
880
1079
  if (rules.length === 0) return text;
881
1080
  edit = appendToSeq(text, repos, reposKey, repoLines(catalog, rules));
@@ -928,7 +1127,19 @@ async function planAgent(choice, context) {
928
1127
  };
929
1128
  for (const { file } of install.scopes) {
930
1129
  const text2 = await context.readText(file);
931
- if (text2 !== null && merge(parseSettings(text2, file), file).added.length === 0) return null;
1130
+ if (text2 === null) continue;
1131
+ const merged2 = merge(parseSettings(text2, file), file);
1132
+ if (merged2.added.length > 0) continue;
1133
+ const updated = merged2.updated ?? [];
1134
+ if (updated.length === 0) return null;
1135
+ return {
1136
+ file,
1137
+ content: `${JSON.stringify(merged2.settings, null, 2)}
1138
+ `,
1139
+ created: false,
1140
+ summary: `~${plural2(updated.length, "hook")} updated (${choice.adapter.label})`,
1141
+ commit: install.scopes.find((entry) => entry.file === file)?.scope === "shared"
1142
+ };
932
1143
  }
933
1144
  const target = install.scopes.find((entry) => entry.scope === choice.scope);
934
1145
  if (target === void 0) throw new PlanError(`${choice.adapter.label} has no ${choice.scope} settings`);
@@ -959,6 +1170,7 @@ function reviewText(changes) {
959
1170
  }
960
1171
 
961
1172
  // src/commands/init.ts
1173
+ var NO_HOOKS = "No agent hooks: rulecast will not run in your agent until you run rulecast install --agent <name>.";
962
1174
  var DEFAULT_CATALOG = "https://github.com/syv-ai/rulecast";
963
1175
  var SCOPES = ["shared", "personal"];
964
1176
  var installable = () => ADAPTERS.filter((adapter) => adapter.install !== null);
@@ -969,11 +1181,13 @@ function parseFlags(args) {
969
1181
  rules: { type: "string" },
970
1182
  "no-rules": { type: "boolean", default: false },
971
1183
  agent: { type: "string", multiple: true },
1184
+ "no-agents": { type: "boolean", default: false },
972
1185
  scope: { type: "string" },
973
1186
  yes: { type: "boolean", default: false }
974
1187
  }
975
1188
  });
976
1189
  if (values.rules !== void 0 && values["no-rules"]) throw new UsageError("use --rules or --no-rules, not both");
1190
+ if (values.agent !== void 0 && values["no-agents"]) throw new UsageError("use --agent or --no-agents, not both");
977
1191
  const scope = values.scope ?? null;
978
1192
  if (scope !== null && !SCOPES.includes(scope)) {
979
1193
  throw new UsageError(`unknown scope "${scope}" (use shared or personal)`);
@@ -987,7 +1201,14 @@ function parseFlags(args) {
987
1201
  return adapter;
988
1202
  });
989
1203
  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 };
1204
+ return {
1205
+ rules,
1206
+ noRules: values["no-rules"],
1207
+ agents,
1208
+ noAgents: values["no-agents"],
1209
+ scope,
1210
+ yes: values.yes
1211
+ };
991
1212
  }
992
1213
  function projectRoot(cwd) {
993
1214
  const found = findRoot(cwd);
@@ -1035,7 +1256,8 @@ var idOf = (entry) => {
1035
1256
  return typeof id === "string" ? id : null;
1036
1257
  };
1037
1258
  var groupOf = (id) => id.includes("/") ? id.slice(0, id.indexOf("/")) : "general";
1038
- async function chooseRules(catalog, installed, files, flags, ui) {
1259
+ async function chooseRules(catalog, installed, files, flags, ui, registry) {
1260
+ const metered = (rule) => rule.detector !== null && registry.get(rule.detector.kind)?.metered === true;
1039
1261
  const available = catalog.rules.filter((rule) => !installed.has(rule.id));
1040
1262
  if (flags.noRules) return [];
1041
1263
  if (flags.rules !== null) {
@@ -1044,7 +1266,7 @@ async function chooseRules(catalog, installed, files, flags, ui) {
1044
1266
  if (unknown !== void 0) throw new UsageError(`"${unknown}" is not a rule in ${catalog.label}`);
1045
1267
  return available.filter((rule) => flags.rules.includes(rule.id));
1046
1268
  }
1047
- const preselected = available.filter((rule) => rule.detector?.kind !== "llm" && files.some((file) => rule.matches(file))).map((rule) => rule.id);
1269
+ const preselected = available.filter((rule) => !metered(rule) && files.some((file) => rule.matches(file))).map((rule) => rule.id);
1048
1270
  if (ui.prompter === null) return available.filter((rule) => preselected.includes(rule.id));
1049
1271
  if (available.length === 0) {
1050
1272
  ui.say(`Every rule from ${catalog.label} is already configured.`, "Rules");
@@ -1058,7 +1280,7 @@ async function chooseRules(catalog, installed, files, flags, ui) {
1058
1280
  choices.push({
1059
1281
  value: rule.id,
1060
1282
  label: group === "general" ? rule.id : rule.id.slice(group.length + 1),
1061
- hint: done ? "installed" : hintFor(rule),
1283
+ hint: done ? "installed" : hintFor(rule, registry),
1062
1284
  disabled: done
1063
1285
  });
1064
1286
  groups[group] = choices;
@@ -1070,11 +1292,10 @@ async function chooseRules(catalog, installed, files, flags, ui) {
1070
1292
  });
1071
1293
  return available.filter((rule) => chosen.includes(rule.id));
1072
1294
  }
1073
- function hintFor(rule) {
1295
+ function hintFor(rule, registry) {
1074
1296
  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}`;
1297
+ const cost = rule.detector === null ? void 0 : registry.get(rule.detector.kind)?.cost?.(rule.detector.config);
1298
+ return cost === void 0 ? description : `${cost} \u2014 ${description}`;
1078
1299
  }
1079
1300
  async function chooseConventions(rules, detection, ui) {
1080
1301
  const choices = detection.docs.flatMap(docChoices);
@@ -1089,20 +1310,27 @@ async function chooseConventions(rules, detection, ui) {
1089
1310
  label: "keep the package's doc",
1090
1311
  hint: rule.context.map((spec) => spec.ref).join(", ")
1091
1312
  };
1313
+ const matched = rule.context.map((spec) => matchHeading(spec, detection.docs)).find((value) => value !== null);
1092
1314
  const answer = await ui.prompter.select({
1093
1315
  message: `Conventions for ${rule.id}`,
1094
1316
  choices: [keep, ...choices],
1095
- initialValue: ""
1317
+ initialValue: matched !== void 0 && choices.some((choice) => choice.value === matched) ? matched : ""
1096
1318
  });
1097
1319
  selections.push({ id: rule.id, context: answer === "" ? null : [answer] });
1098
1320
  }
1099
1321
  return selections;
1100
1322
  }
1323
+ function defaultAgents(detected, adapters) {
1324
+ const found = adapters.filter((adapter) => detected.includes(adapter.name));
1325
+ return found.length > 0 ? found : [...adapters];
1326
+ }
1101
1327
  async function chooseAgents(detection, flags, ui) {
1102
1328
  const detected = detection.agents.filter((agent) => agent.adapter !== null).map((agent) => agent.name);
1329
+ const defaults = defaultAgents(detected, installable());
1103
1330
  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));
1331
+ if (flags.noAgents) chosen = [];
1332
+ else if (flags.agents.length > 0) chosen = flags.agents;
1333
+ else if (ui.prompter === null) chosen = defaults;
1106
1334
  else {
1107
1335
  const choices = installable().map((adapter) => ({
1108
1336
  value: adapter.name,
@@ -1119,12 +1347,13 @@ async function chooseAgents(detection, flags, ui) {
1119
1347
  });
1120
1348
  }
1121
1349
  }
1122
- const names = await ui.prompter.multiselect({ message: "Install hooks for", choices, initialValues: detected });
1350
+ const names = await ui.prompter.multiselect({
1351
+ message: "Install hooks for",
1352
+ choices,
1353
+ initialValues: defaults.map((adapter) => adapter.name)
1354
+ });
1123
1355
  chosen = installable().filter((adapter) => names.includes(adapter.name));
1124
1356
  }
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
1357
  const agents = [];
1129
1358
  for (const adapter of chosen) {
1130
1359
  let scope = flags.scope ?? "shared";
@@ -1183,7 +1412,7 @@ async function run(root, flags, registry, io, ui) {
1183
1412
  const catalog = typeof loaded === "string" ? null : loaded;
1184
1413
  if (typeof loaded === "string") ui.say(`${loaded}. Continuing without catalog rules.`, "Catalog unavailable");
1185
1414
  const installed = new Set((configured?.rules ?? []).map(idOf).filter((id) => id !== null));
1186
- const rules = catalog === null ? [] : await chooseRules(catalog, installed, files, flags, ui);
1415
+ const rules = catalog === null ? [] : await chooseRules(catalog, installed, files, flags, ui, registry);
1187
1416
  const selections = await chooseConventions(rules, detection, ui);
1188
1417
  const agents = await chooseAgents(detection, flags, ui);
1189
1418
  const changes = await planInit(
@@ -1195,9 +1424,10 @@ async function run(root, flags, registry, io, ui) {
1195
1424
  readText: (file) => readSourceFile(root, file)
1196
1425
  }
1197
1426
  );
1198
- if (changes.length === 0) ui.say("Nothing to change.", "Review");
1427
+ const noHooks = agents.length === 0 ? [NO_HOOKS] : [];
1428
+ if (changes.length === 0) ui.say(["Nothing to change.", ...noHooks].join("\n"), "Review");
1199
1429
  else {
1200
- ui.say(reviewText(changes), "Review");
1430
+ ui.say([reviewText(changes), ...noHooks].join("\n"), "Review");
1201
1431
  if (ui.prompter !== null) {
1202
1432
  if (!await ui.prompter.confirm({ message: "Write?", initialValue: true })) {
1203
1433
  ui.prompter.outro("Nothing written.");
@@ -1249,20 +1479,158 @@ async function initCommand(args, registry, io) {
1249
1479
  }
1250
1480
  }
1251
1481
 
1252
- // src/commands/run.ts
1482
+ // src/commands/list.ts
1253
1483
  import { parseArgs as parseArgs5 } from "util";
1484
+ import { stringify } from "yaml";
1485
+ function listed(rule, files) {
1486
+ return {
1487
+ id: rule.id,
1488
+ name: rule.name,
1489
+ source: rule.source,
1490
+ enabled: rule.enabled,
1491
+ stages: [...rule.stages],
1492
+ detector: rule.detector?.kind ?? null,
1493
+ config: rule.detector?.config ?? null,
1494
+ severity: rule.severity,
1495
+ files: rule.patterns.files,
1496
+ exclude: rule.patterns.exclude,
1497
+ matchingFiles: files === null ? null : files.filter((file) => rule.matches(file)).length,
1498
+ context: rule.context.map((spec) => spec.ref)
1499
+ };
1500
+ }
1501
+ async function projectFiles(root) {
1502
+ try {
1503
+ return await allFiles(root);
1504
+ } catch {
1505
+ return null;
1506
+ }
1507
+ }
1508
+ var MATCHING_SHOWN = 20;
1509
+ function matchingText(count) {
1510
+ if (count === null) return "";
1511
+ if (count === 0) return " \u2014 matches no file in this project";
1512
+ return ` \u2014 matches ${count} ${count === 1 ? "file" : "files"}`;
1513
+ }
1514
+ async function listCommand(root, args, registry, io) {
1515
+ const { values, positionals } = parseArgs5({
1516
+ args,
1517
+ allowPositionals: true,
1518
+ options: { format: { type: "string", default: "terminal" } }
1519
+ });
1520
+ if (positionals.length > 1) throw new UsageError(`unexpected arguments: ${positionals.slice(1).join(" ")}`);
1521
+ if (values.format !== "terminal" && values.format !== "json") {
1522
+ throw new UsageError(`unknown format "${values.format}" (use terminal or json)`);
1523
+ }
1524
+ if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents`);
1525
+ const ruleId = positionals[0] ?? null;
1526
+ const project = await compile({ root, registry, repos: fetchingRepos(cacheHome(io.env)) });
1527
+ const compiled = project.rules.filter((rule) => ruleId === null || rule.id === ruleId);
1528
+ if (ruleId !== null && compiled.length === 0) throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
1529
+ const files = await projectFiles(root);
1530
+ const rules = compiled.map((rule) => listed(rule, files));
1531
+ if (ruleId !== null) {
1532
+ const rule = compiled[0];
1533
+ const resolver = createReferenceResolver(root);
1534
+ rules[0].matching = (files ?? []).filter((file) => rule.matches(file));
1535
+ rules[0].message = rule.message;
1536
+ rules[0].sections = await Promise.all(
1537
+ rule.context.map(async (spec) => {
1538
+ const resolved = await resolver.resolve(spec);
1539
+ return { ref: spec.ref, content: resolved.found ? resolved.content : null };
1540
+ })
1541
+ );
1542
+ }
1543
+ if (values.format === "json") {
1544
+ io.stdout(`${JSON.stringify(rules, null, 2)}
1545
+ `);
1546
+ return 0;
1547
+ }
1548
+ const out = [];
1549
+ for (const rule of rules) {
1550
+ const what = rule.detector === null ? "touch" : `${rule.detector} \xB7 ${rule.stages.join(", ")} \xB7 ${rule.severity}`;
1551
+ out.push(`${rule.id}${rule.enabled ? "" : " (disabled)"} ${what} [${rule.source}]`);
1552
+ out.push(` ${rule.name}`);
1553
+ const scope = `files ${rule.files === "" ? "(all)" : rule.files}${rule.exclude === "^$" ? "" : `, exclude ${rule.exclude}`}`;
1554
+ out.push(` ${scope}${matchingText(rule.matchingFiles)}`);
1555
+ if (rule.config !== null) {
1556
+ out.push(
1557
+ " detects:",
1558
+ ...stringify(rule.config).trimEnd().split("\n").map((line2) => ` ${line2}`)
1559
+ );
1560
+ }
1561
+ if (rule.matching !== void 0 && rule.matching.length > 0) {
1562
+ const shown = rule.matching.slice(0, MATCHING_SHOWN);
1563
+ const more = rule.matching.length - shown.length;
1564
+ out.push(` matching ${shown.join(", ")}${more > 0 ? ` and ${more} more` : ""}`);
1565
+ }
1566
+ if (rule.message !== void 0 && rule.message !== null) out.push(` message ${rule.message}`);
1567
+ if (rule.context.length > 0) out.push(` cites ${rule.context.join(", ")}`);
1568
+ out.push("");
1569
+ for (const section of rule.sections ?? []) {
1570
+ if (section.content === null) out.push(`--- ${section.ref} --- not found`, "");
1571
+ else out.push(`--- ${section.ref} ---`, section.content, "");
1572
+ }
1573
+ }
1574
+ if (ruleId === null) {
1575
+ const errors = project.diagnostics.filter((diagnostic) => diagnostic.level === "error");
1576
+ for (const diagnostic of errors) out.push(`not compiled: ${diagnosticText(diagnostic)}`);
1577
+ out.push(
1578
+ `${rules.length} ${rules.length === 1 ? "rule" : "rules"}${errors.length > 0 ? `, ${errors.length} not compiled (rulecast validate)` : ""}`,
1579
+ ""
1580
+ );
1581
+ out.push("rulecast list <rule-id> shows one rule with the text of the sections it cites.");
1582
+ }
1583
+ io.stdout(`${out.join("\n").trimEnd()}
1584
+ `);
1585
+ return 0;
1586
+ }
1587
+
1588
+ // src/commands/run.ts
1589
+ import { parseArgs as parseArgs6 } from "util";
1254
1590
 
1255
1591
  // src/adapters/cli/format.ts
1256
1592
  var CLI_FORMATS = ["terminal", "agent", "json", "sarif"];
1593
+ function ignoredLine(ignored) {
1594
+ return `ignored: ${plural3(ignored.length, "finding")} by rulecast-ignore comments (rulecast run --format json lists them)`;
1595
+ }
1596
+ function skippedLine(skipped) {
1597
+ const rules = skipped.length === 1 ? "metered rule" : "metered rules";
1598
+ return `skipped ${skipped.length} ${rules} (${skipped.join(", ")}): costs money per file. Pass --llm to include ${skipped.length === 1 ? "it" : "them"}.`;
1599
+ }
1257
1600
  var TOP_FILES = 10;
1258
1601
  var plural3 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1602
+ function checkedLine(checked) {
1603
+ switch (checked.selection) {
1604
+ case "staged":
1605
+ return `checked ${plural3(checked.files, "staged file")}`;
1606
+ case "range":
1607
+ return `checked ${plural3(checked.files, "changed file")}`;
1608
+ case "all":
1609
+ return `checked all ${plural3(checked.files, "file")}`;
1610
+ case "files":
1611
+ return `checked ${plural3(checked.files, "file")}`;
1612
+ case "session":
1613
+ return "checked the session's edited files";
1614
+ }
1615
+ }
1616
+ var NOTHING_STAGED = "nothing staged: 0 files checked (rulecast run --all-files checks everything)";
1617
+ function problemsBlock(warnings) {
1618
+ return warnings.length === 0 ? [] : ["rulecast problems:", ...warnings.map((warning) => ` - ${warning}`), ""];
1619
+ }
1259
1620
  function terminal(delivery, options) {
1260
1621
  const out = [];
1622
+ if (options.checked?.selection === "staged" && options.checked.files === 0 && delivery.warnings.length === 0) {
1623
+ return NOTHING_STAGED;
1624
+ }
1261
1625
  if (options.findings === false) {
1262
- if (delivery.warnings.length > 0) {
1263
- out.push("warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`), "");
1264
- }
1626
+ out.push(...problemsBlock(delivery.warnings));
1265
1627
  if (options.backlog === true) out.push(renderBacklog(summarise(delivery), { topFiles: TOP_FILES }));
1628
+ if (options.backlog === true && (options.ignored ?? []).length > 0) out.push("", ignoredLine(options.ignored));
1629
+ const tail = [
1630
+ ...(options.skipped ?? []).length > 0 ? [skippedLine(options.skipped)] : [],
1631
+ ...options.checked !== void 0 ? [checkedLine(options.checked)] : []
1632
+ ];
1633
+ if (tail.length > 0) out.push(...out.length > 0 ? [""] : [], ...tail);
1266
1634
  return out.join("\n");
1267
1635
  }
1268
1636
  const shown = /* @__PURE__ */ new Map();
@@ -1294,18 +1662,21 @@ function terminal(delivery, options) {
1294
1662
  out.push(`conventions: ${delivery.references.map((reference) => reference.ref).join(", ")}`);
1295
1663
  }
1296
1664
  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
- }
1665
+ out.push(...problemsBlock(delivery.warnings));
1666
+ for (const notice of delivery.notices ?? []) out.push(notice, "");
1667
+ if ((options.skipped ?? []).length > 0) out.push(skippedLine(options.skipped));
1668
+ if (options.checked !== void 0) out.push(checkedLine(options.checked));
1300
1669
  const errors = delivery.findings.filter((finding) => finding.severity === "error").length;
1301
1670
  const warningsCount = delivery.findings.length - errors;
1671
+ const problems = delivery.warnings.length === 0 ? "" : ` \xB7 rulecast: ${plural3(delivery.warnings.length, "problem")} (see above)`;
1302
1672
  out.push(
1303
- delivery.findings.length === 0 ? "no findings" : `${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`
1673
+ (delivery.findings.length === 0 ? "no findings" : `findings: ${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`) + problems
1304
1674
  );
1305
- if (options.backlog === true) out.push("", renderBacklog(summarise(delivery), { topFiles: TOP_FILES }));
1675
+ if (options.backlog === true) out.push("", renderBacklog(summarise(delivery), { topFiles: TOP_FILES, note: false }));
1676
+ if (options.backlog === true && (options.ignored ?? []).length > 0) out.push("", ignoredLine(options.ignored));
1306
1677
  return out.join("\n");
1307
1678
  }
1308
- function json(delivery) {
1679
+ function json(delivery, options) {
1309
1680
  return JSON.stringify(
1310
1681
  {
1311
1682
  findings: delivery.findings,
@@ -1316,6 +1687,10 @@ function json(delivery) {
1316
1687
  references: delivery.references,
1317
1688
  touches: delivery.touches,
1318
1689
  warnings: delivery.warnings,
1690
+ skipped: options.skipped ?? [],
1691
+ checked: options.checked ?? null,
1692
+ ignored: options.ignored ?? [],
1693
+ notices: delivery.notices ?? [],
1319
1694
  stop: delivery.stop
1320
1695
  },
1321
1696
  null,
@@ -1358,7 +1733,7 @@ function formatDelivery(delivery, format, options) {
1358
1733
  case "agent":
1359
1734
  return renderAgentText(delivery, options);
1360
1735
  case "json":
1361
- return json(delivery);
1736
+ return json(delivery, options);
1362
1737
  case "sarif":
1363
1738
  return sarif(delivery);
1364
1739
  }
@@ -1370,7 +1745,7 @@ function exitCodeFor(delivery, failed) {
1370
1745
 
1371
1746
  // src/commands/run.ts
1372
1747
  function parseRunArgs(args) {
1373
- const { values, tokens } = parseArgs5({
1748
+ const { values, tokens } = parseArgs6({
1374
1749
  args,
1375
1750
  allowPositionals: true,
1376
1751
  tokens: true,
@@ -1383,6 +1758,7 @@ function parseRunArgs(args) {
1383
1758
  format: { type: "string", default: "terminal" },
1384
1759
  session: { type: "string" },
1385
1760
  "no-llm": { type: "boolean", default: false },
1761
+ llm: { type: "boolean", default: false },
1386
1762
  ref: { type: "string" }
1387
1763
  }
1388
1764
  });
@@ -1407,6 +1783,7 @@ function parseRunArgs(args) {
1407
1783
  if (values.files && values["all-files"]) throw new UsageError("--all-files and --files cannot be combined");
1408
1784
  if (toRef !== null && fromRef === null) throw new UsageError("--to-ref needs --from-ref");
1409
1785
  if (values.summary && format === "sarif") throw new UsageError("--summary cannot be combined with --format sarif");
1786
+ if (values.llm && values["no-llm"]) throw new UsageError("--llm and --no-llm cannot be combined");
1410
1787
  if (fromRef !== null && (values.files || values["all-files"])) {
1411
1788
  throw new UsageError("--from-ref cannot be combined with --all-files or --files");
1412
1789
  }
@@ -1420,22 +1797,35 @@ function parseRunArgs(args) {
1420
1797
  format,
1421
1798
  session: values.session ?? null,
1422
1799
  noLlm: values["no-llm"],
1800
+ llm: values.llm,
1423
1801
  ref: values.ref ?? null
1424
1802
  };
1425
1803
  }
1426
1804
  async function selectFiles(root, cwd, run2) {
1427
1805
  if (run2.files !== null) {
1428
1806
  const files = run2.files.map((file) => toProjectPath(root, cwd, file)).filter((file) => file !== null);
1429
- return { files, baseCommit: null };
1807
+ return { selection: "files", files, baseCommit: null, content: WORKTREE };
1430
1808
  }
1431
1809
  if (run2.fromRef !== null) {
1432
1810
  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 };
1811
+ if (run2.toRef === null) {
1812
+ return { selection: "range", files: await changedFilesSince(root, base), baseCommit: base, content: WORKTREE };
1813
+ }
1814
+ return {
1815
+ selection: "range",
1816
+ files: await changedFilesBetween(root, base, run2.toRef),
1817
+ baseCommit: base,
1818
+ content: { kind: "commit", ref: run2.toRef }
1819
+ };
1435
1820
  }
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 };
1821
+ if (run2.allFiles) return { selection: "all", files: await allFiles(root), baseCommit: null, content: WORKTREE };
1822
+ if (run2.session !== null) return { selection: "session", files: [], baseCommit: null, content: WORKTREE };
1823
+ return {
1824
+ selection: "staged",
1825
+ files: await stagedFiles(root),
1826
+ baseCommit: await headCommit(root),
1827
+ content: { kind: "index" }
1828
+ };
1439
1829
  }
1440
1830
  async function executeRun({ project, ruleId, run: run2, registry, io }) {
1441
1831
  const root = project.root;
@@ -1443,6 +1833,9 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
1443
1833
  throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
1444
1834
  }
1445
1835
  const selected = await selectFiles(root, io.cwd, run2);
1836
+ const skipMetered = run2.noLlm || selected.selection === "staged" && !run2.llm;
1837
+ const meteredKinds = new Set(registry.kinds().filter((kind) => registry.get(kind)?.metered === true));
1838
+ 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
1839
  const result = await runPipeline({
1447
1840
  project,
1448
1841
  stateDir: ensureProjectState(cacheHome(io.env), root),
@@ -1450,18 +1843,27 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
1450
1843
  kind: "verify",
1451
1844
  files: selected.files,
1452
1845
  baseCommit: selected.baseCommit ?? void 0,
1846
+ content: selected.content,
1453
1847
  session: run2.session === null ? void 0 : { id: run2.session },
1454
1848
  cwd: root
1455
1849
  },
1456
1850
  registry,
1457
1851
  maxContextChars: null,
1458
- skipDetectorKinds: run2.noLlm ? /* @__PURE__ */ new Set(["llm"]) : void 0,
1852
+ skipDetectorKinds: skipMetered ? meteredKinds : void 0,
1459
1853
  onlyRules: ruleId === null ? void 0 : /* @__PURE__ */ new Set([ruleId])
1460
1854
  });
1461
1855
  const text = formatDelivery(result.delivery, run2.format, {
1462
1856
  maxMatchesPerRule: project.config.maxMatchesPerRule,
1463
1857
  findings: !run2.summary,
1464
- backlog: run2.allFiles || run2.summary
1858
+ backlog: run2.allFiles || run2.summary,
1859
+ skipped,
1860
+ checked: { files: selected.files.length, selection: selected.selection },
1861
+ ignored: (result.ignored ?? []).map(({ rule, match, reason }) => ({
1862
+ rule: rule.id,
1863
+ file: match.file,
1864
+ line: match.line,
1865
+ reason
1866
+ }))
1465
1867
  });
1466
1868
  if (text) io.stdout(`${text}
1467
1869
  `);
@@ -1477,11 +1879,10 @@ async function runCommand(root, args, registry, io) {
1477
1879
  }
1478
1880
 
1479
1881
  // src/commands/test.ts
1480
- import { parseArgs as parseArgs6 } from "util";
1882
+ import path7 from "path";
1883
+ import { parseArgs as parseArgs7 } from "util";
1481
1884
 
1482
1885
  // 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
1886
  var EXAMPLES_DIR = ".rulecast-examples-";
1486
1887
  function skippedResult(rule) {
1487
1888
  return { ...emptyResult(rule), skipped: true };
@@ -1506,16 +1907,16 @@ async function runExamples(input) {
1506
1907
  ...rule.examples.bad.map((example, index) => ({ kind: "bad", index, example }))
1507
1908
  ];
1508
1909
  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 });
1910
+ for (const { kind, index, example } of cases) {
1911
+ const outcome = await withScratchTree(
1912
+ input.detection.root,
1913
+ EXAMPLES_DIR,
1914
+ /* @__PURE__ */ new Map([[example.path, example.code]]),
1915
+ (scratch) => runOne(input, scratch, example)
1916
+ );
1917
+ const fired2 = outcome.findings.length > 0;
1918
+ const passed = outcome.error === void 0 && fired2 === (kind === "bad");
1919
+ result.outcomes.push({ kind, index, path: example.path, ...outcome, passed });
1519
1920
  }
1520
1921
  const fired = (outcome) => outcome.findings.length > 0;
1521
1922
  const bad = result.outcomes.filter((outcome) => outcome.kind === "bad");
@@ -1526,21 +1927,14 @@ async function runExamples(input) {
1526
1927
  return result;
1527
1928
  }
1528
1929
  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);
1930
+ const relative = `${scratch}/${example.path}`;
1533
1931
  const output = await runDetection({
1534
- root: input.root,
1932
+ detection: input.detection,
1535
1933
  event: "verify",
1536
1934
  selections: [{ rule: input.rule, files: [relative] }],
1537
1935
  // No baseline: an example is entirely new, which is what an author is asking about.
1538
1936
  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,
1937
+ read: (name) => readSourceFile(input.detection.root, name),
1544
1938
  timeoutMs: input.timeoutMs
1545
1939
  });
1546
1940
  const failure = output.errors[0]?.message ?? (output.timedOut.length > 0 ? "timed out" : void 0);
@@ -1548,6 +1942,7 @@ async function runOne(input, scratch, example) {
1548
1942
  const findings = output.findings.map(({ match }) => ({
1549
1943
  line: match.line,
1550
1944
  column: match.column,
1945
+ text: match.text,
1551
1946
  message: renderTemplate(input.rule.message, {
1552
1947
  ...match.captures,
1553
1948
  file: example.path,
@@ -1562,7 +1957,7 @@ async function runOne(input, scratch, example) {
1562
1957
 
1563
1958
  // src/commands/test.ts
1564
1959
  function parseTestArgs(args) {
1565
- const { values, positionals } = parseArgs6({
1960
+ const { values, positionals } = parseArgs7({
1566
1961
  args,
1567
1962
  allowPositionals: true,
1568
1963
  options: { against: { type: "string", multiple: true } }
@@ -1573,14 +1968,35 @@ function parseTestArgs(args) {
1573
1968
  if (against.length > 0 && ruleId === null) throw new UsageError("--against needs a RULE_ID");
1574
1969
  return { ruleId, against };
1575
1970
  }
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
- ];
1971
+ var FEW_VIOLATIONS = 25;
1972
+ var SMALL_SAMPLE = 25;
1973
+ function againstNote(violations, violatingFiles, matchingFiles) {
1974
+ if (matchingFiles < SMALL_SAMPLE) {
1975
+ return [
1976
+ ` Only ${matchingFiles} matching ${matchingFiles === 1 ? "file" : "files"}: too few for these numbers to say how often the rule is broken.`,
1977
+ " Judge the rule by the doc it enforces."
1978
+ ];
1979
+ }
1980
+ if (violations === 0) {
1981
+ return [
1982
+ " Nothing to fix today is not the same as nothing to catch: this counts the",
1983
+ " stock, not how often an edit would break the rule."
1984
+ ];
1985
+ }
1986
+ if (violatingFiles * 2 > matchingFiles) {
1987
+ return [" Most matching files violate it: check it is a convention, not a description of the code."];
1988
+ }
1989
+ if (violations < FEW_VIOLATIONS) {
1990
+ return [
1991
+ " Few violations: a rule nobody breaks costs an agent context and catches little",
1992
+ ` (8 of 15 measured conventions were under ${FEW_VIOLATIONS}).`
1993
+ ];
1994
+ }
1995
+ return [];
1996
+ }
1997
+ var plural4 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1998
+ var touchRule = (id) => `${id} is a touch rule: it delivers context and has nothing to test
1999
+ `;
1584
2000
  var ratio = (part, whole) => whole === 0 ? " \u2014 " : (part / whole).toFixed(2);
1585
2001
  function summaryLine(rule, result) {
1586
2002
  const id = rule.id.padEnd(36);
@@ -1590,6 +2006,11 @@ function summaryLine(rule, result) {
1590
2006
  const score = `${passed}/${result.outcomes.length}`.padEnd(7);
1591
2007
  return ` ${id}${score} P ${ratio(result.precision.correct, result.precision.total)} R ${ratio(result.recall.matched, result.recall.total)}`;
1592
2008
  }
2009
+ var MATCH_PREVIEW = 60;
2010
+ function matchedLine(text) {
2011
+ const shown = text.length > MATCH_PREVIEW ? `${text.slice(0, MATCH_PREVIEW)}\u2026` : text;
2012
+ return `matched ${JSON.stringify(shown)}${/\r?\n/.test(text) ? ", across a line break" : ""}`;
2013
+ }
1593
2014
  function failureLines(rule, result) {
1594
2015
  const out = [];
1595
2016
  for (const outcome of result.outcomes) {
@@ -1606,16 +2027,18 @@ function failureLines(rule, result) {
1606
2027
  const source = (rule.examples?.good[outcome.index]?.code ?? "").split(/\r?\n/);
1607
2028
  for (const finding of outcome.findings) {
1608
2029
  out.push(` ${name} \u2014 fired at line ${finding.line}`, ` ${source[finding.line - 1]?.trim() ?? ""}`);
2030
+ out.push(` ${matchedLine(finding.text)}`);
1609
2031
  }
1610
2032
  }
1611
2033
  return out;
1612
2034
  }
1613
2035
  var AGAINST_TOP_FILES = 10;
1614
- async function reportAgainst(root, rule, paths, detection, io) {
2036
+ async function reportAgainst(root, rule, paths, detection, timeoutMs, io) {
1615
2037
  const prefixes = paths.map((given) => {
2038
+ if (path7.resolve(io.cwd, given) === path7.resolve(root)) return "";
1616
2039
  const resolved = toProjectPath(root, io.cwd, given);
1617
2040
  if (resolved === null) throw new UsageError(`${given} is outside the project`);
1618
- return resolved === "." ? "" : resolved;
2041
+ return resolved;
1619
2042
  });
1620
2043
  const under = (file) => prefixes.some((prefix) => prefix === "" || file === prefix || file.startsWith(`${prefix}/`));
1621
2044
  const files = (await allFiles(root)).filter((file) => under(file) && rule.matches(file));
@@ -1627,12 +2050,12 @@ async function reportAgainst(root, rule, paths, detection, io) {
1627
2050
  return 0;
1628
2051
  }
1629
2052
  const output = await runDetection({
1630
- ...detection,
1631
- root,
2053
+ detection,
1632
2054
  event: "verify",
1633
2055
  selections: [{ rule, files }],
1634
2056
  changes: /* @__PURE__ */ new Map(),
1635
- read: (file) => readSourceFile(root, file)
2057
+ read: (file) => readSourceFile(root, file),
2058
+ timeoutMs
1636
2059
  });
1637
2060
  const failure = output.errors[0];
1638
2061
  if (failure !== void 0) throw new Error(`${rule.detector.kind} detector failed: ${failure.message}`);
@@ -1640,13 +2063,14 @@ async function reportAgainst(root, rule, paths, detection, io) {
1640
2063
  for (const { match } of output.findings) byFile.set(match.file, (byFile.get(match.file) ?? 0) + 1);
1641
2064
  const worst = [...byFile].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, AGAINST_TOP_FILES);
1642
2065
  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`];
2066
+ const violations = output.findings.length;
2067
+ const out = ["", ` ${plural4(violations, "violation")} in ${byFile.size} of ${files.length} matching files`];
1644
2068
  if (worst.length > 0) {
1645
2069
  out.push("", ...worst.map(([file, count]) => ` ${file.padEnd(width + 2)}${count}`));
1646
2070
  if (byFile.size > worst.length) out.push(` \u2026and ${byFile.size - worst.length} more files`);
1647
2071
  }
1648
- out.push("", ...AGAINST_NOTES);
1649
- if (output.findings.length === 0) out.push(...ZERO_NOTES);
2072
+ const note = againstNote(violations, byFile.size, files.length);
2073
+ if (note.length > 0) out.push("", ...note);
1650
2074
  out.push("");
1651
2075
  io.stdout(out.join("\n"));
1652
2076
  return 0;
@@ -1659,28 +2083,34 @@ async function testCommand(root, args, registry, io) {
1659
2083
  throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
1660
2084
  }
1661
2085
  const rules = project.rules.filter(isDetectorRule).filter((rule) => ruleId === null || rule.id === ruleId);
2086
+ if (ruleId !== null && rules.length === 0) {
2087
+ io.stdout(touchRule(ruleId));
2088
+ return 0;
2089
+ }
1662
2090
  const stateDir = ensureProjectState(cacheHome(io.env), root);
1663
2091
  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);
2092
+ const detection = detectionFor(project, registry, stateDir, resolver);
2093
+ const timeoutMs = project.config.timeouts.verifyMs;
2094
+ if (against.length > 0) return await reportAgainst(root, rules[0], against, detection, timeoutMs, io);
1672
2095
  const lines = [""];
1673
2096
  const noExamples = [];
2097
+ const notRun = [];
1674
2098
  let failedRules = 0;
1675
2099
  let ranRules = 0;
1676
2100
  for (const rule of rules) {
1677
- const result = ruleId === null && rule.detector.kind === "llm" ? skippedResult(rule.id) : await runExamples({ ...detection, root, rule });
2101
+ const result = (
2102
+ // Spec §6: a metered rule runs only when asked for by id — never as part of "test them all".
2103
+ ruleId === null && registry.get(rule.detector.kind)?.metered === true ? skippedResult(rule.id) : await runExamples({ detection, rule, timeoutMs })
2104
+ );
1678
2105
  lines.push(summaryLine(rule, result));
1679
2106
  if (result.missing || result.empty) {
1680
2107
  noExamples.push(rule.id);
1681
2108
  continue;
1682
2109
  }
1683
- if (result.skipped) continue;
2110
+ if (result.skipped) {
2111
+ notRun.push(rule.id);
2112
+ continue;
2113
+ }
1684
2114
  ranRules++;
1685
2115
  if (result.outcomes.some((outcome) => !outcome.passed)) {
1686
2116
  failedRules++;
@@ -1692,6 +2122,9 @@ async function testCommand(root, args, registry, io) {
1692
2122
  return 0;
1693
2123
  }
1694
2124
  if (noExamples.length > 0) lines.push("", ` no examples: ${noExamples.join(", ")}`);
2125
+ if (notRun.length > 0) {
2126
+ lines.push("", ` not run: ${notRun.join(", ")} (rulecast test <id> runs one; it costs money per example)`);
2127
+ }
1695
2128
  lines.push(
1696
2129
  "",
1697
2130
  failedRules === 0 ? ranRules === 0 ? " nothing to run" : ` ${ranRules} ${ranRules === 1 ? "rule" : "rules"} passed` : ` ${failedRules} of ${ranRules} rules failed`,
@@ -1703,7 +2136,7 @@ async function testCommand(root, args, registry, io) {
1703
2136
 
1704
2137
  // src/commands/try-repo.ts
1705
2138
  import { existsSync as existsSync5, statSync as statSync2 } from "fs";
1706
- import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
2139
+ import { mkdtemp, rm as rm2 } from "fs/promises";
1707
2140
  import { tmpdir as tmpdir2 } from "os";
1708
2141
  import path8 from "path";
1709
2142
  var isDirectory = (file) => existsSync5(file) && statSync2(file).isDirectory();
@@ -1724,7 +2157,7 @@ async function tryRepoCommand(root, args, registry, io) {
1724
2157
  if (directory && run2.ref !== null && !isGitRepo(local)) throw new UsageError("--ref only applies to URLs");
1725
2158
  const fromDirectory = directory && run2.ref === null;
1726
2159
  const rev = fromDirectory ? "working-tree" : run2.ref ?? "HEAD";
1727
- const temp = fromDirectory ? null : await mkdtemp2(path8.join(tmpdir2(), "rulecast-try-repo-"));
2160
+ const temp = fromDirectory ? null : await mkdtemp(path8.join(tmpdir2(), "rulecast-try-repo-"));
1728
2161
  try {
1729
2162
  const dir = temp ?? local;
1730
2163
  if (temp !== null) await fetchCheckout(directory ? local : repo, rev, temp);
@@ -1743,7 +2176,7 @@ async function tryRepoCommand(root, args, registry, io) {
1743
2176
  const project = await compile({ root, registry, repos: fixedRepo(dir, label), configData });
1744
2177
  return await executeRun({ project, ruleId: null, run: run2, registry, io });
1745
2178
  } finally {
1746
- if (temp !== null) await rm3(temp, { recursive: true, force: true });
2179
+ if (temp !== null) await rm2(temp, { recursive: true, force: true });
1747
2180
  }
1748
2181
  }
1749
2182
 
@@ -1753,19 +2186,38 @@ import path9 from "path";
1753
2186
  function formatDiagnostic(diagnostic) {
1754
2187
  return `${diagnostic.level === "warning" ? "warning: " : ""}${diagnosticText(diagnostic)}`;
1755
2188
  }
2189
+ function kindOf(full, data) {
2190
+ const name = path9.basename(full);
2191
+ if (name === CONFIG_FILE) return "config";
2192
+ if (name === MANIFEST_FILE) return "manifest";
2193
+ if (!data.ok) return null;
2194
+ if (Array.isArray(data.value)) return "manifest";
2195
+ if (typeof data.value === "object" && data.value !== null && "repos" in data.value) return "config";
2196
+ return null;
2197
+ }
1756
2198
  async function validateCommand(root, args, registry, io) {
1757
2199
  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
2200
  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
- }
2201
+ const read = await Promise.all(
2202
+ targets.map(async (target) => {
2203
+ const data = await readYamlFile(target.full);
2204
+ const unparsedYaml = !data.ok && /\.ya?ml$/.test(target.full);
2205
+ const kind = kindOf(target.full, data) ?? (unparsedYaml ? "manifest" : null);
2206
+ if (kind === null) {
2207
+ throw new UsageError(`${target.shown}: neither a config (repos:) nor a manifest (a list of rules)`);
2208
+ }
2209
+ return { ...target, data, kind };
2210
+ })
2211
+ );
1765
2212
  let failed = false;
1766
- for (const { shown, full } of targets) {
2213
+ for (const { shown, full, data, kind } of read) {
1767
2214
  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);
2215
+ const result = kind === "config" ? await compile({
2216
+ root: dir,
2217
+ registry,
2218
+ repos: fetchingRepos(cacheHome(io.env)),
2219
+ configData: data.ok ? data.value : void 0
2220
+ }) : await compileManifest(dir, registry, full);
1769
2221
  for (const diagnostic of result.diagnostics) io.stdout(`${shown}: ${formatDiagnostic(diagnostic)}
1770
2222
  `);
1771
2223
  if (result.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
@@ -1780,10 +2232,10 @@ async function validateCommand(root, args, registry, io) {
1780
2232
  }
1781
2233
 
1782
2234
  // src/commands/warm.ts
1783
- import { parseArgs as parseArgs7 } from "util";
2235
+ import { parseArgs as parseArgs8 } from "util";
1784
2236
  var WARM_TIMEOUT_MS = 5 * 6e4;
1785
2237
  async function warmCommand(root, args, registry, io) {
1786
- const { values } = parseArgs7({ args, options: { detector: { type: "string", multiple: true } } });
2238
+ const { values } = parseArgs8({ args, options: { detector: { type: "string", multiple: true } } });
1787
2239
  if (!hasProject(root)) throw new Error(`no .rulecast-config.yaml in ${root} or its parents (run rulecast init)`);
1788
2240
  const home = cacheHome(io.env);
1789
2241
  const stateDir = ensureProjectState(home, root);
@@ -1813,24 +2265,23 @@ async function warmCommand(root, args, registry, io) {
1813
2265
  }
1814
2266
 
1815
2267
  // 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
2268
  async function main(argv, io) {
1831
2269
  const [command, ...args] = argv;
1832
2270
  const registry = createRegistry([...builtinDetectors]);
1833
2271
  const root = findRoot(io.cwd);
2272
+ if (command === "--version" || command === "-v" || command === "version") {
2273
+ io.stdout(`${VERSION}
2274
+ `);
2275
+ return 0;
2276
+ }
2277
+ const help = command === "help" && args[0] !== void 0 ? args[0] : args.includes("--help") || args.includes("-h") ? command : null;
2278
+ if (help !== null && help !== void 0) {
2279
+ const text = commandHelp(help);
2280
+ if (text !== null) {
2281
+ io.stdout(text);
2282
+ return 0;
2283
+ }
2284
+ }
1834
2285
  try {
1835
2286
  switch (command) {
1836
2287
  case "init":
@@ -1841,6 +2292,8 @@ async function main(argv, io) {
1841
2292
  return await uninstallCommand(root, args, io);
1842
2293
  case "run":
1843
2294
  return await runCommand(root, args, registry, io);
2295
+ case "list":
2296
+ return await listCommand(root, args, registry, io);
1844
2297
  case "autoupdate":
1845
2298
  return await autoupdateCommand(root, args, io);
1846
2299
  case "try-repo":
@@ -1860,7 +2313,8 @@ async function main(argv, io) {
1860
2313
  case void 0:
1861
2314
  case "help":
1862
2315
  case "--help":
1863
- io.stdout(USAGE);
2316
+ case "-h":
2317
+ io.stdout(usage());
1864
2318
  return command === void 0 ? 2 : 0;
1865
2319
  default:
1866
2320
  throw new UsageError(`unknown command "${command}"`);
@@ -1869,7 +2323,8 @@ async function main(argv, io) {
1869
2323
  io.stderr(`rulecast: ${errorMessage(error)}
1870
2324
  `);
1871
2325
  if (error instanceof UsageError || error.code?.startsWith("ERR_PARSE_ARGS")) {
1872
- io.stderr(USAGE);
2326
+ io.stderr(`
2327
+ ${command !== void 0 && commandHelp(command) || usage()}`);
1873
2328
  }
1874
2329
  return 2;
1875
2330
  }