@syv-ai/rulecast 0.1.1 → 0.3.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 +49 -2
- package/dist/chunk-7KUPYT35.js +1977 -0
- package/dist/chunk-HZUATJ53.js +947 -0
- package/dist/chunk-KOUHBT7F.js +1361 -0
- package/dist/cli.js +326 -42
- package/dist/index.d.ts +33 -763
- package/dist/index.js +15 -37
- package/dist/internal.d.ts +136 -0
- package/dist/internal.js +38 -0
- package/dist/types-C8b7hrnG.d.ts +776 -0
- package/package.json +8 -2
- package/dist/chunk-3HRTZULM.js +0 -3780
package/dist/cli.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
ADAPTERS,
|
|
4
|
+
adapterByName,
|
|
5
|
+
builtinDetectors,
|
|
6
|
+
createRegistry,
|
|
7
|
+
renderBacklog,
|
|
8
|
+
summarise
|
|
9
|
+
} from "./chunk-KOUHBT7F.js";
|
|
10
|
+
import {
|
|
4
11
|
CONFIG_FILE,
|
|
5
12
|
LockTimeoutError,
|
|
6
13
|
MANIFEST_FILE,
|
|
7
14
|
NO_PROMPT,
|
|
8
15
|
RepoFetchError,
|
|
9
|
-
VERSION,
|
|
10
|
-
adapterByName,
|
|
11
16
|
allFiles,
|
|
12
|
-
builtinDetectors,
|
|
13
17
|
cacheHome,
|
|
14
18
|
cachedRepo,
|
|
15
19
|
cachedRepos,
|
|
@@ -18,37 +22,45 @@ import {
|
|
|
18
22
|
checkDetectors,
|
|
19
23
|
compile,
|
|
20
24
|
compileManifest,
|
|
21
|
-
|
|
25
|
+
createReferenceResolver,
|
|
22
26
|
debugLogger,
|
|
23
27
|
defaultConfig,
|
|
24
|
-
detectorCacheDir,
|
|
25
28
|
diagnosticText,
|
|
26
|
-
diskCache,
|
|
27
29
|
ensureProjectState,
|
|
28
30
|
ensureRepo,
|
|
29
|
-
errorMessage,
|
|
30
31
|
fetchCheckout,
|
|
31
32
|
fetchingRepos,
|
|
32
33
|
fixedRepo,
|
|
33
34
|
git,
|
|
34
|
-
isNotFound,
|
|
35
|
-
memoryCache,
|
|
36
35
|
mergeBase,
|
|
37
36
|
parseConfig,
|
|
38
|
-
parseVersion,
|
|
39
37
|
projectStateDir,
|
|
40
38
|
readConfigData,
|
|
41
39
|
readManifest,
|
|
42
|
-
readSourceFile,
|
|
43
|
-
renderAgentText,
|
|
44
40
|
repoLabel,
|
|
41
|
+
resolveRuleContext,
|
|
42
|
+
rulesOfKind,
|
|
45
43
|
runDetection,
|
|
46
44
|
runPipeline,
|
|
47
|
-
scanHeadings,
|
|
48
45
|
stagedFiles,
|
|
49
|
-
tagsOf,
|
|
50
46
|
withLock
|
|
51
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-7KUPYT35.js";
|
|
48
|
+
import {
|
|
49
|
+
VERSION,
|
|
50
|
+
detectorCacheDir,
|
|
51
|
+
diskCache,
|
|
52
|
+
errorMessage,
|
|
53
|
+
isDetectorRule,
|
|
54
|
+
isNotFound,
|
|
55
|
+
memoryCache,
|
|
56
|
+
onPath,
|
|
57
|
+
parseVersion,
|
|
58
|
+
readSourceFile,
|
|
59
|
+
renderAgentText,
|
|
60
|
+
renderTemplate,
|
|
61
|
+
scanHeadings,
|
|
62
|
+
tagsOf
|
|
63
|
+
} from "./chunk-HZUATJ53.js";
|
|
52
64
|
import {
|
|
53
65
|
Cancelled
|
|
54
66
|
} from "./chunk-K37BHCEK.js";
|
|
@@ -196,6 +208,9 @@ function inFile(file, transform) {
|
|
|
196
208
|
throw new Error(`${file}: ${errorMessage(error)}`);
|
|
197
209
|
}
|
|
198
210
|
}
|
|
211
|
+
async function hookCommandResolves(root, signal) {
|
|
212
|
+
return existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")) || await onPath("rulecast", signal);
|
|
213
|
+
}
|
|
199
214
|
async function hooksInstalled(root, adapter, verifyMs) {
|
|
200
215
|
const install = installOf(adapter);
|
|
201
216
|
const command = install.command(existsSync2(path2.join(root, "node_modules", ".bin", "rulecast")));
|
|
@@ -434,12 +449,14 @@ async function doctorCommand(root, args, registry, io) {
|
|
|
434
449
|
}
|
|
435
450
|
}
|
|
436
451
|
io.stdout("\nhooks\n");
|
|
452
|
+
const resolves = await hookCommandResolves(root);
|
|
437
453
|
for (const adapter of ADAPTERS) {
|
|
438
454
|
if (adapter.install === null) continue;
|
|
439
455
|
const file = await hooksInstalled(root, adapter, project.config.timeouts.verifyMs);
|
|
440
|
-
const level = file === null ? "warning" : "ok";
|
|
456
|
+
const level = file === null || !resolves ? "warning" : "ok";
|
|
441
457
|
count(level);
|
|
442
|
-
|
|
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.`;
|
|
459
|
+
io.stdout(line(level, adapter.label, detail));
|
|
443
460
|
}
|
|
444
461
|
io.stdout("\ncache\n");
|
|
445
462
|
io.stdout(` home ${home}
|
|
@@ -477,7 +494,7 @@ async function dryRun(project, registry) {
|
|
|
477
494
|
}
|
|
478
495
|
const lines = [];
|
|
479
496
|
for (const rule of project.rules) {
|
|
480
|
-
if (rule
|
|
497
|
+
if (!isDetectorRule(rule)) {
|
|
481
498
|
lines.push({ rule: rule.id, level: "ok", detail: "context only, nothing to run" });
|
|
482
499
|
continue;
|
|
483
500
|
}
|
|
@@ -542,7 +559,7 @@ async function warmDetectors(options) {
|
|
|
542
559
|
await Promise.all(
|
|
543
560
|
kinds.map(async (kind) => {
|
|
544
561
|
const detector = registry.get(kind);
|
|
545
|
-
const rules = project.rules
|
|
562
|
+
const rules = rulesOfKind(project.rules, kind);
|
|
546
563
|
try {
|
|
547
564
|
await withLock(
|
|
548
565
|
path4.join(stateDir, "warm", kind),
|
|
@@ -1237,15 +1254,25 @@ import { parseArgs as parseArgs5 } from "util";
|
|
|
1237
1254
|
|
|
1238
1255
|
// src/adapters/cli/format.ts
|
|
1239
1256
|
var CLI_FORMATS = ["terminal", "agent", "json", "sarif"];
|
|
1257
|
+
var TOP_FILES = 10;
|
|
1240
1258
|
var plural3 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
1241
1259
|
function terminal(delivery, options) {
|
|
1242
1260
|
const out = [];
|
|
1261
|
+
if (options.findings === false) {
|
|
1262
|
+
if (delivery.warnings.length > 0) {
|
|
1263
|
+
out.push("warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`), "");
|
|
1264
|
+
}
|
|
1265
|
+
if (options.backlog === true) out.push(renderBacklog(summarise(delivery), { topFiles: TOP_FILES }));
|
|
1266
|
+
return out.join("\n");
|
|
1267
|
+
}
|
|
1243
1268
|
const shown = /* @__PURE__ */ new Map();
|
|
1244
1269
|
const hidden = /* @__PURE__ */ new Map();
|
|
1245
1270
|
for (const finding of delivery.findings) {
|
|
1246
1271
|
const count = shown.get(finding.rule) ?? 0;
|
|
1247
1272
|
if (count >= options.maxMatchesPerRule) {
|
|
1248
|
-
|
|
1273
|
+
const rest = hidden.get(finding.rule);
|
|
1274
|
+
if (rest === void 0) hidden.set(finding.rule, [finding]);
|
|
1275
|
+
else rest.push(finding);
|
|
1249
1276
|
continue;
|
|
1250
1277
|
}
|
|
1251
1278
|
shown.set(finding.rule, count + 1);
|
|
@@ -1256,8 +1283,12 @@ function terminal(delivery, options) {
|
|
|
1256
1283
|
}
|
|
1257
1284
|
for (const [rule, rest] of hidden) out.push(`\u2026and ${rest.length} more for ${rule}`);
|
|
1258
1285
|
if (out.length > 0) out.push("");
|
|
1259
|
-
|
|
1260
|
-
out.push(
|
|
1286
|
+
if (delivery.preexistingSummary.length > 0) {
|
|
1287
|
+
out.push("backlog in the files checked (not from your change):");
|
|
1288
|
+
for (const summary of delivery.preexistingSummary) {
|
|
1289
|
+
out.push(` ${summary.rule} \xD7${summary.count} in ${summary.file}`);
|
|
1290
|
+
}
|
|
1291
|
+
if (options.backlog !== true) out.push(" see all of it: rulecast run --all-files --summary");
|
|
1261
1292
|
}
|
|
1262
1293
|
if (delivery.references.length > 0) {
|
|
1263
1294
|
out.push(`conventions: ${delivery.references.map((reference) => reference.ref).join(", ")}`);
|
|
@@ -1271,8 +1302,26 @@ function terminal(delivery, options) {
|
|
|
1271
1302
|
out.push(
|
|
1272
1303
|
delivery.findings.length === 0 ? "no findings" : `${plural3(errors, "error")}, ${plural3(warningsCount, "warning")}`
|
|
1273
1304
|
);
|
|
1305
|
+
if (options.backlog === true) out.push("", renderBacklog(summarise(delivery), { topFiles: TOP_FILES }));
|
|
1274
1306
|
return out.join("\n");
|
|
1275
1307
|
}
|
|
1308
|
+
function json(delivery) {
|
|
1309
|
+
return JSON.stringify(
|
|
1310
|
+
{
|
|
1311
|
+
findings: delivery.findings,
|
|
1312
|
+
// Always, whatever flags were given: a consumer that did not ask for it can ignore a key, and
|
|
1313
|
+
// one that wanted it cannot conjure it. This is the number a team tracks over time.
|
|
1314
|
+
backlog: summarise(delivery),
|
|
1315
|
+
preexistingSummary: delivery.preexistingSummary,
|
|
1316
|
+
references: delivery.references,
|
|
1317
|
+
touches: delivery.touches,
|
|
1318
|
+
warnings: delivery.warnings,
|
|
1319
|
+
stop: delivery.stop
|
|
1320
|
+
},
|
|
1321
|
+
null,
|
|
1322
|
+
2
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1276
1325
|
function sarif(delivery) {
|
|
1277
1326
|
const rules = [...new Set(delivery.findings.map((finding) => finding.rule))].map((id) => ({ id }));
|
|
1278
1327
|
return JSON.stringify(
|
|
@@ -1309,7 +1358,7 @@ function formatDelivery(delivery, format, options) {
|
|
|
1309
1358
|
case "agent":
|
|
1310
1359
|
return renderAgentText(delivery, options);
|
|
1311
1360
|
case "json":
|
|
1312
|
-
return
|
|
1361
|
+
return json(delivery);
|
|
1313
1362
|
case "sarif":
|
|
1314
1363
|
return sarif(delivery);
|
|
1315
1364
|
}
|
|
@@ -1328,6 +1377,7 @@ function parseRunArgs(args) {
|
|
|
1328
1377
|
options: {
|
|
1329
1378
|
files: { type: "boolean", default: false },
|
|
1330
1379
|
"all-files": { type: "boolean", default: false },
|
|
1380
|
+
summary: { type: "boolean", default: false },
|
|
1331
1381
|
"from-ref": { type: "string" },
|
|
1332
1382
|
"to-ref": { type: "string" },
|
|
1333
1383
|
format: { type: "string", default: "terminal" },
|
|
@@ -1356,6 +1406,7 @@ function parseRunArgs(args) {
|
|
|
1356
1406
|
if (values.files && files.length === 0) throw new UsageError("--files needs at least one file");
|
|
1357
1407
|
if (values.files && values["all-files"]) throw new UsageError("--all-files and --files cannot be combined");
|
|
1358
1408
|
if (toRef !== null && fromRef === null) throw new UsageError("--to-ref needs --from-ref");
|
|
1409
|
+
if (values.summary && format === "sarif") throw new UsageError("--summary cannot be combined with --format sarif");
|
|
1359
1410
|
if (fromRef !== null && (values.files || values["all-files"])) {
|
|
1360
1411
|
throw new UsageError("--from-ref cannot be combined with --all-files or --files");
|
|
1361
1412
|
}
|
|
@@ -1363,6 +1414,7 @@ function parseRunArgs(args) {
|
|
|
1363
1414
|
leading,
|
|
1364
1415
|
files: values.files ? files : null,
|
|
1365
1416
|
allFiles: values["all-files"],
|
|
1417
|
+
summary: values.summary,
|
|
1366
1418
|
fromRef,
|
|
1367
1419
|
toRef,
|
|
1368
1420
|
format,
|
|
@@ -1406,7 +1458,11 @@ async function executeRun({ project, ruleId, run: run2, registry, io }) {
|
|
|
1406
1458
|
skipDetectorKinds: run2.noLlm ? /* @__PURE__ */ new Set(["llm"]) : void 0,
|
|
1407
1459
|
onlyRules: ruleId === null ? void 0 : /* @__PURE__ */ new Set([ruleId])
|
|
1408
1460
|
});
|
|
1409
|
-
const text = formatDelivery(result.delivery, run2.format, {
|
|
1461
|
+
const text = formatDelivery(result.delivery, run2.format, {
|
|
1462
|
+
maxMatchesPerRule: project.config.maxMatchesPerRule,
|
|
1463
|
+
findings: !run2.summary,
|
|
1464
|
+
backlog: run2.allFiles || run2.summary
|
|
1465
|
+
});
|
|
1410
1466
|
if (text) io.stdout(`${text}
|
|
1411
1467
|
`);
|
|
1412
1468
|
return exitCodeFor(result.delivery, result.failed);
|
|
@@ -1420,13 +1476,238 @@ async function runCommand(root, args, registry, io) {
|
|
|
1420
1476
|
return executeRun({ project, ruleId: run2.leading[0] ?? null, run: run2, registry, io });
|
|
1421
1477
|
}
|
|
1422
1478
|
|
|
1479
|
+
// src/commands/test.ts
|
|
1480
|
+
import { parseArgs as parseArgs6 } from "util";
|
|
1481
|
+
|
|
1482
|
+
// 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
|
+
var EXAMPLES_DIR = ".rulecast-examples-";
|
|
1486
|
+
function skippedResult(rule) {
|
|
1487
|
+
return { ...emptyResult(rule), skipped: true };
|
|
1488
|
+
}
|
|
1489
|
+
function emptyResult(rule) {
|
|
1490
|
+
return {
|
|
1491
|
+
rule,
|
|
1492
|
+
outcomes: [],
|
|
1493
|
+
recall: { matched: 0, total: 0 },
|
|
1494
|
+
precision: { correct: 0, total: 0 },
|
|
1495
|
+
missing: false,
|
|
1496
|
+
empty: false,
|
|
1497
|
+
skipped: false
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
async function runExamples(input) {
|
|
1501
|
+
const { rule } = input;
|
|
1502
|
+
const result = emptyResult(rule.id);
|
|
1503
|
+
if (rule.examples === null) return { ...result, missing: true };
|
|
1504
|
+
const cases = [
|
|
1505
|
+
...rule.examples.good.map((example, index) => ({ kind: "good", index, example })),
|
|
1506
|
+
...rule.examples.bad.map((example, index) => ({ kind: "bad", index, example }))
|
|
1507
|
+
];
|
|
1508
|
+
if (cases.length === 0) return { ...result, empty: true };
|
|
1509
|
+
const scratch = path7.basename(await mkdtemp(path7.join(input.root, EXAMPLES_DIR)));
|
|
1510
|
+
try {
|
|
1511
|
+
for (const { kind, index, example } of cases) {
|
|
1512
|
+
const outcome = await runOne(input, scratch, example);
|
|
1513
|
+
const fired2 = outcome.findings.length > 0;
|
|
1514
|
+
const passed = outcome.error === void 0 && fired2 === (kind === "bad");
|
|
1515
|
+
result.outcomes.push({ kind, index, path: example.path, ...outcome, passed });
|
|
1516
|
+
}
|
|
1517
|
+
} finally {
|
|
1518
|
+
await rm2(path7.join(input.root, scratch), { recursive: true, force: true });
|
|
1519
|
+
}
|
|
1520
|
+
const fired = (outcome) => outcome.findings.length > 0;
|
|
1521
|
+
const bad = result.outcomes.filter((outcome) => outcome.kind === "bad");
|
|
1522
|
+
const goodFired = result.outcomes.filter((outcome) => outcome.kind === "good" && fired(outcome));
|
|
1523
|
+
const badFired = bad.filter(fired);
|
|
1524
|
+
result.recall = { matched: badFired.length, total: bad.length };
|
|
1525
|
+
result.precision = { correct: badFired.length, total: badFired.length + goodFired.length };
|
|
1526
|
+
return result;
|
|
1527
|
+
}
|
|
1528
|
+
async function runOne(input, scratch, example) {
|
|
1529
|
+
const relative = path7.join(scratch, example.path);
|
|
1530
|
+
const file = path7.join(input.root, relative);
|
|
1531
|
+
await mkdir3(path7.dirname(file), { recursive: true });
|
|
1532
|
+
await writeFile4(file, example.code);
|
|
1533
|
+
const output = await runDetection({
|
|
1534
|
+
root: input.root,
|
|
1535
|
+
event: "verify",
|
|
1536
|
+
selections: [{ rule: input.rule, files: [relative] }],
|
|
1537
|
+
// No baseline: an example is entirely new, which is what an author is asking about.
|
|
1538
|
+
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,
|
|
1544
|
+
timeoutMs: input.timeoutMs
|
|
1545
|
+
});
|
|
1546
|
+
const failure = output.errors[0]?.message ?? (output.timedOut.length > 0 ? "timed out" : void 0);
|
|
1547
|
+
if (failure !== void 0) return { findings: [], error: failure };
|
|
1548
|
+
const findings = output.findings.map(({ match }) => ({
|
|
1549
|
+
line: match.line,
|
|
1550
|
+
column: match.column,
|
|
1551
|
+
message: renderTemplate(input.rule.message, {
|
|
1552
|
+
...match.captures,
|
|
1553
|
+
file: example.path,
|
|
1554
|
+
line: String(match.line),
|
|
1555
|
+
column: String(match.column),
|
|
1556
|
+
text: match.text,
|
|
1557
|
+
rule: input.rule.id
|
|
1558
|
+
})
|
|
1559
|
+
}));
|
|
1560
|
+
return { findings };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/commands/test.ts
|
|
1564
|
+
function parseTestArgs(args) {
|
|
1565
|
+
const { values, positionals } = parseArgs6({
|
|
1566
|
+
args,
|
|
1567
|
+
allowPositionals: true,
|
|
1568
|
+
options: { against: { type: "string", multiple: true } }
|
|
1569
|
+
});
|
|
1570
|
+
if (positionals.length > 1) throw new UsageError(`unexpected arguments: ${positionals.slice(1).join(" ")}`);
|
|
1571
|
+
const against = values.against ?? [];
|
|
1572
|
+
const ruleId = positionals[0] ?? null;
|
|
1573
|
+
if (against.length > 0 && ruleId === null) throw new UsageError("--against needs a RULE_ID");
|
|
1574
|
+
return { ruleId, against };
|
|
1575
|
+
}
|
|
1576
|
+
var AGAINST_NOTES = [
|
|
1577
|
+
" A rule this common is usually true by definition rather than a convention.",
|
|
1578
|
+
" A rule with almost no violations is usually not worth an agent's context."
|
|
1579
|
+
];
|
|
1580
|
+
var ZERO_NOTES = [
|
|
1581
|
+
" Nothing to fix today is not the same as nothing to catch: this counts the",
|
|
1582
|
+
" stock, not how often an edit would break the rule."
|
|
1583
|
+
];
|
|
1584
|
+
var ratio = (part, whole) => whole === 0 ? " \u2014 " : (part / whole).toFixed(2);
|
|
1585
|
+
function summaryLine(rule, result) {
|
|
1586
|
+
const id = rule.id.padEnd(36);
|
|
1587
|
+
if (result.skipped) return ` ${id}skipped (llm; name the rule to run it)`;
|
|
1588
|
+
if (result.missing || result.empty) return ` ${id}no examples`;
|
|
1589
|
+
const passed = result.outcomes.filter((outcome) => outcome.passed).length;
|
|
1590
|
+
const score = `${passed}/${result.outcomes.length}`.padEnd(7);
|
|
1591
|
+
return ` ${id}${score} P ${ratio(result.precision.correct, result.precision.total)} R ${ratio(result.recall.matched, result.recall.total)}`;
|
|
1592
|
+
}
|
|
1593
|
+
function failureLines(rule, result) {
|
|
1594
|
+
const out = [];
|
|
1595
|
+
for (const outcome of result.outcomes) {
|
|
1596
|
+
if (outcome.passed) continue;
|
|
1597
|
+
const name = `${outcome.kind}[${outcome.index}] ${outcome.path}`;
|
|
1598
|
+
if (outcome.error !== void 0) {
|
|
1599
|
+
out.push(` ${name} \u2014 ${rule.detector.kind} failed: ${outcome.error}`);
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
if (outcome.kind === "bad") {
|
|
1603
|
+
out.push(` ${name} \u2014 no finding`);
|
|
1604
|
+
continue;
|
|
1605
|
+
}
|
|
1606
|
+
const source = (rule.examples?.good[outcome.index]?.code ?? "").split(/\r?\n/);
|
|
1607
|
+
for (const finding of outcome.findings) {
|
|
1608
|
+
out.push(` ${name} \u2014 fired at line ${finding.line}`, ` ${source[finding.line - 1]?.trim() ?? ""}`);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
return out;
|
|
1612
|
+
}
|
|
1613
|
+
var AGAINST_TOP_FILES = 10;
|
|
1614
|
+
async function reportAgainst(root, rule, paths, detection, io) {
|
|
1615
|
+
const prefixes = paths.map((given) => {
|
|
1616
|
+
const resolved = toProjectPath(root, io.cwd, given);
|
|
1617
|
+
if (resolved === null) throw new UsageError(`${given} is outside the project`);
|
|
1618
|
+
return resolved === "." ? "" : resolved;
|
|
1619
|
+
});
|
|
1620
|
+
const under = (file) => prefixes.some((prefix) => prefix === "" || file === prefix || file.startsWith(`${prefix}/`));
|
|
1621
|
+
const files = (await allFiles(root)).filter((file) => under(file) && rule.matches(file));
|
|
1622
|
+
if (files.length === 0) {
|
|
1623
|
+
io.stdout(`
|
|
1624
|
+
no files under ${paths.join(", ")} match ${rule.id}
|
|
1625
|
+
|
|
1626
|
+
`);
|
|
1627
|
+
return 0;
|
|
1628
|
+
}
|
|
1629
|
+
const output = await runDetection({
|
|
1630
|
+
...detection,
|
|
1631
|
+
root,
|
|
1632
|
+
event: "verify",
|
|
1633
|
+
selections: [{ rule, files }],
|
|
1634
|
+
changes: /* @__PURE__ */ new Map(),
|
|
1635
|
+
read: (file) => readSourceFile(root, file)
|
|
1636
|
+
});
|
|
1637
|
+
const failure = output.errors[0];
|
|
1638
|
+
if (failure !== void 0) throw new Error(`${rule.detector.kind} detector failed: ${failure.message}`);
|
|
1639
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1640
|
+
for (const { match } of output.findings) byFile.set(match.file, (byFile.get(match.file) ?? 0) + 1);
|
|
1641
|
+
const worst = [...byFile].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, AGAINST_TOP_FILES);
|
|
1642
|
+
const width = Math.max(...worst.map(([file]) => file.length), 0);
|
|
1643
|
+
const out = ["", ` ${output.findings.length} violations in ${byFile.size} of ${files.length} matching files`];
|
|
1644
|
+
if (worst.length > 0) {
|
|
1645
|
+
out.push("", ...worst.map(([file, count]) => ` ${file.padEnd(width + 2)}${count}`));
|
|
1646
|
+
if (byFile.size > worst.length) out.push(` \u2026and ${byFile.size - worst.length} more files`);
|
|
1647
|
+
}
|
|
1648
|
+
out.push("", ...AGAINST_NOTES);
|
|
1649
|
+
if (output.findings.length === 0) out.push(...ZERO_NOTES);
|
|
1650
|
+
out.push("");
|
|
1651
|
+
io.stdout(out.join("\n"));
|
|
1652
|
+
return 0;
|
|
1653
|
+
}
|
|
1654
|
+
async function testCommand(root, args, registry, io) {
|
|
1655
|
+
const { ruleId, against } = parseTestArgs(args);
|
|
1656
|
+
if (!hasProject(root)) throw new Error(`no ${CONFIG_FILE} in ${io.cwd} or its parents`);
|
|
1657
|
+
const project = await compile({ root, registry, repos: fetchingRepos(cacheHome(io.env)) });
|
|
1658
|
+
if (ruleId !== null && !project.rules.some((rule) => rule.id === ruleId)) {
|
|
1659
|
+
throw new UsageError(`no rule "${ruleId}" (see rulecast validate)`);
|
|
1660
|
+
}
|
|
1661
|
+
const rules = project.rules.filter(isDetectorRule).filter((rule) => ruleId === null || rule.id === ruleId);
|
|
1662
|
+
const stateDir = ensureProjectState(cacheHome(io.env), root);
|
|
1663
|
+
const resolver = createReferenceResolver(root);
|
|
1664
|
+
const detection = {
|
|
1665
|
+
registry,
|
|
1666
|
+
settings: { llm: project.config.llm },
|
|
1667
|
+
contextFor: (one) => resolveRuleContext(resolver, one.context),
|
|
1668
|
+
cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
|
|
1669
|
+
timeoutMs: project.config.timeouts.verifyMs
|
|
1670
|
+
};
|
|
1671
|
+
if (against.length > 0) return await reportAgainst(root, rules[0], against, detection, io);
|
|
1672
|
+
const lines = [""];
|
|
1673
|
+
const noExamples = [];
|
|
1674
|
+
let failedRules = 0;
|
|
1675
|
+
let ranRules = 0;
|
|
1676
|
+
for (const rule of rules) {
|
|
1677
|
+
const result = ruleId === null && rule.detector.kind === "llm" ? skippedResult(rule.id) : await runExamples({ ...detection, root, rule });
|
|
1678
|
+
lines.push(summaryLine(rule, result));
|
|
1679
|
+
if (result.missing || result.empty) {
|
|
1680
|
+
noExamples.push(rule.id);
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
if (result.skipped) continue;
|
|
1684
|
+
ranRules++;
|
|
1685
|
+
if (result.outcomes.some((outcome) => !outcome.passed)) {
|
|
1686
|
+
failedRules++;
|
|
1687
|
+
lines.push(...failureLines(rule, result));
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
if (rules.length === 0) {
|
|
1691
|
+
io.stdout("no rules with a detector\n");
|
|
1692
|
+
return 0;
|
|
1693
|
+
}
|
|
1694
|
+
if (noExamples.length > 0) lines.push("", ` no examples: ${noExamples.join(", ")}`);
|
|
1695
|
+
lines.push(
|
|
1696
|
+
"",
|
|
1697
|
+
failedRules === 0 ? ranRules === 0 ? " nothing to run" : ` ${ranRules} ${ranRules === 1 ? "rule" : "rules"} passed` : ` ${failedRules} of ${ranRules} rules failed`,
|
|
1698
|
+
""
|
|
1699
|
+
);
|
|
1700
|
+
io.stdout(lines.join("\n"));
|
|
1701
|
+
return failedRules === 0 ? 0 : 1;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1423
1704
|
// src/commands/try-repo.ts
|
|
1424
1705
|
import { existsSync as existsSync5, statSync as statSync2 } from "fs";
|
|
1425
|
-
import { mkdtemp, rm as
|
|
1706
|
+
import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
|
|
1426
1707
|
import { tmpdir as tmpdir2 } from "os";
|
|
1427
|
-
import
|
|
1708
|
+
import path8 from "path";
|
|
1428
1709
|
var isDirectory = (file) => existsSync5(file) && statSync2(file).isDirectory();
|
|
1429
|
-
var isGitRepo = (dir) => existsSync5(
|
|
1710
|
+
var isGitRepo = (dir) => existsSync5(path8.join(dir, ".git")) || existsSync5(path8.join(dir, "HEAD"));
|
|
1430
1711
|
async function baseConfigData(root) {
|
|
1431
1712
|
if (!hasProject(root)) return {};
|
|
1432
1713
|
const data = await readConfigData(root);
|
|
@@ -1438,16 +1719,16 @@ async function tryRepoCommand(root, args, registry, io) {
|
|
|
1438
1719
|
const [repo, ruleId, ...extra] = run2.leading;
|
|
1439
1720
|
if (repo === void 0) throw new UsageError("try-repo needs a repository path or URL");
|
|
1440
1721
|
if (extra.length > 0) throw new UsageError(`unexpected arguments: ${extra.join(" ")}`);
|
|
1441
|
-
const local =
|
|
1722
|
+
const local = path8.resolve(io.cwd, repo);
|
|
1442
1723
|
const directory = isDirectory(local);
|
|
1443
1724
|
if (directory && run2.ref !== null && !isGitRepo(local)) throw new UsageError("--ref only applies to URLs");
|
|
1444
1725
|
const fromDirectory = directory && run2.ref === null;
|
|
1445
1726
|
const rev = fromDirectory ? "working-tree" : run2.ref ?? "HEAD";
|
|
1446
|
-
const temp = fromDirectory ? null : await
|
|
1727
|
+
const temp = fromDirectory ? null : await mkdtemp2(path8.join(tmpdir2(), "rulecast-try-repo-"));
|
|
1447
1728
|
try {
|
|
1448
1729
|
const dir = temp ?? local;
|
|
1449
1730
|
if (temp !== null) await fetchCheckout(directory ? local : repo, rev, temp);
|
|
1450
|
-
const label = fromDirectory ? `${
|
|
1731
|
+
const label = fromDirectory ? `${path8.basename(local)}@${rev}` : repoLabel(repo, rev);
|
|
1451
1732
|
const manifest = await readManifest(dir);
|
|
1452
1733
|
if (!manifest.ok) throw new Error(`${label}: ${manifest.message}`);
|
|
1453
1734
|
const ids = manifest.value.flatMap((rule) => {
|
|
@@ -1462,29 +1743,29 @@ async function tryRepoCommand(root, args, registry, io) {
|
|
|
1462
1743
|
const project = await compile({ root, registry, repos: fixedRepo(dir, label), configData });
|
|
1463
1744
|
return await executeRun({ project, ruleId: null, run: run2, registry, io });
|
|
1464
1745
|
} finally {
|
|
1465
|
-
if (temp !== null) await
|
|
1746
|
+
if (temp !== null) await rm3(temp, { recursive: true, force: true });
|
|
1466
1747
|
}
|
|
1467
1748
|
}
|
|
1468
1749
|
|
|
1469
1750
|
// src/commands/validate.ts
|
|
1470
1751
|
import { existsSync as existsSync6 } from "fs";
|
|
1471
|
-
import
|
|
1752
|
+
import path9 from "path";
|
|
1472
1753
|
function formatDiagnostic(diagnostic) {
|
|
1473
1754
|
return `${diagnostic.level === "warning" ? "warning: " : ""}${diagnosticText(diagnostic)}`;
|
|
1474
1755
|
}
|
|
1475
1756
|
async function validateCommand(root, args, registry, io) {
|
|
1476
|
-
const targets = args.length > 0 ? args.map((file) => ({ shown: file, full:
|
|
1757
|
+
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) }));
|
|
1477
1758
|
if (targets.length === 0) throw new Error(`nothing to validate: no ${CONFIG_FILE} or ${MANIFEST_FILE}`);
|
|
1478
1759
|
for (const { shown, full } of targets) {
|
|
1479
|
-
const name =
|
|
1760
|
+
const name = path9.basename(full);
|
|
1480
1761
|
if (name !== CONFIG_FILE && name !== MANIFEST_FILE) {
|
|
1481
1762
|
throw new UsageError(`${shown}: not a ${CONFIG_FILE} or ${MANIFEST_FILE}`);
|
|
1482
1763
|
}
|
|
1483
1764
|
}
|
|
1484
1765
|
let failed = false;
|
|
1485
1766
|
for (const { shown, full } of targets) {
|
|
1486
|
-
const dir =
|
|
1487
|
-
const result =
|
|
1767
|
+
const dir = path9.dirname(full);
|
|
1768
|
+
const result = path9.basename(full) === CONFIG_FILE ? await compile({ root: dir, registry, repos: fetchingRepos(cacheHome(io.env)) }) : await compileManifest(dir, registry);
|
|
1488
1769
|
for (const diagnostic of result.diagnostics) io.stdout(`${shown}: ${formatDiagnostic(diagnostic)}
|
|
1489
1770
|
`);
|
|
1490
1771
|
if (result.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
|
|
@@ -1499,10 +1780,10 @@ async function validateCommand(root, args, registry, io) {
|
|
|
1499
1780
|
}
|
|
1500
1781
|
|
|
1501
1782
|
// src/commands/warm.ts
|
|
1502
|
-
import { parseArgs as
|
|
1783
|
+
import { parseArgs as parseArgs7 } from "util";
|
|
1503
1784
|
var WARM_TIMEOUT_MS = 5 * 6e4;
|
|
1504
1785
|
async function warmCommand(root, args, registry, io) {
|
|
1505
|
-
const { values } =
|
|
1786
|
+
const { values } = parseArgs7({ args, options: { detector: { type: "string", multiple: true } } });
|
|
1506
1787
|
if (!hasProject(root)) throw new Error(`no .rulecast-config.yaml in ${root} or its parents (run rulecast init)`);
|
|
1507
1788
|
const home = cacheHome(io.env);
|
|
1508
1789
|
const stateDir = ensureProjectState(home, root);
|
|
@@ -1536,9 +1817,10 @@ var USAGE = `usage:
|
|
|
1536
1817
|
rulecast init [--rules id,id | --no-rules] [--agent <name>]... [--scope shared|personal] [--yes]
|
|
1537
1818
|
rulecast install [--agent <name>]... [--scope shared|personal]
|
|
1538
1819
|
rulecast uninstall [--agent <name>]...
|
|
1539
|
-
rulecast run [RULE_ID] [--all-files | --files F...] [--from-ref A [--to-ref B]] [--format terminal|agent|json|sarif] [--session <id>] [--no-llm]
|
|
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]
|
|
1540
1821
|
rulecast autoupdate [--freeze] [--repo URL]...
|
|
1541
1822
|
rulecast try-repo <path|url> [RULE_ID] [--ref REV] [run flags]
|
|
1823
|
+
rulecast test [RULE_ID]
|
|
1542
1824
|
rulecast validate [file...]
|
|
1543
1825
|
rulecast clean [--project]
|
|
1544
1826
|
rulecast hook <adapter>
|
|
@@ -1563,6 +1845,8 @@ async function main(argv, io) {
|
|
|
1563
1845
|
return await autoupdateCommand(root, args, io);
|
|
1564
1846
|
case "try-repo":
|
|
1565
1847
|
return await tryRepoCommand(root, args, registry, io);
|
|
1848
|
+
case "test":
|
|
1849
|
+
return await testCommand(root, args, registry, io);
|
|
1566
1850
|
case "validate":
|
|
1567
1851
|
return await validateCommand(root, args, registry, io);
|
|
1568
1852
|
case "clean":
|
|
@@ -1603,7 +1887,7 @@ function spawnDetached(command, args, cwd) {
|
|
|
1603
1887
|
// src/init/clipboard.ts
|
|
1604
1888
|
import { spawn as spawn2 } from "child_process";
|
|
1605
1889
|
import { accessSync, constants, statSync as statSync3 } from "fs";
|
|
1606
|
-
import
|
|
1890
|
+
import path10 from "path";
|
|
1607
1891
|
var CANDIDATES = [
|
|
1608
1892
|
{ command: "pbcopy", args: [] },
|
|
1609
1893
|
{ command: "wl-copy", args: [] },
|
|
@@ -1620,9 +1904,9 @@ function isExecutable(file) {
|
|
|
1620
1904
|
}
|
|
1621
1905
|
}
|
|
1622
1906
|
function findOnPath(name, env) {
|
|
1623
|
-
for (const dir of (env.PATH ?? "").split(
|
|
1907
|
+
for (const dir of (env.PATH ?? "").split(path10.delimiter)) {
|
|
1624
1908
|
if (dir === "") continue;
|
|
1625
|
-
const candidate =
|
|
1909
|
+
const candidate = path10.join(dir, name);
|
|
1626
1910
|
if (isExecutable(candidate)) return candidate;
|
|
1627
1911
|
}
|
|
1628
1912
|
return null;
|