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