vigiles 14.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
@@ -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,34 @@ 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
+ 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
+ }
1638
1711
  function scaffoldSpec(args) {
1639
1712
  const targetFlag = args.find((a) => a.startsWith("--target="));
1640
1713
  const target = targetFlag ? targetFlag.split("=")[1] : "CLAUDE.md";
@@ -3905,7 +3978,7 @@ function printUsage(command) {
3905
3978
  console.log(" vigiles eject [file] Un-manage a compiled file → plain hand-owned markdown (--keep-spec)");
3906
3979
  console.log(" vigiles lint [files...] Verify references, find gaps in instruction files");
3907
3980
  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.");
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.");
3909
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");
3910
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");
3911
3984
  console.log(" vigiles test [files...] Run *.harness.mjs deterministic harness tests");
@@ -4821,18 +4894,50 @@ async function runHookProgramCommand(file) {
4821
4894
  }
4822
4895
  }
4823
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
+ }
4824
4928
  /**
4825
4929
  * Write the versioned JSON artifact (`vigiles-report.json`) — the upload/CI
4826
4930
  * boundary a hosted dashboard ingests. Stamps `meta.generatedAt` here (at write
4827
4931
  * time, not in the pure builder, so the HTML-embedded form stays deterministic).
4828
4932
  */
4829
- function writeAuditJson(report) {
4830
- 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");
4831
4935
  const stamped = {
4832
4936
  ...report,
4833
4937
  meta: { ...report.meta, generatedAt: new Date().toISOString() },
4834
4938
  };
4835
4939
  try {
4940
+ (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
4836
4941
  (0, node_fs_1.writeFileSync)(jsonPath, JSON.stringify(stamped, null, 2) + "\n");
4837
4942
  console.log("✓ Wrote vigiles-report.json — the upload/CI artifact");
4838
4943
  }
@@ -4864,12 +4969,16 @@ function openBestEffort(file) {
4864
4969
  * for a human at a TTY, open it best-effort. The shareable Lighthouse artifact;
4865
4970
  * never spawns a browser for an agent / CI run.
4866
4971
  */
4867
- function writeAuditHtml(report) {
4868
- 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");
4869
4974
  try {
4975
+ (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
4870
4976
  (0, node_fs_1.writeFileSync)(htmlPath, (0, audit_html_js_1.renderAuditHtml)(report));
4871
4977
  console.log("\n✓ Wrote vigiles-report.html — open it for the full report");
4872
- 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)
4873
4982
  openBestEffort(htmlPath);
4874
4983
  }
4875
4984
  catch (e) {
@@ -5309,26 +5418,30 @@ async function main() {
5309
5418
  // Read the local flight recorder ONCE — feeds both the JSON report
5310
5419
  // (structured summary, the product boundary) and the terminal render.
5311
5420
  const ledgerRecords = (0, observe_js_1.readObservations)(root);
5312
- 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, {
5313
5428
  harness: adapter.name,
5314
5429
  vigilesVersion: getVersion(),
5315
5430
  adoptableSurfaces,
5316
5431
  observations: (0, observe_js_1.summarizeObservations)(ledgerRecords),
5317
5432
  rulesInventory: computeRuleInventory(root, adapter.layout.instructionFile),
5318
- ruleRouting: computeRuleRouting(root, adapter.layout.instructionFile),
5319
5433
  });
5320
- const sc = auditReport.score;
5434
+ const sc = auditReportBase.score;
5321
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.)
5322
5439
  if (!json) {
5323
5440
  // The Lighthouse rings: per-category 0–100 + the weighted overall,
5324
5441
  // shown before the detailed report so the headline signal leads.
5325
5442
  console.log((0, audit_score_js_1.formatAuditScore)(sc));
5326
5443
  console.log("");
5327
- }
5328
- console.log(json
5329
- ? JSON.stringify(auditReport, null, 2)
5330
- : (0, scan_js_1.formatScanReport)(report));
5331
- if (!json) {
5444
+ console.log((0, scan_js_1.formatScanReport)(report));
5332
5445
  // Fold each finding's fix inline (replaces the former --fix-plan/--explain
5333
5446
  // flags): the deterministic, free recommendation list under the report.
5334
5447
  const fixes = (0, optimize_js_1.formatRecommendations)(plan);
@@ -5346,17 +5459,14 @@ async function main() {
5346
5459
  .length);
5347
5460
  if (fireNudge)
5348
5461
  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
5462
  }
5356
5463
  // 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).
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.
5360
5470
  // (The safety battery is NOT here — it needs cross-platform confinement
5361
5471
  // that isn't shipped, so it lives in the vigiles/testing API.)
5362
5472
  const isForeign = root !== process.cwd();
@@ -5368,6 +5478,30 @@ async function main() {
5368
5478
  (0, node_fs_1.existsSync)((0, node_path_1.resolve)(root, adapter.layout.instructionFile)),
5369
5479
  };
5370
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
+ }
5371
5505
  // LIVE MCP tool resolution STARTS each declared MCP server — a server is
5372
5506
  // exactly what connects to a real Postgres / authenticates a real API on
5373
5507
  // boot. So it runs only under consent (`execute`) AND own-repo (never
@@ -5465,10 +5599,19 @@ async function main() {
5465
5599
  const finalReport = adoptabilityResult
5466
5600
  ? { ...auditReport, adoptability: adoptabilityResult }
5467
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 = [];
5468
5610
  // The versioned JSON artifact — the upload/CI boundary (a hosted dashboard
5469
5611
  // ingests this). Written by default in the human path; --no-json to skip.
5470
5612
  if (!json && !args.includes("--no-json")) {
5471
- writeAuditJson(finalReport);
5613
+ writeAuditJson(finalReport, outDir);
5614
+ wroteReports.push("vigiles-report.json");
5472
5615
  }
5473
5616
  // The HTML report has two deliveries. STATIC (default): write the
5474
5617
  // shareable file whose buttons copy the `init` command. LIVE (`--serve`,
@@ -5493,7 +5636,21 @@ async function main() {
5493
5636
  await runAuditServe(finalReport, finalReport.adoptable, errMsg);
5494
5637
  }
5495
5638
  else if (!json && !args.includes("--no-html")) {
5496
- 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);
5497
5654
  }
5498
5655
  }
5499
5656
  break;
@@ -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
@@ -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