vigiles 14.0.0 → 14.2.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 +16 -13
- package/dist/adapters/claude-code/dialect.d.ts +1 -1
- package/dist/audit-report.js +4 -1
- package/dist/audit-report.template.html +28 -28
- package/dist/cli.js +194 -35
- package/dist/core/rule-catalog.d.ts +49 -4
- package/dist/core/rule-catalog.js +151 -3
- package/dist/instruction-sources.d.ts +1 -1
- package/dist/instruction-sources.js +1 -1
- package/dist/rule-inventory.js +151 -2
- package/dist/rule-routing.d.ts +63 -1
- package/dist/rule-routing.js +231 -98
- package/dist/rule-signals.d.ts +46 -0
- package/dist/rule-signals.js +49 -0
- package/dist/segment.d.ts +35 -2
- package/dist/segment.js +233 -171
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1535,7 +1535,7 @@ function computeRuleInventory(root, instructionFile) {
|
|
|
1535
1535
|
* instruction files PLUS nested subdirectory-memory (`src/CLAUDE.md`,
|
|
1536
1536
|
* `research/CLAUDE.md`, …), skipping fixture/demo/build/test dirs (`isFixturePath`)
|
|
1537
1537
|
* so a repo's real memory is read without the test-fixture noise. `.claude/` rule
|
|
1538
|
-
* sources remain a future source. research/rule-
|
|
1538
|
+
* sources remain a future source. research/rule-enforcer-multilang-design.md §0. */
|
|
1539
1539
|
function gatherInstructionFiles(root, instructionFile) {
|
|
1540
1540
|
const raw = [];
|
|
1541
1541
|
const collect = (rel) => {
|
|
@@ -1580,28 +1580,73 @@ function gatherInstructionFiles(root, instructionFile) {
|
|
|
1580
1580
|
* sticky `audit.measure` consent as the other executing checks AND on own-repo
|
|
1581
1581
|
* (never a stranger's toolchain). The textual routing is the foreign-safe default;
|
|
1582
1582
|
* the catalog only ADDS enabled-state nudges and matches named-but-`/`-broken rules. */
|
|
1583
|
+
/** Does the repo actually USE Pylint — i.e. is there a real Pylint config to run
|
|
1584
|
+
* against? A DEDICATED pylintrc file (`.pylintrc`, `pylintrc`, `.pylintrc.toml`,
|
|
1585
|
+
* `pylintrc.toml`) is unambiguous. A SHARED file (`pyproject.toml`, `setup.cfg`,
|
|
1586
|
+
* `tox.ini`) counts ONLY when it carries a Pylint section — otherwise a Ruff-only
|
|
1587
|
+
* / packaging-only `pyproject.toml` would falsely make us spawn pylint and route
|
|
1588
|
+
* docs as `reuse`/`enabled` against a linter the repo doesn't use. Mirrors
|
|
1589
|
+
* Pylint's own config search order. */
|
|
1590
|
+
function hasPythonSurface(root) {
|
|
1591
|
+
const dedicated = [
|
|
1592
|
+
".pylintrc",
|
|
1593
|
+
"pylintrc",
|
|
1594
|
+
".pylintrc.toml",
|
|
1595
|
+
"pylintrc.toml",
|
|
1596
|
+
];
|
|
1597
|
+
if (dedicated.some((f) => (0, node_fs_1.existsSync)((0, node_path_1.resolve)(root, f))))
|
|
1598
|
+
return true;
|
|
1599
|
+
// A pylint section: `[tool.pylint...]` (pyproject) or `[pylint...]` / `[MASTER]`
|
|
1600
|
+
// / `[MESSAGES CONTROL]` (setup.cfg / tox.ini, incl. case variants).
|
|
1601
|
+
const pylintSection = /(\[tool\.pylint)|(\[pylint)|(\[MASTER\])|(\[MESSAGES CONTROL\])/i;
|
|
1602
|
+
for (const f of ["pyproject.toml", "setup.cfg", "tox.ini"]) {
|
|
1603
|
+
const p = (0, node_path_1.resolve)(root, f);
|
|
1604
|
+
if ((0, node_fs_1.existsSync)(p)) {
|
|
1605
|
+
try {
|
|
1606
|
+
if (pylintSection.test((0, node_fs_1.readFileSync)(p, "utf-8")))
|
|
1607
|
+
return true;
|
|
1608
|
+
}
|
|
1609
|
+
catch {
|
|
1610
|
+
/* unreadable — treat as no pylint config */
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
return false;
|
|
1615
|
+
}
|
|
1583
1616
|
function computeRuleRouting(root, instructionFile) {
|
|
1584
1617
|
try {
|
|
1585
1618
|
const files = gatherInstructionFiles(root, instructionFile);
|
|
1586
1619
|
if (files.every((f) => !f.text.trim()))
|
|
1587
1620
|
return undefined;
|
|
1588
|
-
// Own-repo + consented → enumerate the live
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
//
|
|
1594
|
-
// repo
|
|
1621
|
+
// Own-repo + consented → enumerate the live rule catalog of whichever
|
|
1622
|
+
// linter(s) the repo has: ESLint (JS/TS) and/or Pylint (Python), merged so a
|
|
1623
|
+
// polyglot repo matches against both. NOT gated on the agent harness — a
|
|
1624
|
+
// catalog is a property of the repo's LINTER (its config on disk), not of
|
|
1625
|
+
// Claude-Code-vs-Codex (adapter-aware-lint-rules: never gate a harness-
|
|
1626
|
+
// agnostic capability on CC). Each enumerate returns null when its linter
|
|
1627
|
+
// doesn't apply, so a repo with neither falls back to the foreign-safe textual
|
|
1628
|
+
// routing. Enumerating EXECUTES the linter (plugin code loads), hence own-repo
|
|
1629
|
+
// + consent — same posture for both.
|
|
1595
1630
|
const ownRepo = (0, node_path_1.resolve)(root) === (0, node_path_1.resolve)(process.cwd());
|
|
1596
1631
|
const consented = (0, validate_js_1.loadConfig)().audit?.measure === true;
|
|
1597
1632
|
const availableRules = ownRepo && consented
|
|
1598
|
-
? ((0, rule_catalog_js_1.enumerateEslintCatalog)(root)
|
|
1633
|
+
? (0, rule_catalog_js_1.mergeCatalogs)((0, rule_catalog_js_1.enumerateEslintCatalog)(root),
|
|
1634
|
+
// Only spawn pylint when the repo actually has a Python surface —
|
|
1635
|
+
// avoids a needless (and possibly noisy) pylint run on a pure-JS repo.
|
|
1636
|
+
hasPythonSurface(root) ? (0, rule_catalog_js_1.enumeratePylintCatalog)(root) : null)
|
|
1599
1637
|
: undefined;
|
|
1600
1638
|
// Route each source SEPARATELY (each rule keeps its own file + line numbers),
|
|
1601
1639
|
// then merge — so a CLAUDE.md rule and an AGENTS.md rule carry correct
|
|
1602
1640
|
// provenance instead of line numbers offset by a concatenation.
|
|
1603
1641
|
const routing = (0, rule_routing_js_1.mergeRoutings)(files.map((f) => (0, rule_routing_js_1.routeRules)(f.text, f.path, { availableRules })));
|
|
1604
|
-
|
|
1642
|
+
// Keep the routing if it found ANY confident rule, possible rule, or skipped
|
|
1643
|
+
// bullet — so the two-tier + skipped report surfaces even a doc with 0
|
|
1644
|
+
// confident rules (all its bullets landed in possible/skipped).
|
|
1645
|
+
return routing.segmented > 0 ||
|
|
1646
|
+
routing.possible.length > 0 ||
|
|
1647
|
+
routing.skipped.length > 0
|
|
1648
|
+
? routing
|
|
1649
|
+
: undefined;
|
|
1605
1650
|
}
|
|
1606
1651
|
catch {
|
|
1607
1652
|
return undefined;
|
|
@@ -1635,6 +1680,36 @@ function formatTriggerNudge(triggerableSkills) {
|
|
|
1635
1680
|
return (`ℹ Do your ${String(n)} skill${n === 1 ? "" : "s"} actually fire? The deterministic read can't tell — ` +
|
|
1636
1681
|
`run \`audit\` interactively to measure, or test with \`measureTriggerRate\` (vigiles/testing).`);
|
|
1637
1682
|
}
|
|
1683
|
+
/** A terminal summary of the rule map: the CONFIDENT lane counts + the POSSIBLE
|
|
1684
|
+
* (review) and SKIPPED tiers, with the honest caveat that detection is a
|
|
1685
|
+
* heuristic filter. The full per-rule map + skipped list live in the HTML/JSON
|
|
1686
|
+
* report; this is the "be clear about what was detected" headline. "" when there
|
|
1687
|
+
* is nothing to show. */
|
|
1688
|
+
function formatRuleMapSummary(routing) {
|
|
1689
|
+
if (!routing)
|
|
1690
|
+
return "";
|
|
1691
|
+
const { counts, possible, skipped } = routing;
|
|
1692
|
+
const confident = counts.reuse +
|
|
1693
|
+
counts.hook +
|
|
1694
|
+
counts.unrouted +
|
|
1695
|
+
counts.semantic +
|
|
1696
|
+
counts.meta;
|
|
1697
|
+
if (confident === 0 && possible.length === 0 && skipped.length === 0)
|
|
1698
|
+
return "";
|
|
1699
|
+
// Lane counts, rendered from the single-source LANE_META (glyph + label).
|
|
1700
|
+
const lane = (c) => `${rule_routing_js_1.LANE_META[c].glyph} ${String(counts[c])} ${rule_routing_js_1.LANE_META[c].label}`;
|
|
1701
|
+
const lines = [
|
|
1702
|
+
"Rule map [experimental] — how your prose rules could be enforced (heuristic, precision-first; misses some rules):",
|
|
1703
|
+
` ${lane("reuse")} · ${lane("hook")} · ${lane("unrouted")} · ${lane("semantic")}` +
|
|
1704
|
+
(counts.meta > 0 ? ` · ${lane("meta")}` : ""),
|
|
1705
|
+
];
|
|
1706
|
+
if (possible.length > 0)
|
|
1707
|
+
lines.push(` ? ${String(possible.length)} possible — rule-ish, but below the confidence bar (review these)`);
|
|
1708
|
+
if (skipped.length > 0)
|
|
1709
|
+
lines.push(` ⊘ ${String(skipped.length)} skipped — not treated as rules (setup steps, descriptions, index entries, no norm signal)`);
|
|
1710
|
+
lines.push(" Detection is a best-effort filter — it won't catch every rule. Full map + skipped list in the report (or --json).");
|
|
1711
|
+
return lines.join("\n");
|
|
1712
|
+
}
|
|
1638
1713
|
function scaffoldSpec(args) {
|
|
1639
1714
|
const targetFlag = args.find((a) => a.startsWith("--target="));
|
|
1640
1715
|
const target = targetFlag ? targetFlag.split("=")[1] : "CLAUDE.md";
|
|
@@ -3905,7 +3980,7 @@ function printUsage(command) {
|
|
|
3905
3980
|
console.log(" vigiles eject [file] Un-manage a compiled file → plain hand-owned markdown (--keep-spec)");
|
|
3906
3981
|
console.log(" vigiles lint [files...] Verify references, find gaps in instruction files");
|
|
3907
3982
|
console.log(" vigiles audit [dir...] Lighthouse for your harness — a LOCAL report: rings + what's broken + fixes (a deterministic read; 2+ dirs → leaderboard)");
|
|
3908
|
-
console.log(" writes vigiles-report.html +
|
|
3983
|
+
console.log(" writes vigiles-report.html + .json (auto-gitignored; --out=<dir> · --no-html/--no-json · --no-open · --json for machine output). NOT a CI step — use `vigiles lint` in CI.");
|
|
3909
3984
|
console.log(" the executing checks (run your hooks · live MCP · do skills fire?) run only interactively — `audit` asks once (remembered); automation uses the vigiles/testing API");
|
|
3910
3985
|
console.log(" --serve opens a LIVE local report whose buttons create specs in one click (own repo only; loopback + token-guarded) · --no-serve to skip the prompt");
|
|
3911
3986
|
console.log(" vigiles test [files...] Run *.harness.mjs deterministic harness tests");
|
|
@@ -4821,18 +4896,50 @@ async function runHookProgramCommand(file) {
|
|
|
4821
4896
|
}
|
|
4822
4897
|
}
|
|
4823
4898
|
}
|
|
4899
|
+
/**
|
|
4900
|
+
* Idempotently keep the generated report artifacts OUT of git — the tool that
|
|
4901
|
+
* writes a build artifact keeps it ignored (the `next build` → `.next` pattern),
|
|
4902
|
+
* so `vigiles audit` (which runs zero-config, without `init`) never leaves
|
|
4903
|
+
* surprise untracked files in `git status`. Appends only the MISSING entries
|
|
4904
|
+
* under a labelled block if a `.gitignore` exists; if none exists, prints a
|
|
4905
|
+
* one-line nudge rather than creating one silently. Best-effort — a failure here
|
|
4906
|
+
* never breaks the audit. `entries` are paths relative to the git root (cwd).
|
|
4907
|
+
*/
|
|
4908
|
+
function ensureReportGitignored(cwd, entries) {
|
|
4909
|
+
if (entries.length === 0)
|
|
4910
|
+
return;
|
|
4911
|
+
const gi = (0, node_path_1.resolve)(cwd, ".gitignore");
|
|
4912
|
+
try {
|
|
4913
|
+
if (!(0, node_fs_1.existsSync)(gi)) {
|
|
4914
|
+
console.log(`\nℹ tip: add ${entries.join(" + ")} to a .gitignore (generated report artifacts)`);
|
|
4915
|
+
return;
|
|
4916
|
+
}
|
|
4917
|
+
const content = (0, node_fs_1.readFileSync)(gi, "utf-8");
|
|
4918
|
+
const present = new Set(content.split("\n").map((l) => l.trim()));
|
|
4919
|
+
const missing = entries.filter((e) => !present.has(e));
|
|
4920
|
+
if (missing.length === 0)
|
|
4921
|
+
return;
|
|
4922
|
+
const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
|
|
4923
|
+
(0, node_fs_1.writeFileSync)(gi, `${content}${sep}\n# vigiles audit report (generated)\n${missing.join("\n")}\n`);
|
|
4924
|
+
console.log(`✓ Added ${missing.join(" + ")} to .gitignore`);
|
|
4925
|
+
}
|
|
4926
|
+
catch {
|
|
4927
|
+
/* best-effort — a read-only or missing .gitignore never breaks the audit */
|
|
4928
|
+
}
|
|
4929
|
+
}
|
|
4824
4930
|
/**
|
|
4825
4931
|
* Write the versioned JSON artifact (`vigiles-report.json`) — the upload/CI
|
|
4826
4932
|
* boundary a hosted dashboard ingests. Stamps `meta.generatedAt` here (at write
|
|
4827
4933
|
* time, not in the pure builder, so the HTML-embedded form stays deterministic).
|
|
4828
4934
|
*/
|
|
4829
|
-
function writeAuditJson(report) {
|
|
4830
|
-
const jsonPath = (0, node_path_1.resolve)(
|
|
4935
|
+
function writeAuditJson(report, outDir) {
|
|
4936
|
+
const jsonPath = (0, node_path_1.resolve)(outDir, "vigiles-report.json");
|
|
4831
4937
|
const stamped = {
|
|
4832
4938
|
...report,
|
|
4833
4939
|
meta: { ...report.meta, generatedAt: new Date().toISOString() },
|
|
4834
4940
|
};
|
|
4835
4941
|
try {
|
|
4942
|
+
(0, node_fs_1.mkdirSync)(outDir, { recursive: true });
|
|
4836
4943
|
(0, node_fs_1.writeFileSync)(jsonPath, JSON.stringify(stamped, null, 2) + "\n");
|
|
4837
4944
|
console.log("✓ Wrote vigiles-report.json — the upload/CI artifact");
|
|
4838
4945
|
}
|
|
@@ -4864,12 +4971,16 @@ function openBestEffort(file) {
|
|
|
4864
4971
|
* for a human at a TTY, open it best-effort. The shareable Lighthouse artifact;
|
|
4865
4972
|
* never spawns a browser for an agent / CI run.
|
|
4866
4973
|
*/
|
|
4867
|
-
function writeAuditHtml(report) {
|
|
4868
|
-
const htmlPath = (0, node_path_1.resolve)(
|
|
4974
|
+
function writeAuditHtml(report, outDir, open) {
|
|
4975
|
+
const htmlPath = (0, node_path_1.resolve)(outDir, "vigiles-report.html");
|
|
4869
4976
|
try {
|
|
4977
|
+
(0, node_fs_1.mkdirSync)(outDir, { recursive: true });
|
|
4870
4978
|
(0, node_fs_1.writeFileSync)(htmlPath, (0, audit_html_js_1.renderAuditHtml)(report));
|
|
4871
4979
|
console.log("\n✓ Wrote vigiles-report.html — open it for the full report");
|
|
4872
|
-
|
|
4980
|
+
// Great DX: pop the report open in the browser for a human at a TTY (the
|
|
4981
|
+
// `lighthouse --view` behaviour). `--no-open` suppresses it; an agent / CI
|
|
4982
|
+
// run (no TTY) never spawns a browser.
|
|
4983
|
+
if (open && process.stdout.isTTY)
|
|
4873
4984
|
openBestEffort(htmlPath);
|
|
4874
4985
|
}
|
|
4875
4986
|
catch (e) {
|
|
@@ -5309,26 +5420,30 @@ async function main() {
|
|
|
5309
5420
|
// Read the local flight recorder ONCE — feeds both the JSON report
|
|
5310
5421
|
// (structured summary, the product boundary) and the terminal render.
|
|
5311
5422
|
const ledgerRecords = (0, observe_js_1.readObservations)(root);
|
|
5312
|
-
|
|
5423
|
+
// The report scaffold WITHOUT the rule map — the map's catalog
|
|
5424
|
+
// enrichment (enabled-state / "documented but OFF") enumerates the repo's
|
|
5425
|
+
// linter, which is gated on the SAME audit.measure consent as the
|
|
5426
|
+
// executing checks. So the map is routed AFTER consent (resolved below)
|
|
5427
|
+
// and folded into the report there, so a first-time "yes" enriches THIS
|
|
5428
|
+
// run — not the next one.
|
|
5429
|
+
const auditReportBase = (0, audit_report_js_1.buildAuditReport)(report, {
|
|
5313
5430
|
harness: adapter.name,
|
|
5314
5431
|
vigilesVersion: getVersion(),
|
|
5315
5432
|
adoptableSurfaces,
|
|
5316
5433
|
observations: (0, observe_js_1.summarizeObservations)(ledgerRecords),
|
|
5317
5434
|
rulesInventory: computeRuleInventory(root, adapter.layout.instructionFile),
|
|
5318
|
-
ruleRouting: computeRuleRouting(root, adapter.layout.instructionFile),
|
|
5319
5435
|
});
|
|
5320
|
-
const sc =
|
|
5436
|
+
const sc = auditReportBase.score;
|
|
5321
5437
|
const plan = (0, optimize_js_1.optimize)(report);
|
|
5438
|
+
// The deterministic READ leads: rings + report + fixes + nudges print
|
|
5439
|
+
// BEFORE the consent prompt, so a plain `audit` shows its findings first.
|
|
5440
|
+
// (JSON stays silent until the single blob below, after consent.)
|
|
5322
5441
|
if (!json) {
|
|
5323
5442
|
// The Lighthouse rings: per-category 0–100 + the weighted overall,
|
|
5324
5443
|
// shown before the detailed report so the headline signal leads.
|
|
5325
5444
|
console.log((0, audit_score_js_1.formatAuditScore)(sc));
|
|
5326
5445
|
console.log("");
|
|
5327
|
-
|
|
5328
|
-
console.log(json
|
|
5329
|
-
? JSON.stringify(auditReport, null, 2)
|
|
5330
|
-
: (0, scan_js_1.formatScanReport)(report));
|
|
5331
|
-
if (!json) {
|
|
5446
|
+
console.log((0, scan_js_1.formatScanReport)(report));
|
|
5332
5447
|
// Fold each finding's fix inline (replaces the former --fix-plan/--explain
|
|
5333
5448
|
// flags): the deterministic, free recommendation list under the report.
|
|
5334
5449
|
const fixes = (0, optimize_js_1.formatRecommendations)(plan);
|
|
@@ -5346,17 +5461,14 @@ async function main() {
|
|
|
5346
5461
|
.length);
|
|
5347
5462
|
if (fireNudge)
|
|
5348
5463
|
console.log("\n" + fireNudge);
|
|
5349
|
-
// The flight recorder: a compact summary of what the harness actually
|
|
5350
|
-
// DID in real sessions (hook/agent decisions), read off the local
|
|
5351
|
-
// agent-readable ledger. Empty (skipped) until something is recorded.
|
|
5352
|
-
const ledgerSummary = (0, observe_js_1.formatLedgerSummary)(ledgerRecords);
|
|
5353
|
-
if (ledgerSummary)
|
|
5354
|
-
console.log("\n" + ledgerSummary);
|
|
5355
5464
|
}
|
|
5356
5465
|
// ONE read-vs-run decision for the EXECUTING checks (live MCP + skill
|
|
5357
|
-
// firing)
|
|
5358
|
-
//
|
|
5359
|
-
//
|
|
5466
|
+
// firing) AND the rule map's catalog enrichment (enumerating the repo's
|
|
5467
|
+
// linter also executes it). A plain `audit` is a deterministic READ;
|
|
5468
|
+
// these run only on consent — ASK once at a TTY (remembered); headless
|
|
5469
|
+
// stays a read + a nudge (no execution flag — automation uses the
|
|
5470
|
+
// vigiles/testing API). Resolved AFTER the report (so the read leads) but
|
|
5471
|
+
// BEFORE the rule map is routed, so a first-time "yes" enriches THIS run.
|
|
5360
5472
|
// (The safety battery is NOT here — it needs cross-platform confinement
|
|
5361
5473
|
// that isn't shipped, so it lives in the vigiles/testing API.)
|
|
5362
5474
|
const isForeign = root !== process.cwd();
|
|
@@ -5368,6 +5480,30 @@ async function main() {
|
|
|
5368
5480
|
(0, node_fs_1.existsSync)((0, node_path_1.resolve)(root, adapter.layout.instructionFile)),
|
|
5369
5481
|
};
|
|
5370
5482
|
const { execute, note: execNote } = await resolveExecution(surfaces, json, args, adapter.name);
|
|
5483
|
+
// Consent is now settled (and remembered via .vigilesrc.json, which
|
|
5484
|
+
// computeRuleRouting re-reads) — route the prose rules, enumerating the
|
|
5485
|
+
// live catalog when consented + own-repo. Feeds the JSON report, the
|
|
5486
|
+
// written artifacts, and the terminal rule-map summary.
|
|
5487
|
+
const ruleRouting = computeRuleRouting(root, adapter.layout.instructionFile);
|
|
5488
|
+
const auditReport = ruleRouting
|
|
5489
|
+
? { ...auditReportBase, ruleRouting }
|
|
5490
|
+
: auditReportBase;
|
|
5491
|
+
if (json) {
|
|
5492
|
+
console.log(JSON.stringify(auditReport, null, 2));
|
|
5493
|
+
}
|
|
5494
|
+
else {
|
|
5495
|
+
// The rule map — what audit detected in your instruction file and how
|
|
5496
|
+
// each rule could be enforced (confident / possible / skipped tiers).
|
|
5497
|
+
const ruleMap = formatRuleMapSummary(ruleRouting);
|
|
5498
|
+
if (ruleMap)
|
|
5499
|
+
console.log("\n" + ruleMap);
|
|
5500
|
+
// The flight recorder: a compact summary of what the harness actually
|
|
5501
|
+
// DID in real sessions (hook/agent decisions), read off the local
|
|
5502
|
+
// agent-readable ledger. Empty (skipped) until something is recorded.
|
|
5503
|
+
const ledgerSummary = (0, observe_js_1.formatLedgerSummary)(ledgerRecords);
|
|
5504
|
+
if (ledgerSummary)
|
|
5505
|
+
console.log("\n" + ledgerSummary);
|
|
5506
|
+
}
|
|
5371
5507
|
// LIVE MCP tool resolution STARTS each declared MCP server — a server is
|
|
5372
5508
|
// exactly what connects to a real Postgres / authenticates a real API on
|
|
5373
5509
|
// boot. So it runs only under consent (`execute`) AND own-repo (never
|
|
@@ -5465,10 +5601,19 @@ async function main() {
|
|
|
5465
5601
|
const finalReport = adoptabilityResult
|
|
5466
5602
|
? { ...auditReport, adoptability: adoptabilityResult }
|
|
5467
5603
|
: auditReport;
|
|
5604
|
+
// Where the report artifacts land: cwd by default, or `--out=<dir>` (for
|
|
5605
|
+
// CI upload / a custom location). The dir is created if missing.
|
|
5606
|
+
const outFlag = args.find((a) => a.startsWith("--out="));
|
|
5607
|
+
const outDir = outFlag
|
|
5608
|
+
? (0, node_path_1.resolve)(process.cwd(), outFlag.slice("--out=".length))
|
|
5609
|
+
: process.cwd();
|
|
5610
|
+
const openReport = !args.includes("--no-open");
|
|
5611
|
+
const wroteReports = [];
|
|
5468
5612
|
// The versioned JSON artifact — the upload/CI boundary (a hosted dashboard
|
|
5469
5613
|
// ingests this). Written by default in the human path; --no-json to skip.
|
|
5470
5614
|
if (!json && !args.includes("--no-json")) {
|
|
5471
|
-
writeAuditJson(finalReport);
|
|
5615
|
+
writeAuditJson(finalReport, outDir);
|
|
5616
|
+
wroteReports.push("vigiles-report.json");
|
|
5472
5617
|
}
|
|
5473
5618
|
// The HTML report has two deliveries. STATIC (default): write the
|
|
5474
5619
|
// shareable file whose buttons copy the `init` command. LIVE (`--serve`,
|
|
@@ -5493,7 +5638,21 @@ async function main() {
|
|
|
5493
5638
|
await runAuditServe(finalReport, finalReport.adoptable, errMsg);
|
|
5494
5639
|
}
|
|
5495
5640
|
else if (!json && !args.includes("--no-html")) {
|
|
5496
|
-
writeAuditHtml(finalReport);
|
|
5641
|
+
writeAuditHtml(finalReport, outDir, openReport);
|
|
5642
|
+
wroteReports.push("vigiles-report.html");
|
|
5643
|
+
}
|
|
5644
|
+
// Keep the generated artifacts out of git so a zero-config `audit` leaves
|
|
5645
|
+
// a clean `git status` (works without `init`). Entries are relative to the
|
|
5646
|
+
// git root (cwd); a custom --out dir is ignored by its relative path.
|
|
5647
|
+
// .gitignore patterns are POSIX-separated, so normalize away Windows
|
|
5648
|
+
// backslashes (`relative()` yields `reports\x` on Windows, which would
|
|
5649
|
+
// never match `reports/x`).
|
|
5650
|
+
if (wroteReports.length > 0) {
|
|
5651
|
+
const rel = wroteReports.map((f) => {
|
|
5652
|
+
const r = (0, node_path_1.relative)(process.cwd(), (0, node_path_1.resolve)(outDir, f)) || f;
|
|
5653
|
+
return node_path_1.sep === "/" ? r : r.split(node_path_1.sep).join("/");
|
|
5654
|
+
});
|
|
5655
|
+
ensureReportGitignored(process.cwd(), rel);
|
|
5497
5656
|
}
|
|
5498
5657
|
}
|
|
5499
5658
|
break;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* / boundaries), of which ~140 are enabled — vs the old static map's ~23. That
|
|
9
9
|
* makes an architecture norm enforceable too (`boundaries/dependencies` is in the
|
|
10
10
|
* catalog), which a static map never captured. See
|
|
11
|
-
* `research/rule-
|
|
11
|
+
* `research/rule-enforcer-multilang-design.md` §0 (the spike this productizes).
|
|
12
12
|
*
|
|
13
13
|
* SAFETY — this EXECUTES the linter. Loading ESLint resolves the repo's real
|
|
14
14
|
* config (which can run plugin/config code), so `enumerateEslintCatalog` is an
|
|
@@ -21,16 +21,25 @@
|
|
|
21
21
|
*/
|
|
22
22
|
/** One rule the repo's linter has available, with its enabled state. */
|
|
23
23
|
export interface AvailableRule {
|
|
24
|
-
/** The rule id: `no-console`, `@typescript-eslint/no-explicit-any`, `boundaries/dependencies`.
|
|
24
|
+
/** The rule id: `no-console`, `@typescript-eslint/no-explicit-any`, `boundaries/dependencies`.
|
|
25
|
+
* For Pylint this is the SYMBOLIC name (`missing-function-docstring`). */
|
|
25
26
|
id: string;
|
|
26
|
-
/** The
|
|
27
|
+
/** The linter this rule belongs to. Carried PER-RULE (not only on the catalog)
|
|
28
|
+
* so a merged polyglot catalog keeps each rule's provenance — a routed reuse
|
|
29
|
+
* hit can then say `pylint:invalid-name` vs `eslint:no-console`. */
|
|
30
|
+
linter: "eslint" | "pylint";
|
|
31
|
+
/** The plugin prefix (`@typescript-eslint`, `boundaries`), or null for a core rule.
|
|
32
|
+
* Pylint's `--list-msgs` doesn't attribute a message to its plugin, so it's null there. */
|
|
27
33
|
plugin: string | null;
|
|
34
|
+
/** An alternate id the rule is ALSO matchable by (Pylint's numeric code, e.g. `C0116`),
|
|
35
|
+
* so a doc naming either the symbol or the code resolves. Absent for ESLint. */
|
|
36
|
+
code?: string;
|
|
28
37
|
/** Whether the rule is enabled (severity not 0/"off") in the resolved config. */
|
|
29
38
|
enabled: boolean;
|
|
30
39
|
}
|
|
31
40
|
/** The full available-rule catalog for a repo's linter. */
|
|
32
41
|
export interface RuleCatalog {
|
|
33
|
-
linter: "eslint";
|
|
42
|
+
linter: "eslint" | "pylint";
|
|
34
43
|
/** Total rules available (core + every installed plugin). */
|
|
35
44
|
available: number;
|
|
36
45
|
/** How many of those are enabled in the resolved config. */
|
|
@@ -45,6 +54,30 @@ export interface RuleCatalog {
|
|
|
45
54
|
* `discoverEslintRules` returning null when nothing is found).
|
|
46
55
|
*/
|
|
47
56
|
export declare function parseEslintCatalog(raw: string): RuleCatalog | null;
|
|
57
|
+
/**
|
|
58
|
+
* Parse pylint's `--list-msgs` (available) + `--list-msgs-enabled` (enabled)
|
|
59
|
+
* text listings into a typed {@link RuleCatalog}.
|
|
60
|
+
*
|
|
61
|
+
* The available set is every emittable message (`:name (CODE):` lines); enabled
|
|
62
|
+
* is the section-scoped subset. Each rule is matchable by BOTH its symbolic name
|
|
63
|
+
* (`id`) and its numeric code (`code`), since a doc may name either. Returns null
|
|
64
|
+
* when no message parses (pylint absent, or malformed output).
|
|
65
|
+
*/
|
|
66
|
+
export declare function parsePylintCatalog(listMsgs: string, listEnabled: string): RuleCatalog | null;
|
|
67
|
+
/**
|
|
68
|
+
* Merge the catalogs of every linter a repo has into ONE catalog for routing.
|
|
69
|
+
*
|
|
70
|
+
* KEEPS EVERY entry — a polyglot repo genuinely has a rule in each linter, so an
|
|
71
|
+
* id shared across ESLint and Pylint (`no-else-return`) is two real rules and
|
|
72
|
+
* both are retained (never dropped by id, the bug that let a Python doc inherit
|
|
73
|
+
* ESLint's state). Collision handling belongs at the ROUTING lookup, not here: a
|
|
74
|
+
* bare id that resolves to two hits is combined conservatively there, while a
|
|
75
|
+
* numeric code (unique to its linter) keeps its own hit — see `buildCatalogLookup`
|
|
76
|
+
* in rule-routing.ts. So the merge is a plain concatenation; each rule carries its
|
|
77
|
+
* own `linter`/`enabled`/`code` provenance. Returns undefined when nothing was
|
|
78
|
+
* enumerated (so a non-JS-non-Python repo is byte-identical to before this existed).
|
|
79
|
+
*/
|
|
80
|
+
export declare function mergeCatalogs(...cats: (RuleCatalog | null | undefined)[]): RuleCatalog | undefined;
|
|
48
81
|
/**
|
|
49
82
|
* Enumerate the repo's available ESLint rules (core + every installed plugin).
|
|
50
83
|
*
|
|
@@ -53,4 +86,16 @@ export declare function parseEslintCatalog(raw: string): RuleCatalog | null;
|
|
|
53
86
|
* config applies, or the subprocess fails.
|
|
54
87
|
*/
|
|
55
88
|
export declare function enumerateEslintCatalog(root: string): RuleCatalog | null;
|
|
89
|
+
/**
|
|
90
|
+
* Enumerate the repo's available Pylint messages (core + every loaded plugin)
|
|
91
|
+
* and their enabled state, via `pylint --list-msgs` + `--list-msgs-enabled`.
|
|
92
|
+
*
|
|
93
|
+
* Run at the repo's cwd so its rcfile (`.pylintrc` / `pyproject.toml` /
|
|
94
|
+
* `setup.cfg`) and `load-plugins` apply — so the listing reflects the repo's
|
|
95
|
+
* REAL rule set, plugins included, with correct enabled state. Like the ESLint
|
|
96
|
+
* catalog this EXECUTES the linter (loading a pylint plugin imports its module),
|
|
97
|
+
* so it's an OWN-REPO / consented capability, NOT the foreign-safe default.
|
|
98
|
+
* Returns null when pylint isn't runnable or lists nothing.
|
|
99
|
+
*/
|
|
100
|
+
export declare function enumeratePylintCatalog(root: string): RuleCatalog | null;
|
|
56
101
|
//# sourceMappingURL=rule-catalog.d.ts.map
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* / boundaries), of which ~140 are enabled — vs the old static map's ~23. That
|
|
10
10
|
* makes an architecture norm enforceable too (`boundaries/dependencies` is in the
|
|
11
11
|
* catalog), which a static map never captured. See
|
|
12
|
-
* `research/rule-
|
|
12
|
+
* `research/rule-enforcer-multilang-design.md` §0 (the spike this productizes).
|
|
13
13
|
*
|
|
14
14
|
* SAFETY — this EXECUTES the linter. Loading ESLint resolves the repo's real
|
|
15
15
|
* config (which can run plugin/config code), so `enumerateEslintCatalog` is an
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
*/
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
24
|
exports.parseEslintCatalog = parseEslintCatalog;
|
|
25
|
+
exports.parsePylintCatalog = parsePylintCatalog;
|
|
26
|
+
exports.mergeCatalogs = mergeCatalogs;
|
|
25
27
|
exports.enumerateEslintCatalog = enumerateEslintCatalog;
|
|
28
|
+
exports.enumeratePylintCatalog = enumeratePylintCatalog;
|
|
26
29
|
const node_path_1 = require("node:path");
|
|
27
30
|
const node_child_process_1 = require("node:child_process");
|
|
28
31
|
// ---------------------------------------------------------------------------
|
|
@@ -44,12 +47,22 @@ function parsePlugins(v) {
|
|
|
44
47
|
function buildRules(core, plugins, enabledSet) {
|
|
45
48
|
const rules = [];
|
|
46
49
|
for (const id of core) {
|
|
47
|
-
rules.push({
|
|
50
|
+
rules.push({
|
|
51
|
+
id,
|
|
52
|
+
linter: "eslint",
|
|
53
|
+
plugin: null,
|
|
54
|
+
enabled: enabledSet.has(id),
|
|
55
|
+
});
|
|
48
56
|
}
|
|
49
57
|
for (const [prefix, pluginRules] of Object.entries(plugins)) {
|
|
50
58
|
for (const rule of pluginRules) {
|
|
51
59
|
const id = `${prefix}/${rule}`;
|
|
52
|
-
rules.push({
|
|
60
|
+
rules.push({
|
|
61
|
+
id,
|
|
62
|
+
linter: "eslint",
|
|
63
|
+
plugin: prefix,
|
|
64
|
+
enabled: enabledSet.has(id),
|
|
65
|
+
});
|
|
53
66
|
}
|
|
54
67
|
}
|
|
55
68
|
return rules;
|
|
@@ -85,6 +98,108 @@ function parseEslintCatalog(raw) {
|
|
|
85
98
|
return { linter: "eslint", available: rules.length, enabled, rules };
|
|
86
99
|
}
|
|
87
100
|
// ---------------------------------------------------------------------------
|
|
101
|
+
// Pure parse: pylint's text listings → typed catalog (covered by the unit test)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// A message line in `pylint --list-msgs`: `:invalid-name (C0103): *...*`
|
|
104
|
+
// (leading colon, no indent). In `--list-msgs-enabled`: ` invalid-name (C0103)`
|
|
105
|
+
// (indented, no colon). One regex captures the `name (CODE)` core of both.
|
|
106
|
+
const PYLINT_MSG_RE = /^\s*:?([a-z][a-z0-9-]*)\s+\(([A-Z]\d+)\)/;
|
|
107
|
+
/** Collect the symbol names under the `Enabled messages:` header ONLY.
|
|
108
|
+
*
|
|
109
|
+
* `--list-msgs-enabled` also prints `Disabled messages:` and `Non-emittable
|
|
110
|
+
* messages:` sections with the SAME `name (CODE)` line shape, so a naive
|
|
111
|
+
* line-shape parse would mislabel disabled rules as enabled. Track the current
|
|
112
|
+
* section: a non-indented line ending in `:` is a header; only lines while the
|
|
113
|
+
* "enabled" header is active count. */
|
|
114
|
+
function parseEnabledSymbols(listEnabled) {
|
|
115
|
+
const enabled = new Set();
|
|
116
|
+
let inEnabled = false;
|
|
117
|
+
for (const line of listEnabled.split("\n")) {
|
|
118
|
+
// A section header is a flush-left line ending in a colon (e.g.
|
|
119
|
+
// "Enabled messages:", "Disabled messages:"). Indented message lines never
|
|
120
|
+
// start at column 0, so this never eats a rule.
|
|
121
|
+
if (/^\S.*:\s*$/.test(line)) {
|
|
122
|
+
inEnabled = /^enabled messages:/i.test(line.trim());
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!inEnabled)
|
|
126
|
+
continue;
|
|
127
|
+
const m = PYLINT_MSG_RE.exec(line);
|
|
128
|
+
if (m)
|
|
129
|
+
enabled.add(m[1]);
|
|
130
|
+
}
|
|
131
|
+
return enabled;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Parse pylint's `--list-msgs` (available) + `--list-msgs-enabled` (enabled)
|
|
135
|
+
* text listings into a typed {@link RuleCatalog}.
|
|
136
|
+
*
|
|
137
|
+
* The available set is every emittable message (`:name (CODE):` lines); enabled
|
|
138
|
+
* is the section-scoped subset. Each rule is matchable by BOTH its symbolic name
|
|
139
|
+
* (`id`) and its numeric code (`code`), since a doc may name either. Returns null
|
|
140
|
+
* when no message parses (pylint absent, or malformed output).
|
|
141
|
+
*/
|
|
142
|
+
function parsePylintCatalog(listMsgs, listEnabled) {
|
|
143
|
+
const enabledSet = parseEnabledSymbols(listEnabled);
|
|
144
|
+
const rules = [];
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
for (const line of listMsgs.split("\n")) {
|
|
147
|
+
// Available lines carry a leading colon; skip anything else (headers, the
|
|
148
|
+
// wrapped description lines, blank lines).
|
|
149
|
+
if (!line.startsWith(":"))
|
|
150
|
+
continue;
|
|
151
|
+
const m = PYLINT_MSG_RE.exec(line);
|
|
152
|
+
if (!m)
|
|
153
|
+
continue;
|
|
154
|
+
const [, name, code] = m;
|
|
155
|
+
if (seen.has(name))
|
|
156
|
+
continue;
|
|
157
|
+
seen.add(name);
|
|
158
|
+
rules.push({
|
|
159
|
+
id: name,
|
|
160
|
+
linter: "pylint",
|
|
161
|
+
plugin: null,
|
|
162
|
+
code,
|
|
163
|
+
enabled: enabledSet.has(name),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (rules.length === 0)
|
|
167
|
+
return null;
|
|
168
|
+
const enabled = rules.reduce((n, r) => (r.enabled ? n + 1 : n), 0);
|
|
169
|
+
return { linter: "pylint", available: rules.length, enabled, rules };
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Merge: a polyglot repo (JS + Python) yields two catalogs → one for routing
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Merge the catalogs of every linter a repo has into ONE catalog for routing.
|
|
176
|
+
*
|
|
177
|
+
* KEEPS EVERY entry — a polyglot repo genuinely has a rule in each linter, so an
|
|
178
|
+
* id shared across ESLint and Pylint (`no-else-return`) is two real rules and
|
|
179
|
+
* both are retained (never dropped by id, the bug that let a Python doc inherit
|
|
180
|
+
* ESLint's state). Collision handling belongs at the ROUTING lookup, not here: a
|
|
181
|
+
* bare id that resolves to two hits is combined conservatively there, while a
|
|
182
|
+
* numeric code (unique to its linter) keeps its own hit — see `buildCatalogLookup`
|
|
183
|
+
* in rule-routing.ts. So the merge is a plain concatenation; each rule carries its
|
|
184
|
+
* own `linter`/`enabled`/`code` provenance. Returns undefined when nothing was
|
|
185
|
+
* enumerated (so a non-JS-non-Python repo is byte-identical to before this existed).
|
|
186
|
+
*/
|
|
187
|
+
function mergeCatalogs(...cats) {
|
|
188
|
+
const present = cats.filter((c) => c != null);
|
|
189
|
+
if (present.length === 0)
|
|
190
|
+
return undefined;
|
|
191
|
+
if (present.length === 1)
|
|
192
|
+
return present[0];
|
|
193
|
+
const rules = present.flatMap((c) => c.rules);
|
|
194
|
+
const enabled = rules.reduce((n, r) => (r.enabled ? n + 1 : n), 0);
|
|
195
|
+
return {
|
|
196
|
+
linter: present[0].linter,
|
|
197
|
+
available: rules.length,
|
|
198
|
+
enabled,
|
|
199
|
+
rules,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
88
203
|
// Real-IO seam: run ESLint in a child process at the repo's cwd
|
|
89
204
|
// ---------------------------------------------------------------------------
|
|
90
205
|
/* v8 ignore start -- spawns a `node -e` subprocess that LOADS ESLint in the
|
|
@@ -143,4 +258,37 @@ function enumerateEslintCatalog(root) {
|
|
|
143
258
|
}
|
|
144
259
|
}
|
|
145
260
|
/* v8 ignore stop */
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Real-IO seam: run pylint in a child process at the repo's cwd
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
/* v8 ignore start -- spawns the repo's `pylint` twice (the executes-the-linter
|
|
265
|
+
seam); the pure text→typed parse is parsePylintCatalog, covered by the unit
|
|
266
|
+
test, and the gated integration test drives this real path when pylint is on
|
|
267
|
+
PATH. */
|
|
268
|
+
/**
|
|
269
|
+
* Enumerate the repo's available Pylint messages (core + every loaded plugin)
|
|
270
|
+
* and their enabled state, via `pylint --list-msgs` + `--list-msgs-enabled`.
|
|
271
|
+
*
|
|
272
|
+
* Run at the repo's cwd so its rcfile (`.pylintrc` / `pyproject.toml` /
|
|
273
|
+
* `setup.cfg`) and `load-plugins` apply — so the listing reflects the repo's
|
|
274
|
+
* REAL rule set, plugins included, with correct enabled state. Like the ESLint
|
|
275
|
+
* catalog this EXECUTES the linter (loading a pylint plugin imports its module),
|
|
276
|
+
* so it's an OWN-REPO / consented capability, NOT the foreign-safe default.
|
|
277
|
+
* Returns null when pylint isn't runnable or lists nothing.
|
|
278
|
+
*/
|
|
279
|
+
function enumeratePylintCatalog(root) {
|
|
280
|
+
const run = (args) => (0, node_child_process_1.execSync)(`pylint ${args}`, {
|
|
281
|
+
encoding: "utf-8",
|
|
282
|
+
cwd: root,
|
|
283
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
284
|
+
timeout: 15000,
|
|
285
|
+
});
|
|
286
|
+
try {
|
|
287
|
+
return parsePylintCatalog(run("--list-msgs"), run("--list-msgs-enabled"));
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/* v8 ignore stop */
|
|
146
294
|
//# sourceMappingURL=rule-catalog.js.map
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* NOISE that would flood the preview. `isFixturePath` is the precision-first
|
|
8
8
|
* discriminator (over-skip a legit `sample-service` before flooding with fixture
|
|
9
9
|
* rules). Pure + unit-tested; the fs discovery/glue lives in cli.ts
|
|
10
|
-
* (`gatherInstructionFiles`). See research/rule-
|
|
10
|
+
* (`gatherInstructionFiles`). See research/rule-enforcer-multilang-design.md §0.
|
|
11
11
|
*/
|
|
12
12
|
/** One instruction file gathered from disk, with its canonical (symlink-resolved)
|
|
13
13
|
* path so a mirror can be detected. */
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* NOISE that would flood the preview. `isFixturePath` is the precision-first
|
|
9
9
|
* discriminator (over-skip a legit `sample-service` before flooding with fixture
|
|
10
10
|
* rules). Pure + unit-tested; the fs discovery/glue lives in cli.ts
|
|
11
|
-
* (`gatherInstructionFiles`). See research/rule-
|
|
11
|
+
* (`gatherInstructionFiles`). See research/rule-enforcer-multilang-design.md §0.
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.dedupeInstructionFiles = dedupeInstructionFiles;
|