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/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-compiler-multilang-design.md §0. */
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 ESLint catalog. NOT gated on the
1589
- // agent harness: the catalog is a property of the repo's LINTER (its ESLint
1590
- // config on disk), not of Claude-Code-vs-Codex, so a Codex JS/TS repo gets the
1591
- // same catalog match + enabled-state (adapter-aware-lint-rules: never gate a
1592
- // harness-agnostic capability on CC). enumerateEslintCatalog returns null when
1593
- // no ESLint config resolves (e.g. a pure-Python repo) → undefined, so a non-JS
1594
- // repo simply falls back to the foreign-safe textual routing regardless.
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) ?? undefined)
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
- return routing.segmented > 0 ? routing : undefined;
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 + vigiles-report.json (--no-html/--no-json) · --json for machine output. NOT a CI step — use `vigiles lint` in CI.");
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)(process.cwd(), "vigiles-report.json");
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)(process.cwd(), "vigiles-report.html");
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
- if (process.stdout.isTTY)
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
- const auditReport = (0, audit_report_js_1.buildAuditReport)(report, {
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 = auditReport.score;
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). A plain `audit` is a deterministic READ; these run only on
5358
- // consent — ASK once at a TTY (remembered); headless stays a read + a
5359
- // nudge (no execution flag — automation uses the vigiles/testing API).
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-compiler-multilang-design.md` §0 (the spike this productizes).
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 plugin prefix (`@typescript-eslint`, `boundaries`), or null for a core rule. */
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-compiler-multilang-design.md` §0 (the spike this productizes).
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({ id, plugin: null, enabled: enabledSet.has(id) });
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({ id, plugin: prefix, enabled: enabledSet.has(id) });
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-compiler-multilang-design.md §0.
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-compiler-multilang-design.md §0.
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;