vigiles 13.0.0 → 14.1.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/dist/cli.js CHANGED
@@ -37,6 +37,8 @@ const audit_serve_js_1 = require("./audit-serve.js");
37
37
  const audit_report_js_1 = require("./audit-report.js");
38
38
  const rule_inventory_js_1 = require("./rule-inventory.js");
39
39
  const rule_routing_js_1 = require("./rule-routing.js");
40
+ const instruction_sources_js_1 = require("./instruction-sources.js");
41
+ const rule_catalog_js_1 = require("./core/rule-catalog.js");
40
42
  const adoptability_js_1 = require("./adoptability.js");
41
43
  const compile_js_1 = require("./core/compile.js");
42
44
  const proofs_js_1 = require("./core/proofs.js");
@@ -1513,7 +1515,11 @@ function collectLintConfigText(root) {
1513
1515
  * NO model, NO config execution — safe on any repo. Composition-root. */
1514
1516
  function computeRuleInventory(root, instructionFile) {
1515
1517
  try {
1516
- const instructionText = readInstructionText(root, instructionFile);
1518
+ // The inventory maps intents → rules; it has no per-file line provenance, so
1519
+ // the concatenated text is fine here (unlike the routing preview below).
1520
+ const instructionText = gatherInstructionFiles(root, instructionFile)
1521
+ .map((f) => f.text)
1522
+ .join("\n");
1517
1523
  if (!instructionText.trim())
1518
1524
  return [];
1519
1525
  return (0, rule_inventory_js_1.buildRuleInventory)(instructionText, collectLintConfigText(root));
@@ -1522,28 +1528,125 @@ function computeRuleInventory(root, instructionFile) {
1522
1528
  return [];
1523
1529
  }
1524
1530
  }
1525
- /** Read EVERY agent instruction file present (not just the harness-native one) —
1526
- * rules are often documented in AGENTS.md even under a claude-code harness. */
1527
- function readInstructionText(root, instructionFile) {
1528
- let instructionText = "";
1529
- for (const name of new Set([instructionFile, "CLAUDE.md", "AGENTS.md"])) {
1530
- const p = (0, node_path_1.resolve)(root, name);
1531
- if ((0, node_fs_1.existsSync)(p))
1532
- instructionText += (0, node_fs_1.readFileSync)(p, "utf-8") + "\n";
1531
+ /** Gather EVERY agent instruction file present (not just the harness-native one) —
1532
+ * rules are often documented in AGENTS.md even under a claude-code harness — as a
1533
+ * list of {path, text} so each is routed SEPARATELY and keeps its OWN provenance
1534
+ * (concatenating first would corrupt per-file line numbers). Reads the ROOT
1535
+ * instruction files PLUS nested subdirectory-memory (`src/CLAUDE.md`,
1536
+ * `research/CLAUDE.md`, …), skipping fixture/demo/build/test dirs (`isFixturePath`)
1537
+ * so a repo's real memory is read without the test-fixture noise. `.claude/` rule
1538
+ * sources remain a future source. research/rule-compiler-multilang-design.md §0. */
1539
+ function gatherInstructionFiles(root, instructionFile) {
1540
+ const raw = [];
1541
+ const collect = (rel) => {
1542
+ const p = (0, node_path_1.resolve)(root, rel);
1543
+ if (!(0, node_fs_1.existsSync)(p))
1544
+ return;
1545
+ raw.push({
1546
+ path: rel,
1547
+ canonical: (0, node_fs_1.realpathSync)(p),
1548
+ text: (0, node_fs_1.readFileSync)(p, "utf-8"),
1549
+ });
1550
+ };
1551
+ // Root instruction files first (stable, deterministic order).
1552
+ for (const name of new Set([instructionFile, "CLAUDE.md", "AGENTS.md"]))
1553
+ collect(name);
1554
+ // Nested subdirectory memory, minus fixture/demo/build/test noise.
1555
+ try {
1556
+ const nested = (0, glob_1.globSync)(["**/CLAUDE.md", "**/AGENTS.md"], {
1557
+ cwd: root,
1558
+ ignore: [...IGNORE_NODE_MODULES, "dist/**", ".git/**"],
1559
+ })
1560
+ .filter((rel) => !(0, instruction_sources_js_1.isFixturePath)(rel))
1561
+ .sort();
1562
+ for (const rel of nested)
1563
+ collect(rel);
1564
+ }
1565
+ catch {
1566
+ // best-effort — a glob failure just means root-only, never breaks the audit
1533
1567
  }
1534
- return instructionText;
1568
+ // Dedup a CLAUDE.md⇄AGENTS.md mirror (symlink or byte-identical sync) to ONE
1569
+ // artifact so its rules aren't double-counted (compose-with-sync-tools).
1570
+ return (0, instruction_sources_js_1.dedupeInstructionFiles)(raw);
1535
1571
  }
1536
1572
  /** The deterministic State-B routing preview for `audit`: segment the instruction
1537
- * file(s) into atomic rules and route each (reuse / hook / semantic / unrouted) —
1573
+ * file(s) into atomic rules and route each (reuse / hook / meta / semantic / unrouted) —
1538
1574
  * NO model, fs-only. `undefined` when there's nothing to segment (kept off the
1539
- * report). Best-effort; a routing failure never breaks the audit. */
1575
+ * report). Best-effort; a routing failure never breaks the audit.
1576
+ *
1577
+ * The DYNAMIC catalog (every rule the repo's ESLint actually has + its enabled
1578
+ * state) sharpens matching — but enumerating it EXECUTES the linter, so it is an
1579
+ * OWN-REPO / CONSENTED capability (audit-side-effect-free): gated on the same
1580
+ * sticky `audit.measure` consent as the other executing checks AND on own-repo
1581
+ * (never a stranger's toolchain). The textual routing is the foreign-safe default;
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
+ }
1540
1616
  function computeRuleRouting(root, instructionFile) {
1541
1617
  try {
1542
- const instructionText = readInstructionText(root, instructionFile);
1543
- if (!instructionText.trim())
1618
+ const files = gatherInstructionFiles(root, instructionFile);
1619
+ if (files.every((f) => !f.text.trim()))
1544
1620
  return undefined;
1545
- const routing = (0, rule_routing_js_1.routeRules)(instructionText, instructionFile);
1546
- return routing.segmented > 0 ? routing : undefined;
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.
1630
+ const ownRepo = (0, node_path_1.resolve)(root) === (0, node_path_1.resolve)(process.cwd());
1631
+ const consented = (0, validate_js_1.loadConfig)().audit?.measure === true;
1632
+ const availableRules = ownRepo && consented
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)
1637
+ : undefined;
1638
+ // Route each source SEPARATELY (each rule keeps its own file + line numbers),
1639
+ // then merge — so a CLAUDE.md rule and an AGENTS.md rule carry correct
1640
+ // provenance instead of line numbers offset by a concatenation.
1641
+ const routing = (0, rule_routing_js_1.mergeRoutings)(files.map((f) => (0, rule_routing_js_1.routeRules)(f.text, f.path, { availableRules })));
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;
1547
1650
  }
1548
1651
  catch {
1549
1652
  return undefined;
@@ -1577,6 +1680,34 @@ function formatTriggerNudge(triggerableSkills) {
1577
1680
  return (`ℹ Do your ${String(n)} skill${n === 1 ? "" : "s"} actually fire? The deterministic read can't tell — ` +
1578
1681
  `run \`audit\` interactively to measure, or test with \`measureTriggerRate\` (vigiles/testing).`);
1579
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
+ const lines = [
1700
+ "Rule map — how your prose rules could be enforced (heuristic, precision-first):",
1701
+ ` ✓ ${String(counts.reuse)} enforceable · ⛓ ${String(counts.hook)} hook · ⚙ ${String(counts.unrouted)} custom · ✎ ${String(counts.semantic)} judgment` +
1702
+ (counts.meta > 0 ? ` · ☰ ${String(counts.meta)} agent-note` : ""),
1703
+ ];
1704
+ if (possible.length > 0)
1705
+ lines.push(` ? ${String(possible.length)} possible — rule-ish, but below the confidence bar (review these)`);
1706
+ if (skipped.length > 0)
1707
+ lines.push(` ⊘ ${String(skipped.length)} skipped — not treated as rules (setup steps, descriptions, index entries, no norm signal)`);
1708
+ lines.push(" Detection is a best-effort filter — it won't catch every rule. Full map + skipped list in the report (or --json).");
1709
+ return lines.join("\n");
1710
+ }
1580
1711
  function scaffoldSpec(args) {
1581
1712
  const targetFlag = args.find((a) => a.startsWith("--target="));
1582
1713
  const target = targetFlag ? targetFlag.split("=")[1] : "CLAUDE.md";
@@ -3847,7 +3978,7 @@ function printUsage(command) {
3847
3978
  console.log(" vigiles eject [file] Un-manage a compiled file → plain hand-owned markdown (--keep-spec)");
3848
3979
  console.log(" vigiles lint [files...] Verify references, find gaps in instruction files");
3849
3980
  console.log(" vigiles audit [dir...] Lighthouse for your harness — a LOCAL report: rings + what's broken + fixes (a deterministic read; 2+ dirs → leaderboard)");
3850
- console.log(" writes vigiles-report.html + vigiles-report.json (--no-html/--no-json) · --json for machine output. NOT a CI step — use `vigiles lint` in CI.");
3981
+ 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.");
3851
3982
  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");
3852
3983
  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");
3853
3984
  console.log(" vigiles test [files...] Run *.harness.mjs deterministic harness tests");
@@ -4763,18 +4894,50 @@ async function runHookProgramCommand(file) {
4763
4894
  }
4764
4895
  }
4765
4896
  }
4897
+ /**
4898
+ * Idempotently keep the generated report artifacts OUT of git — the tool that
4899
+ * writes a build artifact keeps it ignored (the `next build` → `.next` pattern),
4900
+ * so `vigiles audit` (which runs zero-config, without `init`) never leaves
4901
+ * surprise untracked files in `git status`. Appends only the MISSING entries
4902
+ * under a labelled block if a `.gitignore` exists; if none exists, prints a
4903
+ * one-line nudge rather than creating one silently. Best-effort — a failure here
4904
+ * never breaks the audit. `entries` are paths relative to the git root (cwd).
4905
+ */
4906
+ function ensureReportGitignored(cwd, entries) {
4907
+ if (entries.length === 0)
4908
+ return;
4909
+ const gi = (0, node_path_1.resolve)(cwd, ".gitignore");
4910
+ try {
4911
+ if (!(0, node_fs_1.existsSync)(gi)) {
4912
+ console.log(`\nℹ tip: add ${entries.join(" + ")} to a .gitignore (generated report artifacts)`);
4913
+ return;
4914
+ }
4915
+ const content = (0, node_fs_1.readFileSync)(gi, "utf-8");
4916
+ const present = new Set(content.split("\n").map((l) => l.trim()));
4917
+ const missing = entries.filter((e) => !present.has(e));
4918
+ if (missing.length === 0)
4919
+ return;
4920
+ const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
4921
+ (0, node_fs_1.writeFileSync)(gi, `${content}${sep}\n# vigiles audit report (generated)\n${missing.join("\n")}\n`);
4922
+ console.log(`✓ Added ${missing.join(" + ")} to .gitignore`);
4923
+ }
4924
+ catch {
4925
+ /* best-effort — a read-only or missing .gitignore never breaks the audit */
4926
+ }
4927
+ }
4766
4928
  /**
4767
4929
  * Write the versioned JSON artifact (`vigiles-report.json`) — the upload/CI
4768
4930
  * boundary a hosted dashboard ingests. Stamps `meta.generatedAt` here (at write
4769
4931
  * time, not in the pure builder, so the HTML-embedded form stays deterministic).
4770
4932
  */
4771
- function writeAuditJson(report) {
4772
- const jsonPath = (0, node_path_1.resolve)(process.cwd(), "vigiles-report.json");
4933
+ function writeAuditJson(report, outDir) {
4934
+ const jsonPath = (0, node_path_1.resolve)(outDir, "vigiles-report.json");
4773
4935
  const stamped = {
4774
4936
  ...report,
4775
4937
  meta: { ...report.meta, generatedAt: new Date().toISOString() },
4776
4938
  };
4777
4939
  try {
4940
+ (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
4778
4941
  (0, node_fs_1.writeFileSync)(jsonPath, JSON.stringify(stamped, null, 2) + "\n");
4779
4942
  console.log("✓ Wrote vigiles-report.json — the upload/CI artifact");
4780
4943
  }
@@ -4806,12 +4969,16 @@ function openBestEffort(file) {
4806
4969
  * for a human at a TTY, open it best-effort. The shareable Lighthouse artifact;
4807
4970
  * never spawns a browser for an agent / CI run.
4808
4971
  */
4809
- function writeAuditHtml(report) {
4810
- const htmlPath = (0, node_path_1.resolve)(process.cwd(), "vigiles-report.html");
4972
+ function writeAuditHtml(report, outDir, open) {
4973
+ const htmlPath = (0, node_path_1.resolve)(outDir, "vigiles-report.html");
4811
4974
  try {
4975
+ (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
4812
4976
  (0, node_fs_1.writeFileSync)(htmlPath, (0, audit_html_js_1.renderAuditHtml)(report));
4813
4977
  console.log("\n✓ Wrote vigiles-report.html — open it for the full report");
4814
- if (process.stdout.isTTY)
4978
+ // Great DX: pop the report open in the browser for a human at a TTY (the
4979
+ // `lighthouse --view` behaviour). `--no-open` suppresses it; an agent / CI
4980
+ // run (no TTY) never spawns a browser.
4981
+ if (open && process.stdout.isTTY)
4815
4982
  openBestEffort(htmlPath);
4816
4983
  }
4817
4984
  catch (e) {
@@ -5251,26 +5418,30 @@ async function main() {
5251
5418
  // Read the local flight recorder ONCE — feeds both the JSON report
5252
5419
  // (structured summary, the product boundary) and the terminal render.
5253
5420
  const ledgerRecords = (0, observe_js_1.readObservations)(root);
5254
- const auditReport = (0, audit_report_js_1.buildAuditReport)(report, {
5421
+ // The report scaffold WITHOUT the rule map — the map's catalog
5422
+ // enrichment (enabled-state / "documented but OFF") enumerates the repo's
5423
+ // linter, which is gated on the SAME audit.measure consent as the
5424
+ // executing checks. So the map is routed AFTER consent (resolved below)
5425
+ // and folded into the report there, so a first-time "yes" enriches THIS
5426
+ // run — not the next one.
5427
+ const auditReportBase = (0, audit_report_js_1.buildAuditReport)(report, {
5255
5428
  harness: adapter.name,
5256
5429
  vigilesVersion: getVersion(),
5257
5430
  adoptableSurfaces,
5258
5431
  observations: (0, observe_js_1.summarizeObservations)(ledgerRecords),
5259
5432
  rulesInventory: computeRuleInventory(root, adapter.layout.instructionFile),
5260
- ruleRouting: computeRuleRouting(root, adapter.layout.instructionFile),
5261
5433
  });
5262
- const sc = auditReport.score;
5434
+ const sc = auditReportBase.score;
5263
5435
  const plan = (0, optimize_js_1.optimize)(report);
5436
+ // The deterministic READ leads: rings + report + fixes + nudges print
5437
+ // BEFORE the consent prompt, so a plain `audit` shows its findings first.
5438
+ // (JSON stays silent until the single blob below, after consent.)
5264
5439
  if (!json) {
5265
5440
  // The Lighthouse rings: per-category 0–100 + the weighted overall,
5266
5441
  // shown before the detailed report so the headline signal leads.
5267
5442
  console.log((0, audit_score_js_1.formatAuditScore)(sc));
5268
5443
  console.log("");
5269
- }
5270
- console.log(json
5271
- ? JSON.stringify(auditReport, null, 2)
5272
- : (0, scan_js_1.formatScanReport)(report));
5273
- if (!json) {
5444
+ console.log((0, scan_js_1.formatScanReport)(report));
5274
5445
  // Fold each finding's fix inline (replaces the former --fix-plan/--explain
5275
5446
  // flags): the deterministic, free recommendation list under the report.
5276
5447
  const fixes = (0, optimize_js_1.formatRecommendations)(plan);
@@ -5288,17 +5459,14 @@ async function main() {
5288
5459
  .length);
5289
5460
  if (fireNudge)
5290
5461
  console.log("\n" + fireNudge);
5291
- // The flight recorder: a compact summary of what the harness actually
5292
- // DID in real sessions (hook/agent decisions), read off the local
5293
- // agent-readable ledger. Empty (skipped) until something is recorded.
5294
- const ledgerSummary = (0, observe_js_1.formatLedgerSummary)(ledgerRecords);
5295
- if (ledgerSummary)
5296
- console.log("\n" + ledgerSummary);
5297
5462
  }
5298
5463
  // ONE read-vs-run decision for the EXECUTING checks (live MCP + skill
5299
- // firing). A plain `audit` is a deterministic READ; these run only on
5300
- // consent — ASK once at a TTY (remembered); headless stays a read + a
5301
- // nudge (no execution flag — automation uses the vigiles/testing API).
5464
+ // firing) AND the rule map's catalog enrichment (enumerating the repo's
5465
+ // linter also executes it). A plain `audit` is a deterministic READ;
5466
+ // these run only on consent — ASK once at a TTY (remembered); headless
5467
+ // stays a read + a nudge (no execution flag — automation uses the
5468
+ // vigiles/testing API). Resolved AFTER the report (so the read leads) but
5469
+ // BEFORE the rule map is routed, so a first-time "yes" enriches THIS run.
5302
5470
  // (The safety battery is NOT here — it needs cross-platform confinement
5303
5471
  // that isn't shipped, so it lives in the vigiles/testing API.)
5304
5472
  const isForeign = root !== process.cwd();
@@ -5310,6 +5478,30 @@ async function main() {
5310
5478
  (0, node_fs_1.existsSync)((0, node_path_1.resolve)(root, adapter.layout.instructionFile)),
5311
5479
  };
5312
5480
  const { execute, note: execNote } = await resolveExecution(surfaces, json, args, adapter.name);
5481
+ // Consent is now settled (and remembered via .vigilesrc.json, which
5482
+ // computeRuleRouting re-reads) — route the prose rules, enumerating the
5483
+ // live catalog when consented + own-repo. Feeds the JSON report, the
5484
+ // written artifacts, and the terminal rule-map summary.
5485
+ const ruleRouting = computeRuleRouting(root, adapter.layout.instructionFile);
5486
+ const auditReport = ruleRouting
5487
+ ? { ...auditReportBase, ruleRouting }
5488
+ : auditReportBase;
5489
+ if (json) {
5490
+ console.log(JSON.stringify(auditReport, null, 2));
5491
+ }
5492
+ else {
5493
+ // The rule map — what audit detected in your instruction file and how
5494
+ // each rule could be enforced (confident / possible / skipped tiers).
5495
+ const ruleMap = formatRuleMapSummary(ruleRouting);
5496
+ if (ruleMap)
5497
+ console.log("\n" + ruleMap);
5498
+ // The flight recorder: a compact summary of what the harness actually
5499
+ // DID in real sessions (hook/agent decisions), read off the local
5500
+ // agent-readable ledger. Empty (skipped) until something is recorded.
5501
+ const ledgerSummary = (0, observe_js_1.formatLedgerSummary)(ledgerRecords);
5502
+ if (ledgerSummary)
5503
+ console.log("\n" + ledgerSummary);
5504
+ }
5313
5505
  // LIVE MCP tool resolution STARTS each declared MCP server — a server is
5314
5506
  // exactly what connects to a real Postgres / authenticates a real API on
5315
5507
  // boot. So it runs only under consent (`execute`) AND own-repo (never
@@ -5407,10 +5599,19 @@ async function main() {
5407
5599
  const finalReport = adoptabilityResult
5408
5600
  ? { ...auditReport, adoptability: adoptabilityResult }
5409
5601
  : auditReport;
5602
+ // Where the report artifacts land: cwd by default, or `--out=<dir>` (for
5603
+ // CI upload / a custom location). The dir is created if missing.
5604
+ const outFlag = args.find((a) => a.startsWith("--out="));
5605
+ const outDir = outFlag
5606
+ ? (0, node_path_1.resolve)(process.cwd(), outFlag.slice("--out=".length))
5607
+ : process.cwd();
5608
+ const openReport = !args.includes("--no-open");
5609
+ const wroteReports = [];
5410
5610
  // The versioned JSON artifact — the upload/CI boundary (a hosted dashboard
5411
5611
  // ingests this). Written by default in the human path; --no-json to skip.
5412
5612
  if (!json && !args.includes("--no-json")) {
5413
- writeAuditJson(finalReport);
5613
+ writeAuditJson(finalReport, outDir);
5614
+ wroteReports.push("vigiles-report.json");
5414
5615
  }
5415
5616
  // The HTML report has two deliveries. STATIC (default): write the
5416
5617
  // shareable file whose buttons copy the `init` command. LIVE (`--serve`,
@@ -5435,7 +5636,21 @@ async function main() {
5435
5636
  await runAuditServe(finalReport, finalReport.adoptable, errMsg);
5436
5637
  }
5437
5638
  else if (!json && !args.includes("--no-html")) {
5438
- writeAuditHtml(finalReport);
5639
+ writeAuditHtml(finalReport, outDir, openReport);
5640
+ wroteReports.push("vigiles-report.html");
5641
+ }
5642
+ // Keep the generated artifacts out of git so a zero-config `audit` leaves
5643
+ // a clean `git status` (works without `init`). Entries are relative to the
5644
+ // git root (cwd); a custom --out dir is ignored by its relative path.
5645
+ // .gitignore patterns are POSIX-separated, so normalize away Windows
5646
+ // backslashes (`relative()` yields `reports\x` on Windows, which would
5647
+ // never match `reports/x`).
5648
+ if (wroteReports.length > 0) {
5649
+ const rel = wroteReports.map((f) => {
5650
+ const r = (0, node_path_1.relative)(process.cwd(), (0, node_path_1.resolve)(outDir, f)) || f;
5651
+ return node_path_1.sep === "/" ? r : r.split(node_path_1.sep).join("/");
5652
+ });
5653
+ ensureReportGitignored(process.cwd(), rel);
5439
5654
  }
5440
5655
  }
5441
5656
  break;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * vigiles rule-catalog — the DYNAMIC available-rule catalog.
3
+ *
4
+ * Enumerates every lint rule the repo's linter ACTUALLY has — core built-ins PLUS
5
+ * every installed plugin's rules — so prose can be matched against the LIVE
6
+ * catalog instead of a static hand-curated map. On this repo one ESLint API call
7
+ * yields ~702 available rules (292 core + 410 plugin: typescript-eslint / sonarjs
8
+ * / boundaries), of which ~140 are enabled — vs the old static map's ~23. That
9
+ * makes an architecture norm enforceable too (`boundaries/dependencies` is in the
10
+ * catalog), which a static map never captured. See
11
+ * `research/rule-compiler-multilang-design.md` §0 (the spike this productizes).
12
+ *
13
+ * SAFETY — this EXECUTES the linter. Loading ESLint resolves the repo's real
14
+ * config (which can run plugin/config code), so `enumerateEslintCatalog` is an
15
+ * OWN-REPO / consented capability, NOT the foreign-safe default. The deterministic
16
+ * default rule-compile tier stays purely TEXTUAL (it parses config, never loads
17
+ * it); reach for this only where executing the repo's toolchain is already
18
+ * consented (own repo, on the user's machine). Mirrors the subprocess pattern of
19
+ * `discoverEslintRules` in `src/core/generate-types.ts`: ESLint is loaded in a
20
+ * child `node -e` process at the repo's cwd so it stays out of our process.
21
+ */
22
+ /** One rule the repo's linter has available, with its enabled state. */
23
+ export interface AvailableRule {
24
+ /** The rule id: `no-console`, `@typescript-eslint/no-explicit-any`, `boundaries/dependencies`.
25
+ * For Pylint this is the SYMBOLIC name (`missing-function-docstring`). */
26
+ id: string;
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. */
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;
37
+ /** Whether the rule is enabled (severity not 0/"off") in the resolved config. */
38
+ enabled: boolean;
39
+ }
40
+ /** The full available-rule catalog for a repo's linter. */
41
+ export interface RuleCatalog {
42
+ linter: "eslint" | "pylint";
43
+ /** Total rules available (core + every installed plugin). */
44
+ available: number;
45
+ /** How many of those are enabled in the resolved config. */
46
+ enabled: number;
47
+ rules: AvailableRule[];
48
+ }
49
+ /**
50
+ * Parse the enumeration subprocess's JSON payload into a typed {@link RuleCatalog}.
51
+ *
52
+ * Payload shape: `{ core: string[]; enabled: string[]; plugins: Record<prefix, string[]> }`.
53
+ * Returns null on `"null"` / malformed input / an empty catalog (mirrors
54
+ * `discoverEslintRules` returning null when nothing is found).
55
+ */
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;
81
+ /**
82
+ * Enumerate the repo's available ESLint rules (core + every installed plugin).
83
+ *
84
+ * EXECUTES the repo's ESLint in a child process — an own-repo / consented
85
+ * capability (see the file header). Returns null if ESLint isn't resolvable, no
86
+ * config applies, or the subprocess fails.
87
+ */
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;
101
+ //# sourceMappingURL=rule-catalog.d.ts.map