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