@syv-ai/rulecast 0.2.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/README.md +134 -10
- package/dist/{chunk-ZEFEQKG5.js → chunk-DQUCE2IK.js} +230 -69
- package/dist/{chunk-C6C5EER7.js → chunk-IKONOTME.js} +238 -130
- package/dist/{chunk-RODZRI7B.js → chunk-WUFU4LW2.js} +792 -319
- package/dist/cli.js +803 -102
- package/dist/index.d.ts +111 -4
- package/dist/index.js +37 -5
- package/dist/internal.d.ts +13 -10
- package/dist/internal.js +2 -2
- package/dist/{types-2oz2AlwA.d.ts → types-Dcra5LXR.d.ts} +200 -3
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -3,14 +3,17 @@ import {
|
|
|
3
3
|
ADAPTERS,
|
|
4
4
|
adapterByName,
|
|
5
5
|
builtinDetectors,
|
|
6
|
-
createRegistry
|
|
7
|
-
|
|
6
|
+
createRegistry,
|
|
7
|
+
renderBacklog,
|
|
8
|
+
summarise
|
|
9
|
+
} from "./chunk-IKONOTME.js";
|
|
8
10
|
import {
|
|
9
11
|
CONFIG_FILE,
|
|
10
12
|
LockTimeoutError,
|
|
11
13
|
MANIFEST_FILE,
|
|
12
14
|
NO_PROMPT,
|
|
13
15
|
RepoFetchError,
|
|
16
|
+
WORKTREE,
|
|
14
17
|
allFiles,
|
|
15
18
|
cacheHome,
|
|
16
19
|
cachedRepo,
|
|
@@ -20,8 +23,10 @@ import {
|
|
|
20
23
|
checkDetectors,
|
|
21
24
|
compile,
|
|
22
25
|
compileManifest,
|
|
26
|
+
createReferenceResolver,
|
|
23
27
|
debugLogger,
|
|
24
28
|
defaultConfig,
|
|
29
|
+
detectionFor,
|
|
25
30
|
diagnosticText,
|
|
26
31
|
ensureProjectState,
|
|
27
32
|
ensureRepo,
|
|
@@ -29,18 +34,21 @@ import {
|
|
|
29
34
|
fetchingRepos,
|
|
30
35
|
fixedRepo,
|
|
31
36
|
git,
|
|
37
|
+
headCommit,
|
|
32
38
|
mergeBase,
|
|
33
39
|
parseConfig,
|
|
34
40
|
projectStateDir,
|
|
35
41
|
readConfigData,
|
|
36
42
|
readManifest,
|
|
43
|
+
readYamlFile,
|
|
37
44
|
repoLabel,
|
|
38
45
|
rulesOfKind,
|
|
39
46
|
runDetection,
|
|
40
47
|
runPipeline,
|
|
41
48
|
stagedFiles,
|
|
42
|
-
withLock
|
|
43
|
-
|
|
49
|
+
withLock,
|
|
50
|
+
withScratchTree
|
|
51
|
+
} from "./chunk-WUFU4LW2.js";
|
|
44
52
|
import {
|
|
45
53
|
VERSION,
|
|
46
54
|
detectorCacheDir,
|
|
@@ -49,12 +57,14 @@ import {
|
|
|
49
57
|
isDetectorRule,
|
|
50
58
|
isNotFound,
|
|
51
59
|
memoryCache,
|
|
60
|
+
onPath,
|
|
52
61
|
parseVersion,
|
|
53
62
|
readSourceFile,
|
|
54
63
|
renderAgentText,
|
|
64
|
+
renderTemplate,
|
|
55
65
|
scanHeadings,
|
|
56
66
|
tagsOf
|
|
57
|
-
} from "./chunk-
|
|
67
|
+
} from "./chunk-DQUCE2IK.js";
|
|
58
68
|
import {
|
|
59
69
|
Cancelled
|
|
60
70
|
} from "./chunk-K37BHCEK.js";
|
|
@@ -202,21 +212,31 @@ function inFile(file, transform) {
|
|
|
202
212
|
throw new Error(`${file}: ${errorMessage(error)}`);
|
|
203
213
|
}
|
|
204
214
|
}
|
|
205
|
-
async function
|
|
215
|
+
async function hookCommandResolves(root, signal) {
|
|
216
|
+
return existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")) || await onPath("rulecast", signal);
|
|
217
|
+
}
|
|
218
|
+
async function hookState(root, adapter, verifyMs) {
|
|
206
219
|
const install = installOf(adapter);
|
|
207
220
|
const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
|
|
208
221
|
for (const { file } of install.scopes) {
|
|
209
222
|
const current = await readSettings(root, file);
|
|
210
223
|
if (current === null) continue;
|
|
211
|
-
|
|
224
|
+
const merged = inFile(file, () => install.merge(current.value, command, verifyMs));
|
|
225
|
+
if (merged.added.length === 0) return { file, stale: merged.updated ?? [] };
|
|
212
226
|
}
|
|
213
227
|
return null;
|
|
214
228
|
}
|
|
215
229
|
async function installHooks(root, adapter, scope, verifyMs) {
|
|
216
230
|
const install = installOf(adapter);
|
|
217
231
|
const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
|
|
218
|
-
const already = await
|
|
219
|
-
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
|
+
}
|
|
220
240
|
const target = install.scopes.find((candidate) => candidate.scope === scope);
|
|
221
241
|
if (!target) throw new UsageError(`${adapter.name} has no ${scope} settings`);
|
|
222
242
|
const current = (await readSettings(root, target.file))?.value ?? {};
|
|
@@ -253,8 +273,10 @@ async function loadProjectConfig(root, command) {
|
|
|
253
273
|
return config.value;
|
|
254
274
|
}
|
|
255
275
|
function printInstall(io, adapter, result) {
|
|
276
|
+
const updated = result.updated ?? [];
|
|
256
277
|
io.stdout(
|
|
257
|
-
|
|
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}
|
|
258
280
|
` : `installed ${adapter.label} hooks in ${result.file}: ${result.added.join(", ")}
|
|
259
281
|
`
|
|
260
282
|
);
|
|
@@ -440,12 +462,16 @@ async function doctorCommand(root, args, registry, io) {
|
|
|
440
462
|
}
|
|
441
463
|
}
|
|
442
464
|
io.stdout("\nhooks\n");
|
|
465
|
+
const resolves = await hookCommandResolves(root);
|
|
443
466
|
for (const adapter of ADAPTERS) {
|
|
444
467
|
if (adapter.install === null) continue;
|
|
445
|
-
const
|
|
446
|
-
const
|
|
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";
|
|
447
472
|
count(level);
|
|
448
|
-
|
|
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.`;
|
|
474
|
+
io.stdout(line(level, adapter.label, detail));
|
|
449
475
|
}
|
|
450
476
|
io.stdout("\ncache\n");
|
|
451
477
|
io.stdout(` home ${home}
|
|
@@ -460,12 +486,14 @@ async function doctorCommand(root, args, registry, io) {
|
|
|
460
486
|
`);
|
|
461
487
|
}
|
|
462
488
|
io.stdout("\ndry run\n");
|
|
463
|
-
const dry = await dryRun(project, registry);
|
|
489
|
+
const dry = await dryRun(project, detectionFor(project, registry, stateDir, createReferenceResolver(root)));
|
|
464
490
|
if (dry.length === 0) io.stdout(" nothing to run\n");
|
|
465
491
|
for (const result of dry) {
|
|
466
492
|
count(result.level);
|
|
467
493
|
io.stdout(line(result.level, result.rule, result.detail));
|
|
468
494
|
}
|
|
495
|
+
if (dry.some((result) => result.tried)) io.stdout(` ${DRY_RUN_FOOTER}
|
|
496
|
+
`);
|
|
469
497
|
io.stdout(
|
|
470
498
|
`
|
|
471
499
|
${errors + warnings === 0 ? "no problems found" : [plural(errors, "error"), plural(warnings, "warning")].join(", ")}
|
|
@@ -473,7 +501,8 @@ ${errors + warnings === 0 ? "no problems found" : [plural(errors, "error"), plur
|
|
|
473
501
|
);
|
|
474
502
|
return errors > 0 ? 2 : 0;
|
|
475
503
|
}
|
|
476
|
-
|
|
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) {
|
|
477
506
|
if (project.rules.length === 0) return [];
|
|
478
507
|
let files;
|
|
479
508
|
try {
|
|
@@ -483,31 +512,38 @@ async function dryRun(project, registry) {
|
|
|
483
512
|
}
|
|
484
513
|
const lines = [];
|
|
485
514
|
for (const rule of project.rules) {
|
|
515
|
+
if (!rule.enabled) {
|
|
516
|
+
lines.push({ rule: rule.id, level: "skipped", detail: "disabled" });
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
486
519
|
if (!isDetectorRule(rule)) {
|
|
487
520
|
lines.push({ rule: rule.id, level: "ok", detail: "context only, nothing to run" });
|
|
488
521
|
continue;
|
|
489
522
|
}
|
|
490
|
-
if (rule.detector.kind ===
|
|
491
|
-
lines.push({
|
|
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
|
+
});
|
|
492
529
|
continue;
|
|
493
530
|
}
|
|
494
|
-
const
|
|
531
|
+
const matching = files.filter((candidate) => rule.matches(candidate));
|
|
532
|
+
const file = matching[0];
|
|
495
533
|
if (file === void 0) {
|
|
496
534
|
lines.push({ rule: rule.id, level: "warning", detail: "no file in the project matches this rule" });
|
|
497
535
|
continue;
|
|
498
536
|
}
|
|
499
537
|
const timeoutMs = project.config.timeouts.verifyMs;
|
|
500
538
|
const output = await runDetection({
|
|
501
|
-
|
|
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 () => [] },
|
|
502
543
|
event: "verify",
|
|
503
544
|
selections: [{ rule, files: [file] }],
|
|
504
545
|
changes: /* @__PURE__ */ new Map(),
|
|
505
546
|
read: (name) => readSourceFile(project.root, name),
|
|
506
|
-
registry,
|
|
507
|
-
cacheFor: () => memoryCache(),
|
|
508
|
-
// Only the llm detector reads context, and llm rules never reach here.
|
|
509
|
-
contextFor: async () => [],
|
|
510
|
-
settings: { llm: project.config.llm },
|
|
511
547
|
timeoutMs
|
|
512
548
|
});
|
|
513
549
|
const failure = output.errors[0];
|
|
@@ -523,12 +559,150 @@ async function dryRun(project, registry) {
|
|
|
523
559
|
lines.push({
|
|
524
560
|
rule: rule.id,
|
|
525
561
|
level: "ok",
|
|
526
|
-
detail:
|
|
562
|
+
detail: `tried ${file} (1 of ${plural(matching.length, "matching file")}): ${matches === 0 ? "no match" : matches === 1 ? "1 match" : `${matches} matches`}`,
|
|
563
|
+
tried: true
|
|
527
564
|
});
|
|
528
565
|
}
|
|
529
566
|
return lines;
|
|
530
567
|
}
|
|
531
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
|
+
|
|
532
706
|
// src/core/detection/warm.ts
|
|
533
707
|
import path4 from "path";
|
|
534
708
|
var WARM_LOCK = { waitMs: 0, staleMs: 10 * 6e4, retryMs: 20 };
|
|
@@ -764,6 +938,26 @@ function detectionSummary(detection) {
|
|
|
764
938
|
}
|
|
765
939
|
return parts.length === 0 ? "nothing yet" : parts.join(" \xB7 ");
|
|
766
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
|
+
}
|
|
767
961
|
function docChoices(doc) {
|
|
768
962
|
const choices = [{ value: `@${doc.file}`, label: doc.file }];
|
|
769
963
|
const trail = [];
|
|
@@ -858,13 +1052,13 @@ function addCatalogRules(text, catalog, rules) {
|
|
|
858
1052
|
const entry = repos.items.find((item) => isMap2(item) && item.get("repo") === catalog.url);
|
|
859
1053
|
let edit;
|
|
860
1054
|
if (entry) {
|
|
861
|
-
const
|
|
1055
|
+
const listed2 = entry.get("rules", true);
|
|
862
1056
|
const rulesKey = pairKey(entry, "rules");
|
|
863
|
-
if (!isSeq2(
|
|
864
|
-
const present = new Set(
|
|
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));
|
|
865
1059
|
const added = rules.filter((rule) => !present.has(rule.id));
|
|
866
1060
|
if (added.length === 0) return text;
|
|
867
|
-
edit = appendToSeq(text,
|
|
1061
|
+
edit = appendToSeq(text, listed2, rulesKey, added.flatMap(ruleLines));
|
|
868
1062
|
} else {
|
|
869
1063
|
if (rules.length === 0) return text;
|
|
870
1064
|
edit = appendToSeq(text, repos, reposKey, repoLines(catalog, rules));
|
|
@@ -917,7 +1111,19 @@ async function planAgent(choice, context) {
|
|
|
917
1111
|
};
|
|
918
1112
|
for (const { file } of install.scopes) {
|
|
919
1113
|
const text2 = await context.readText(file);
|
|
920
|
-
if (text2
|
|
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
|
+
};
|
|
921
1127
|
}
|
|
922
1128
|
const target = install.scopes.find((entry) => entry.scope === choice.scope);
|
|
923
1129
|
if (target === void 0) throw new PlanError(`${choice.adapter.label} has no ${choice.scope} settings`);
|
|
@@ -948,6 +1154,7 @@ function reviewText(changes) {
|
|
|
948
1154
|
}
|
|
949
1155
|
|
|
950
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>.";
|
|
951
1158
|
var DEFAULT_CATALOG = "https://github.com/syv-ai/rulecast";
|
|
952
1159
|
var SCOPES = ["shared", "personal"];
|
|
953
1160
|
var installable = () => ADAPTERS.filter((adapter) => adapter.install !== null);
|
|
@@ -958,11 +1165,13 @@ function parseFlags(args) {
|
|
|
958
1165
|
rules: { type: "string" },
|
|
959
1166
|
"no-rules": { type: "boolean", default: false },
|
|
960
1167
|
agent: { type: "string", multiple: true },
|
|
1168
|
+
"no-agents": { type: "boolean", default: false },
|
|
961
1169
|
scope: { type: "string" },
|
|
962
1170
|
yes: { type: "boolean", default: false }
|
|
963
1171
|
}
|
|
964
1172
|
});
|
|
965
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");
|
|
966
1175
|
const scope = values.scope ?? null;
|
|
967
1176
|
if (scope !== null && !SCOPES.includes(scope)) {
|
|
968
1177
|
throw new UsageError(`unknown scope "${scope}" (use shared or personal)`);
|
|
@@ -976,7 +1185,14 @@ function parseFlags(args) {
|
|
|
976
1185
|
return adapter;
|
|
977
1186
|
});
|
|
978
1187
|
const rules = values.rules === void 0 ? null : values.rules.split(",").map((id) => id.trim()).filter(Boolean);
|
|
979
|
-
return {
|
|
1188
|
+
return {
|
|
1189
|
+
rules,
|
|
1190
|
+
noRules: values["no-rules"],
|
|
1191
|
+
agents,
|
|
1192
|
+
noAgents: values["no-agents"],
|
|
1193
|
+
scope,
|
|
1194
|
+
yes: values.yes
|
|
1195
|
+
};
|
|
980
1196
|
}
|
|
981
1197
|
function projectRoot(cwd) {
|
|
982
1198
|
const found = findRoot(cwd);
|
|
@@ -1024,7 +1240,8 @@ var idOf = (entry) => {
|
|
|
1024
1240
|
return typeof id === "string" ? id : null;
|
|
1025
1241
|
};
|
|
1026
1242
|
var groupOf = (id) => id.includes("/") ? id.slice(0, id.indexOf("/")) : "general";
|
|
1027
|
-
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;
|
|
1028
1245
|
const available = catalog.rules.filter((rule) => !installed.has(rule.id));
|
|
1029
1246
|
if (flags.noRules) return [];
|
|
1030
1247
|
if (flags.rules !== null) {
|
|
@@ -1033,7 +1250,7 @@ async function chooseRules(catalog, installed, files, flags, ui) {
|
|
|
1033
1250
|
if (unknown !== void 0) throw new UsageError(`"${unknown}" is not a rule in ${catalog.label}`);
|
|
1034
1251
|
return available.filter((rule) => flags.rules.includes(rule.id));
|
|
1035
1252
|
}
|
|
1036
|
-
const preselected = available.filter((rule) => rule
|
|
1253
|
+
const preselected = available.filter((rule) => !metered(rule) && files.some((file) => rule.matches(file))).map((rule) => rule.id);
|
|
1037
1254
|
if (ui.prompter === null) return available.filter((rule) => preselected.includes(rule.id));
|
|
1038
1255
|
if (available.length === 0) {
|
|
1039
1256
|
ui.say(`Every rule from ${catalog.label} is already configured.`, "Rules");
|
|
@@ -1047,7 +1264,7 @@ async function chooseRules(catalog, installed, files, flags, ui) {
|
|
|
1047
1264
|
choices.push({
|
|
1048
1265
|
value: rule.id,
|
|
1049
1266
|
label: group === "general" ? rule.id : rule.id.slice(group.length + 1),
|
|
1050
|
-
hint: done ? "installed" : hintFor(rule),
|
|
1267
|
+
hint: done ? "installed" : hintFor(rule, registry),
|
|
1051
1268
|
disabled: done
|
|
1052
1269
|
});
|
|
1053
1270
|
groups[group] = choices;
|
|
@@ -1059,11 +1276,10 @@ async function chooseRules(catalog, installed, files, flags, ui) {
|
|
|
1059
1276
|
});
|
|
1060
1277
|
return available.filter((rule) => chosen.includes(rule.id));
|
|
1061
1278
|
}
|
|
1062
|
-
function hintFor(rule) {
|
|
1279
|
+
function hintFor(rule, registry) {
|
|
1063
1280
|
const description = rule.description ?? rule.name;
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
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}`;
|
|
1067
1283
|
}
|
|
1068
1284
|
async function chooseConventions(rules, detection, ui) {
|
|
1069
1285
|
const choices = detection.docs.flatMap(docChoices);
|
|
@@ -1078,20 +1294,27 @@ async function chooseConventions(rules, detection, ui) {
|
|
|
1078
1294
|
label: "keep the package's doc",
|
|
1079
1295
|
hint: rule.context.map((spec) => spec.ref).join(", ")
|
|
1080
1296
|
};
|
|
1297
|
+
const matched = rule.context.map((spec) => matchHeading(spec, detection.docs)).find((value) => value !== null);
|
|
1081
1298
|
const answer = await ui.prompter.select({
|
|
1082
1299
|
message: `Conventions for ${rule.id}`,
|
|
1083
1300
|
choices: [keep, ...choices],
|
|
1084
|
-
initialValue: ""
|
|
1301
|
+
initialValue: matched !== void 0 && choices.some((choice) => choice.value === matched) ? matched : ""
|
|
1085
1302
|
});
|
|
1086
1303
|
selections.push({ id: rule.id, context: answer === "" ? null : [answer] });
|
|
1087
1304
|
}
|
|
1088
1305
|
return selections;
|
|
1089
1306
|
}
|
|
1307
|
+
function defaultAgents(detected, adapters) {
|
|
1308
|
+
const found = adapters.filter((adapter) => detected.includes(adapter.name));
|
|
1309
|
+
return found.length > 0 ? found : [...adapters];
|
|
1310
|
+
}
|
|
1090
1311
|
async function chooseAgents(detection, flags, ui) {
|
|
1091
1312
|
const detected = detection.agents.filter((agent) => agent.adapter !== null).map((agent) => agent.name);
|
|
1313
|
+
const defaults = defaultAgents(detected, installable());
|
|
1092
1314
|
let chosen;
|
|
1093
|
-
if (flags.
|
|
1094
|
-
else if (
|
|
1315
|
+
if (flags.noAgents) chosen = [];
|
|
1316
|
+
else if (flags.agents.length > 0) chosen = flags.agents;
|
|
1317
|
+
else if (ui.prompter === null) chosen = defaults;
|
|
1095
1318
|
else {
|
|
1096
1319
|
const choices = installable().map((adapter) => ({
|
|
1097
1320
|
value: adapter.name,
|
|
@@ -1108,12 +1331,13 @@ async function chooseAgents(detection, flags, ui) {
|
|
|
1108
1331
|
});
|
|
1109
1332
|
}
|
|
1110
1333
|
}
|
|
1111
|
-
const names = await ui.prompter.multiselect({
|
|
1334
|
+
const names = await ui.prompter.multiselect({
|
|
1335
|
+
message: "Install hooks for",
|
|
1336
|
+
choices,
|
|
1337
|
+
initialValues: defaults.map((adapter) => adapter.name)
|
|
1338
|
+
});
|
|
1112
1339
|
chosen = installable().filter((adapter) => names.includes(adapter.name));
|
|
1113
1340
|
}
|
|
1114
|
-
if (chosen.length === 0) {
|
|
1115
|
-
ui.say("No agent hooks will be installed. Add them later with rulecast install --agent <name>.", "Agents");
|
|
1116
|
-
}
|
|
1117
1341
|
const agents = [];
|
|
1118
1342
|
for (const adapter of chosen) {
|
|
1119
1343
|
let scope = flags.scope ?? "shared";
|
|
@@ -1172,7 +1396,7 @@ async function run(root, flags, registry, io, ui) {
|
|
|
1172
1396
|
const catalog = typeof loaded === "string" ? null : loaded;
|
|
1173
1397
|
if (typeof loaded === "string") ui.say(`${loaded}. Continuing without catalog rules.`, "Catalog unavailable");
|
|
1174
1398
|
const installed = new Set((configured?.rules ?? []).map(idOf).filter((id) => id !== null));
|
|
1175
|
-
const rules = catalog === null ? [] : await chooseRules(catalog, installed, files, flags, ui);
|
|
1399
|
+
const rules = catalog === null ? [] : await chooseRules(catalog, installed, files, flags, ui, registry);
|
|
1176
1400
|
const selections = await chooseConventions(rules, detection, ui);
|
|
1177
1401
|
const agents = await chooseAgents(detection, flags, ui);
|
|
1178
1402
|
const changes = await planInit(
|
|
@@ -1184,9 +1408,10 @@ async function run(root, flags, registry, io, ui) {
|
|
|
1184
1408
|
readText: (file) => readSourceFile(root, file)
|
|
1185
1409
|
}
|
|
1186
1410
|
);
|
|
1187
|
-
|
|
1411
|
+
const noHooks = agents.length === 0 ? [NO_HOOKS] : [];
|
|
1412
|
+
if (changes.length === 0) ui.say(["Nothing to change.", ...noHooks].join("\n"), "Review");
|
|
1188
1413
|
else {
|
|
1189
|
-
ui.say(reviewText(changes), "Review");
|
|
1414
|
+
ui.say([reviewText(changes), ...noHooks].join("\n"), "Review");
|
|
1190
1415
|
if (ui.prompter !== null) {
|
|
1191
1416
|
if (!await ui.prompter.confirm({ message: "Write?", initialValue: true })) {
|
|
1192
1417
|
ui.prompter.outro("Nothing written.");
|
|
@@ -1238,14 +1463,160 @@ async function initCommand(args, registry, io) {
|
|
|
1238
1463
|
}
|
|
1239
1464
|
}
|
|
1240
1465
|
|
|
1241
|
-
// src/commands/
|
|
1466
|
+
// src/commands/list.ts
|
|
1242
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";
|
|
1243
1574
|
|
|
1244
1575
|
// src/adapters/cli/format.ts
|
|
1245
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
|
+
}
|
|
1584
|
+
var TOP_FILES = 10;
|
|
1246
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
|
+
}
|
|
1247
1604
|
function terminal(delivery, options) {
|
|
1248
1605
|
const out = [];
|
|
1606
|
+
if (options.checked?.selection === "staged" && options.checked.files === 0 && delivery.warnings.length === 0) {
|
|
1607
|
+
return NOTHING_STAGED;
|
|
1608
|
+
}
|
|
1609
|
+
if (options.findings === false) {
|
|
1610
|
+
out.push(...problemsBlock(delivery.warnings));
|
|
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);
|
|
1618
|
+
return out.join("\n");
|
|
1619
|
+
}
|
|
1249
1620
|
const shown = /* @__PURE__ */ new Map();
|
|
1250
1621
|
const hidden = /* @__PURE__ */ new Map();
|
|
1251
1622
|
for (const finding of delivery.findings) {
|
|
@@ -1264,31 +1635,46 @@ function terminal(delivery, options) {
|
|
|
1264
1635
|
}
|
|
1265
1636
|
for (const [rule, rest] of hidden) out.push(`\u2026and ${rest.length} more for ${rule}`);
|
|
1266
1637
|
if (out.length > 0) out.push("");
|
|
1267
|
-
|
|
1268
|
-
out.push(
|
|
1638
|
+
if (delivery.preexistingSummary.length > 0) {
|
|
1639
|
+
out.push("backlog in the files checked (not from your change):");
|
|
1640
|
+
for (const summary of delivery.preexistingSummary) {
|
|
1641
|
+
out.push(` ${summary.rule} \xD7${summary.count} in ${summary.file}`);
|
|
1642
|
+
}
|
|
1643
|
+
if (options.backlog !== true) out.push(" see all of it: rulecast run --all-files --summary");
|
|
1269
1644
|
}
|
|
1270
1645
|
if (delivery.references.length > 0) {
|
|
1271
1646
|
out.push(`conventions: ${delivery.references.map((reference) => reference.ref).join(", ")}`);
|
|
1272
1647
|
}
|
|
1273
1648
|
if (delivery.preexistingSummary.length > 0 || delivery.references.length > 0) out.push("");
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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));
|
|
1277
1653
|
const errors = delivery.findings.filter((finding) => finding.severity === "error").length;
|
|
1278
1654
|
const warningsCount = delivery.findings.length - errors;
|
|
1655
|
+
const problems = delivery.warnings.length === 0 ? "" : ` \xB7 rulecast: ${plural3(delivery.warnings.length, "problem")} (see above)`;
|
|
1279
1656
|
out.push(
|
|
1280
|
-
delivery.findings.length === 0 ? "no findings" :
|
|
1657
|
+
(delivery.findings.length === 0 ? "no findings" : `findings: ${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`) + problems
|
|
1281
1658
|
);
|
|
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));
|
|
1282
1661
|
return out.join("\n");
|
|
1283
1662
|
}
|
|
1284
|
-
function json(delivery) {
|
|
1663
|
+
function json(delivery, options) {
|
|
1285
1664
|
return JSON.stringify(
|
|
1286
1665
|
{
|
|
1287
1666
|
findings: delivery.findings,
|
|
1667
|
+
// Always, whatever flags were given: a consumer that did not ask for it can ignore a key, and
|
|
1668
|
+
// one that wanted it cannot conjure it. This is the number a team tracks over time.
|
|
1669
|
+
backlog: summarise(delivery),
|
|
1288
1670
|
preexistingSummary: delivery.preexistingSummary,
|
|
1289
1671
|
references: delivery.references,
|
|
1290
1672
|
touches: delivery.touches,
|
|
1291
1673
|
warnings: delivery.warnings,
|
|
1674
|
+
skipped: options.skipped ?? [],
|
|
1675
|
+
checked: options.checked ?? null,
|
|
1676
|
+
ignored: options.ignored ?? [],
|
|
1677
|
+
notices: delivery.notices ?? [],
|
|
1292
1678
|
stop: delivery.stop
|
|
1293
1679
|
},
|
|
1294
1680
|
null,
|
|
@@ -1331,7 +1717,7 @@ function formatDelivery(delivery, format, options) {
|
|
|
1331
1717
|
case "agent":
|
|
1332
1718
|
return renderAgentText(delivery, options);
|
|
1333
1719
|
case "json":
|
|
1334
|
-
return json(delivery);
|
|
1720
|
+
return json(delivery, options);
|
|
1335
1721
|
case "sarif":
|
|
1336
1722
|
return sarif(delivery);
|
|
1337
1723
|
}
|
|
@@ -1343,18 +1729,20 @@ function exitCodeFor(delivery, failed) {
|
|
|
1343
1729
|
|
|
1344
1730
|
// src/commands/run.ts
|
|
1345
1731
|
function parseRunArgs(args) {
|
|
1346
|
-
const { values, tokens } =
|
|
1732
|
+
const { values, tokens } = parseArgs6({
|
|
1347
1733
|
args,
|
|
1348
1734
|
allowPositionals: true,
|
|
1349
1735
|
tokens: true,
|
|
1350
1736
|
options: {
|
|
1351
1737
|
files: { type: "boolean", default: false },
|
|
1352
1738
|
"all-files": { type: "boolean", default: false },
|
|
1739
|
+
summary: { type: "boolean", default: false },
|
|
1353
1740
|
"from-ref": { type: "string" },
|
|
1354
1741
|
"to-ref": { type: "string" },
|
|
1355
1742
|
format: { type: "string", default: "terminal" },
|
|
1356
1743
|
session: { type: "string" },
|
|
1357
1744
|
"no-llm": { type: "boolean", default: false },
|
|
1745
|
+
llm: { type: "boolean", default: false },
|
|
1358
1746
|
ref: { type: "string" }
|
|
1359
1747
|
}
|
|
1360
1748
|
});
|
|
@@ -1378,6 +1766,8 @@ function parseRunArgs(args) {
|
|
|
1378
1766
|
if (values.files && files.length === 0) throw new UsageError("--files needs at least one file");
|
|
1379
1767
|
if (values.files && values["all-files"]) throw new UsageError("--all-files and --files cannot be combined");
|
|
1380
1768
|
if (toRef !== null && fromRef === null) throw new UsageError("--to-ref needs --from-ref");
|
|
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");
|
|
1381
1771
|
if (fromRef !== null && (values.files || values["all-files"])) {
|
|
1382
1772
|
throw new UsageError("--from-ref cannot be combined with --all-files or --files");
|
|
1383
1773
|
}
|
|
@@ -1385,27 +1775,41 @@ function parseRunArgs(args) {
|
|
|
1385
1775
|
leading,
|
|
1386
1776
|
files: values.files ? files : null,
|
|
1387
1777
|
allFiles: values["all-files"],
|
|
1778
|
+
summary: values.summary,
|
|
1388
1779
|
fromRef,
|
|
1389
1780
|
toRef,
|
|
1390
1781
|
format,
|
|
1391
1782
|
session: values.session ?? null,
|
|
1392
1783
|
noLlm: values["no-llm"],
|
|
1784
|
+
llm: values.llm,
|
|
1393
1785
|
ref: values.ref ?? null
|
|
1394
1786
|
};
|
|
1395
1787
|
}
|
|
1396
1788
|
async function selectFiles(root, cwd, run2) {
|
|
1397
1789
|
if (run2.files !== null) {
|
|
1398
1790
|
const files = run2.files.map((file) => toProjectPath(root, cwd, file)).filter((file) => file !== null);
|
|
1399
|
-
return { files, baseCommit: null };
|
|
1791
|
+
return { selection: "files", files, baseCommit: null, content: WORKTREE };
|
|
1400
1792
|
}
|
|
1401
1793
|
if (run2.fromRef !== null) {
|
|
1402
1794
|
const base = await mergeBase(root, run2.fromRef, run2.toRef ?? "HEAD");
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
+
};
|
|
1405
1804
|
}
|
|
1406
|
-
if (run2.allFiles) return { files: await allFiles(root), baseCommit: null };
|
|
1407
|
-
if (run2.session !== null) return { files: [], baseCommit: null };
|
|
1408
|
-
return {
|
|
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
|
+
};
|
|
1409
1813
|
}
|
|
1410
1814
|
async function executeRun({ project, ruleId, run: run2, registry, io }) {
|
|
1411
1815
|
const root = project.root;
|
|
@@ -1413,6 +1817,9 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
|
|
|
1413
1817
|
throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
|
|
1414
1818
|
}
|
|
1415
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) : [];
|
|
1416
1823
|
const result = await runPipeline({
|
|
1417
1824
|
project,
|
|
1418
1825
|
stateDir: ensureProjectState(cacheHome(io.env), root),
|
|
@@ -1420,15 +1827,28 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
|
|
|
1420
1827
|
kind: "verify",
|
|
1421
1828
|
files: selected.files,
|
|
1422
1829
|
baseCommit: selected.baseCommit ?? void 0,
|
|
1830
|
+
content: selected.content,
|
|
1423
1831
|
session: run2.session === null ? void 0 : { id: run2.session },
|
|
1424
1832
|
cwd: root
|
|
1425
1833
|
},
|
|
1426
1834
|
registry,
|
|
1427
1835
|
maxContextChars: null,
|
|
1428
|
-
skipDetectorKinds:
|
|
1836
|
+
skipDetectorKinds: skipMetered ? meteredKinds : void 0,
|
|
1429
1837
|
onlyRules: ruleId === null ? void 0 : /* @__PURE__ */ new Set([ruleId])
|
|
1430
1838
|
});
|
|
1431
|
-
const text = formatDelivery(result.delivery, run2.format, {
|
|
1839
|
+
const text = formatDelivery(result.delivery, run2.format, {
|
|
1840
|
+
maxMatchesPerRule: project.config.maxMatchesPerRule,
|
|
1841
|
+
findings: !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
|
+
}))
|
|
1851
|
+
});
|
|
1432
1852
|
if (text) io.stdout(`${text}
|
|
1433
1853
|
`);
|
|
1434
1854
|
return exitCodeFor(result.delivery, result.failed);
|
|
@@ -1442,13 +1862,269 @@ async function runCommand(root, args, registry, io) {
|
|
|
1442
1862
|
return executeRun({ project, ruleId: run2.leading[0] ?? null, run: run2, registry, io });
|
|
1443
1863
|
}
|
|
1444
1864
|
|
|
1865
|
+
// src/commands/test.ts
|
|
1866
|
+
import path7 from "path";
|
|
1867
|
+
import { parseArgs as parseArgs7 } from "util";
|
|
1868
|
+
|
|
1869
|
+
// src/core/examples.ts
|
|
1870
|
+
var EXAMPLES_DIR = ".rulecast-examples-";
|
|
1871
|
+
function skippedResult(rule) {
|
|
1872
|
+
return { ...emptyResult(rule), skipped: true };
|
|
1873
|
+
}
|
|
1874
|
+
function emptyResult(rule) {
|
|
1875
|
+
return {
|
|
1876
|
+
rule,
|
|
1877
|
+
outcomes: [],
|
|
1878
|
+
recall: { matched: 0, total: 0 },
|
|
1879
|
+
precision: { correct: 0, total: 0 },
|
|
1880
|
+
missing: false,
|
|
1881
|
+
empty: false,
|
|
1882
|
+
skipped: false
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
async function runExamples(input) {
|
|
1886
|
+
const { rule } = input;
|
|
1887
|
+
const result = emptyResult(rule.id);
|
|
1888
|
+
if (rule.examples === null) return { ...result, missing: true };
|
|
1889
|
+
const cases = [
|
|
1890
|
+
...rule.examples.good.map((example, index) => ({ kind: "good", index, example })),
|
|
1891
|
+
...rule.examples.bad.map((example, index) => ({ kind: "bad", index, example }))
|
|
1892
|
+
];
|
|
1893
|
+
if (cases.length === 0) return { ...result, empty: 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 });
|
|
1904
|
+
}
|
|
1905
|
+
const fired = (outcome) => outcome.findings.length > 0;
|
|
1906
|
+
const bad = result.outcomes.filter((outcome) => outcome.kind === "bad");
|
|
1907
|
+
const goodFired = result.outcomes.filter((outcome) => outcome.kind === "good" && fired(outcome));
|
|
1908
|
+
const badFired = bad.filter(fired);
|
|
1909
|
+
result.recall = { matched: badFired.length, total: bad.length };
|
|
1910
|
+
result.precision = { correct: badFired.length, total: badFired.length + goodFired.length };
|
|
1911
|
+
return result;
|
|
1912
|
+
}
|
|
1913
|
+
async function runOne(input, scratch, example) {
|
|
1914
|
+
const relative = `${scratch}/${example.path}`;
|
|
1915
|
+
const output = await runDetection({
|
|
1916
|
+
detection: input.detection,
|
|
1917
|
+
event: "verify",
|
|
1918
|
+
selections: [{ rule: input.rule, files: [relative] }],
|
|
1919
|
+
// No baseline: an example is entirely new, which is what an author is asking about.
|
|
1920
|
+
changes: /* @__PURE__ */ new Map(),
|
|
1921
|
+
read: (name) => readSourceFile(input.detection.root, name),
|
|
1922
|
+
timeoutMs: input.timeoutMs
|
|
1923
|
+
});
|
|
1924
|
+
const failure = output.errors[0]?.message ?? (output.timedOut.length > 0 ? "timed out" : void 0);
|
|
1925
|
+
if (failure !== void 0) return { findings: [], error: failure };
|
|
1926
|
+
const findings = output.findings.map(({ match }) => ({
|
|
1927
|
+
line: match.line,
|
|
1928
|
+
column: match.column,
|
|
1929
|
+
text: match.text,
|
|
1930
|
+
message: renderTemplate(input.rule.message, {
|
|
1931
|
+
...match.captures,
|
|
1932
|
+
file: example.path,
|
|
1933
|
+
line: String(match.line),
|
|
1934
|
+
column: String(match.column),
|
|
1935
|
+
text: match.text,
|
|
1936
|
+
rule: input.rule.id
|
|
1937
|
+
})
|
|
1938
|
+
}));
|
|
1939
|
+
return { findings };
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// src/commands/test.ts
|
|
1943
|
+
function parseTestArgs(args) {
|
|
1944
|
+
const { values, positionals } = parseArgs7({
|
|
1945
|
+
args,
|
|
1946
|
+
allowPositionals: true,
|
|
1947
|
+
options: { against: { type: "string", multiple: true } }
|
|
1948
|
+
});
|
|
1949
|
+
if (positionals.length > 1) throw new UsageError(`unexpected arguments: ${positionals.slice(1).join(" ")}`);
|
|
1950
|
+
const against = values.against ?? [];
|
|
1951
|
+
const ruleId = positionals[0] ?? null;
|
|
1952
|
+
if (against.length > 0 && ruleId === null) throw new UsageError("--against needs a RULE_ID");
|
|
1953
|
+
return { ruleId, against };
|
|
1954
|
+
}
|
|
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
|
+
`;
|
|
1984
|
+
var ratio = (part, whole) => whole === 0 ? " \u2014 " : (part / whole).toFixed(2);
|
|
1985
|
+
function summaryLine(rule, result) {
|
|
1986
|
+
const id = rule.id.padEnd(36);
|
|
1987
|
+
if (result.skipped) return ` ${id}skipped (llm; name the rule to run it)`;
|
|
1988
|
+
if (result.missing || result.empty) return ` ${id}no examples`;
|
|
1989
|
+
const passed = result.outcomes.filter((outcome) => outcome.passed).length;
|
|
1990
|
+
const score = `${passed}/${result.outcomes.length}`.padEnd(7);
|
|
1991
|
+
return ` ${id}${score} P ${ratio(result.precision.correct, result.precision.total)} R ${ratio(result.recall.matched, result.recall.total)}`;
|
|
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
|
+
}
|
|
1998
|
+
function failureLines(rule, result) {
|
|
1999
|
+
const out = [];
|
|
2000
|
+
for (const outcome of result.outcomes) {
|
|
2001
|
+
if (outcome.passed) continue;
|
|
2002
|
+
const name = `${outcome.kind}[${outcome.index}] ${outcome.path}`;
|
|
2003
|
+
if (outcome.error !== void 0) {
|
|
2004
|
+
out.push(` ${name} \u2014 ${rule.detector.kind} failed: ${outcome.error}`);
|
|
2005
|
+
continue;
|
|
2006
|
+
}
|
|
2007
|
+
if (outcome.kind === "bad") {
|
|
2008
|
+
out.push(` ${name} \u2014 no finding`);
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
2011
|
+
const source = (rule.examples?.good[outcome.index]?.code ?? "").split(/\r?\n/);
|
|
2012
|
+
for (const finding of outcome.findings) {
|
|
2013
|
+
out.push(` ${name} \u2014 fired at line ${finding.line}`, ` ${source[finding.line - 1]?.trim() ?? ""}`);
|
|
2014
|
+
out.push(` ${matchedLine(finding.text)}`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
return out;
|
|
2018
|
+
}
|
|
2019
|
+
var AGAINST_TOP_FILES = 10;
|
|
2020
|
+
async function reportAgainst(root, rule, paths, detection, timeoutMs, io) {
|
|
2021
|
+
const prefixes = paths.map((given) => {
|
|
2022
|
+
if (path7.resolve(io.cwd, given) === path7.resolve(root)) return "";
|
|
2023
|
+
const resolved = toProjectPath(root, io.cwd, given);
|
|
2024
|
+
if (resolved === null) throw new UsageError(`${given} is outside the project`);
|
|
2025
|
+
return resolved;
|
|
2026
|
+
});
|
|
2027
|
+
const under = (file) => prefixes.some((prefix) => prefix === "" || file === prefix || file.startsWith(`${prefix}/`));
|
|
2028
|
+
const files = (await allFiles(root)).filter((file) => under(file) && rule.matches(file));
|
|
2029
|
+
if (files.length === 0) {
|
|
2030
|
+
io.stdout(`
|
|
2031
|
+
no files under ${paths.join(", ")} match ${rule.id}
|
|
2032
|
+
|
|
2033
|
+
`);
|
|
2034
|
+
return 0;
|
|
2035
|
+
}
|
|
2036
|
+
const output = await runDetection({
|
|
2037
|
+
detection,
|
|
2038
|
+
event: "verify",
|
|
2039
|
+
selections: [{ rule, files }],
|
|
2040
|
+
changes: /* @__PURE__ */ new Map(),
|
|
2041
|
+
read: (file) => readSourceFile(root, file),
|
|
2042
|
+
timeoutMs
|
|
2043
|
+
});
|
|
2044
|
+
const failure = output.errors[0];
|
|
2045
|
+
if (failure !== void 0) throw new Error(`${rule.detector.kind} detector failed: ${failure.message}`);
|
|
2046
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
2047
|
+
for (const { match } of output.findings) byFile.set(match.file, (byFile.get(match.file) ?? 0) + 1);
|
|
2048
|
+
const worst = [...byFile].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, AGAINST_TOP_FILES);
|
|
2049
|
+
const width = Math.max(...worst.map(([file]) => file.length), 0);
|
|
2050
|
+
const violations = output.findings.length;
|
|
2051
|
+
const out = ["", ` ${plural4(violations, "violation")} in ${byFile.size} of ${files.length} matching files`];
|
|
2052
|
+
if (worst.length > 0) {
|
|
2053
|
+
out.push("", ...worst.map(([file, count]) => ` ${file.padEnd(width + 2)}${count}`));
|
|
2054
|
+
if (byFile.size > worst.length) out.push(` \u2026and ${byFile.size - worst.length} more files`);
|
|
2055
|
+
}
|
|
2056
|
+
const note = againstNote(violations, byFile.size, files.length);
|
|
2057
|
+
if (note.length > 0) out.push("", ...note);
|
|
2058
|
+
out.push("");
|
|
2059
|
+
io.stdout(out.join("\n"));
|
|
2060
|
+
return 0;
|
|
2061
|
+
}
|
|
2062
|
+
async function testCommand(root, args, registry, io) {
|
|
2063
|
+
const { ruleId, against } = parseTestArgs(args);
|
|
2064
|
+
if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents`);
|
|
2065
|
+
const project = await compile({ root, registry, repos: fetchingRepos(cacheHome(io.env)) });
|
|
2066
|
+
if (ruleId !== null && !project.rules.some((rule) => rule.id === ruleId)) {
|
|
2067
|
+
throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
|
|
2068
|
+
}
|
|
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
|
+
}
|
|
2074
|
+
const stateDir = ensureProjectState(cacheHome(io.env), root);
|
|
2075
|
+
const resolver = createReferenceResolver(root);
|
|
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);
|
|
2079
|
+
const lines = [""];
|
|
2080
|
+
const noExamples = [];
|
|
2081
|
+
const notRun = [];
|
|
2082
|
+
let failedRules = 0;
|
|
2083
|
+
let ranRules = 0;
|
|
2084
|
+
for (const rule of rules) {
|
|
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
|
+
);
|
|
2089
|
+
lines.push(summaryLine(rule, result));
|
|
2090
|
+
if (result.missing || result.empty) {
|
|
2091
|
+
noExamples.push(rule.id);
|
|
2092
|
+
continue;
|
|
2093
|
+
}
|
|
2094
|
+
if (result.skipped) {
|
|
2095
|
+
notRun.push(rule.id);
|
|
2096
|
+
continue;
|
|
2097
|
+
}
|
|
2098
|
+
ranRules++;
|
|
2099
|
+
if (result.outcomes.some((outcome) => !outcome.passed)) {
|
|
2100
|
+
failedRules++;
|
|
2101
|
+
lines.push(...failureLines(rule, result));
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
if (rules.length === 0) {
|
|
2105
|
+
io.stdout("no rules with a detector\n");
|
|
2106
|
+
return 0;
|
|
2107
|
+
}
|
|
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
|
+
}
|
|
2112
|
+
lines.push(
|
|
2113
|
+
"",
|
|
2114
|
+
failedRules === 0 ? ranRules === 0 ? " nothing to run" : ` ${ranRules} ${ranRules === 1 ? "rule" : "rules"} passed` : ` ${failedRules} of ${ranRules} rules failed`,
|
|
2115
|
+
""
|
|
2116
|
+
);
|
|
2117
|
+
io.stdout(lines.join("\n"));
|
|
2118
|
+
return failedRules === 0 ? 0 : 1;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
1445
2121
|
// src/commands/try-repo.ts
|
|
1446
2122
|
import { existsSync as existsSync5, statSync as statSync2 } from "fs";
|
|
1447
2123
|
import { mkdtemp, rm as rm2 } from "fs/promises";
|
|
1448
2124
|
import { tmpdir as tmpdir2 } from "os";
|
|
1449
|
-
import
|
|
2125
|
+
import path8 from "path";
|
|
1450
2126
|
var isDirectory = (file) => existsSync5(file) && statSync2(file).isDirectory();
|
|
1451
|
-
var isGitRepo = (dir) => existsSync5(
|
|
2127
|
+
var isGitRepo = (dir) => existsSync5(path8.join(dir, ".git")) || existsSync5(path8.join(dir, "HEAD"));
|
|
1452
2128
|
async function baseConfigData(root) {
|
|
1453
2129
|
if (!hasProject(root)) return {};
|
|
1454
2130
|
const data = await readConfigData(root);
|
|
@@ -1460,16 +2136,16 @@ async function tryRepoCommand(root, args, registry, io) {
|
|
|
1460
2136
|
const [repo, ruleId, ...extra] = run2.leading;
|
|
1461
2137
|
if (repo === void 0) throw new UsageError("try-repo needs a repository path or URL");
|
|
1462
2138
|
if (extra.length > 0) throw new UsageError(`unexpected arguments: ${extra.join(" ")}`);
|
|
1463
|
-
const local =
|
|
2139
|
+
const local = path8.resolve(io.cwd, repo);
|
|
1464
2140
|
const directory = isDirectory(local);
|
|
1465
2141
|
if (directory && run2.ref !== null && !isGitRepo(local)) throw new UsageError("--ref only applies to URLs");
|
|
1466
2142
|
const fromDirectory = directory && run2.ref === null;
|
|
1467
2143
|
const rev = fromDirectory ? "working-tree" : run2.ref ?? "HEAD";
|
|
1468
|
-
const temp = fromDirectory ? null : await mkdtemp(
|
|
2144
|
+
const temp = fromDirectory ? null : await mkdtemp(path8.join(tmpdir2(), "rulecast-try-repo-"));
|
|
1469
2145
|
try {
|
|
1470
2146
|
const dir = temp ?? local;
|
|
1471
2147
|
if (temp !== null) await fetchCheckout(directory ? local : repo, rev, temp);
|
|
1472
|
-
const label = fromDirectory ? `${
|
|
2148
|
+
const label = fromDirectory ? `${path8.basename(local)}@${rev}` : repoLabel(repo, rev);
|
|
1473
2149
|
const manifest = await readManifest(dir);
|
|
1474
2150
|
if (!manifest.ok) throw new Error(`${label}: ${manifest.message}`);
|
|
1475
2151
|
const ids = manifest.value.flatMap((rule) => {
|
|
@@ -1490,23 +2166,42 @@ async function tryRepoCommand(root, args, registry, io) {
|
|
|
1490
2166
|
|
|
1491
2167
|
// src/commands/validate.ts
|
|
1492
2168
|
import { existsSync as existsSync6 } from "fs";
|
|
1493
|
-
import
|
|
2169
|
+
import path9 from "path";
|
|
1494
2170
|
function formatDiagnostic(diagnostic) {
|
|
1495
2171
|
return `${diagnostic.level === "warning" ? "warning: " : ""}${diagnosticText(diagnostic)}`;
|
|
1496
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
|
+
}
|
|
1497
2182
|
async function validateCommand(root, args, registry, io) {
|
|
1498
|
-
const targets = args.length > 0 ? args.map((file) => ({ shown: file, full:
|
|
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) }));
|
|
1499
2184
|
if (targets.length === 0) throw new Error(`nothing to validate: no ${CONFIG_FILE} or ${MANIFEST_FILE}`);
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
+
);
|
|
1506
2196
|
let failed = false;
|
|
1507
|
-
for (const { shown, full } of
|
|
1508
|
-
const dir =
|
|
1509
|
-
const result =
|
|
2197
|
+
for (const { shown, full, data, kind } of read) {
|
|
2198
|
+
const dir = path9.dirname(full);
|
|
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);
|
|
1510
2205
|
for (const diagnostic of result.diagnostics) io.stdout(`${shown}: ${formatDiagnostic(diagnostic)}
|
|
1511
2206
|
`);
|
|
1512
2207
|
if (result.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
|
|
@@ -1521,10 +2216,10 @@ async function validateCommand(root, args, registry, io) {
|
|
|
1521
2216
|
}
|
|
1522
2217
|
|
|
1523
2218
|
// src/commands/warm.ts
|
|
1524
|
-
import { parseArgs as
|
|
2219
|
+
import { parseArgs as parseArgs8 } from "util";
|
|
1525
2220
|
var WARM_TIMEOUT_MS = 5 * 6e4;
|
|
1526
2221
|
async function warmCommand(root, args, registry, io) {
|
|
1527
|
-
const { values } =
|
|
2222
|
+
const { values } = parseArgs8({ args, options: { detector: { type: "string", multiple: true } } });
|
|
1528
2223
|
if (!hasProject(root)) throw new Error(`no .rulecast-config.yaml in ${root} or its parents (run rulecast init)`);
|
|
1529
2224
|
const home = cacheHome(io.env);
|
|
1530
2225
|
const stateDir = ensureProjectState(home, root);
|
|
@@ -1554,23 +2249,23 @@ async function warmCommand(root, args, registry, io) {
|
|
|
1554
2249
|
}
|
|
1555
2250
|
|
|
1556
2251
|
// src/commands/main.ts
|
|
1557
|
-
var USAGE = `usage:
|
|
1558
|
-
rulecast init [--rules id,id | --no-rules] [--agent <name>]... [--scope shared|personal] [--yes]
|
|
1559
|
-
rulecast install [--agent <name>]... [--scope shared|personal]
|
|
1560
|
-
rulecast uninstall [--agent <name>]...
|
|
1561
|
-
rulecast run [RULE_ID] [--all-files | --files F...] [--from-ref A [--to-ref B]] [--format terminal|agent|json|sarif] [--session <id>] [--no-llm]
|
|
1562
|
-
rulecast autoupdate [--freeze] [--repo URL]...
|
|
1563
|
-
rulecast try-repo <path|url> [RULE_ID] [--ref REV] [run flags]
|
|
1564
|
-
rulecast validate [file...]
|
|
1565
|
-
rulecast clean [--project]
|
|
1566
|
-
rulecast hook <adapter>
|
|
1567
|
-
rulecast warm [--detector <kind>]...
|
|
1568
|
-
rulecast doctor
|
|
1569
|
-
`;
|
|
1570
2252
|
async function main(argv, io) {
|
|
1571
2253
|
const [command, ...args] = argv;
|
|
1572
2254
|
const registry = createRegistry([...builtinDetectors]);
|
|
1573
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
|
+
}
|
|
1574
2269
|
try {
|
|
1575
2270
|
switch (command) {
|
|
1576
2271
|
case "init":
|
|
@@ -1581,10 +2276,14 @@ async function main(argv, io) {
|
|
|
1581
2276
|
return await uninstallCommand(root, args, io);
|
|
1582
2277
|
case "run":
|
|
1583
2278
|
return await runCommand(root, args, registry, io);
|
|
2279
|
+
case "list":
|
|
2280
|
+
return await listCommand(root, args, registry, io);
|
|
1584
2281
|
case "autoupdate":
|
|
1585
2282
|
return await autoupdateCommand(root, args, io);
|
|
1586
2283
|
case "try-repo":
|
|
1587
2284
|
return await tryRepoCommand(root, args, registry, io);
|
|
2285
|
+
case "test":
|
|
2286
|
+
return await testCommand(root, args, registry, io);
|
|
1588
2287
|
case "validate":
|
|
1589
2288
|
return await validateCommand(root, args, registry, io);
|
|
1590
2289
|
case "clean":
|
|
@@ -1598,7 +2297,8 @@ async function main(argv, io) {
|
|
|
1598
2297
|
case void 0:
|
|
1599
2298
|
case "help":
|
|
1600
2299
|
case "--help":
|
|
1601
|
-
|
|
2300
|
+
case "-h":
|
|
2301
|
+
io.stdout(usage());
|
|
1602
2302
|
return command === void 0 ? 2 : 0;
|
|
1603
2303
|
default:
|
|
1604
2304
|
throw new UsageError(`unknown command "${command}"`);
|
|
@@ -1607,7 +2307,8 @@ async function main(argv, io) {
|
|
|
1607
2307
|
io.stderr(`rulecast: ${errorMessage(error)}
|
|
1608
2308
|
`);
|
|
1609
2309
|
if (error instanceof UsageError || error.code?.startsWith("ERR_PARSE_ARGS")) {
|
|
1610
|
-
io.stderr(
|
|
2310
|
+
io.stderr(`
|
|
2311
|
+
${command !== void 0 && commandHelp(command) || usage()}`);
|
|
1611
2312
|
}
|
|
1612
2313
|
return 2;
|
|
1613
2314
|
}
|
|
@@ -1625,7 +2326,7 @@ function spawnDetached(command, args, cwd) {
|
|
|
1625
2326
|
// src/init/clipboard.ts
|
|
1626
2327
|
import { spawn as spawn2 } from "child_process";
|
|
1627
2328
|
import { accessSync, constants, statSync as statSync3 } from "fs";
|
|
1628
|
-
import
|
|
2329
|
+
import path10 from "path";
|
|
1629
2330
|
var CANDIDATES = [
|
|
1630
2331
|
{ command: "pbcopy", args: [] },
|
|
1631
2332
|
{ command: "wl-copy", args: [] },
|
|
@@ -1642,9 +2343,9 @@ function isExecutable(file) {
|
|
|
1642
2343
|
}
|
|
1643
2344
|
}
|
|
1644
2345
|
function findOnPath(name, env) {
|
|
1645
|
-
for (const dir of (env.PATH ?? "").split(
|
|
2346
|
+
for (const dir of (env.PATH ?? "").split(path10.delimiter)) {
|
|
1646
2347
|
if (dir === "") continue;
|
|
1647
|
-
const candidate =
|
|
2348
|
+
const candidate = path10.join(dir, name);
|
|
1648
2349
|
if (isExecutable(candidate)) return candidate;
|
|
1649
2350
|
}
|
|
1650
2351
|
return null;
|