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.
@@ -0,0 +1,294 @@
1
+ "use strict";
2
+ /**
3
+ * vigiles rule-catalog — the DYNAMIC available-rule catalog.
4
+ *
5
+ * Enumerates every lint rule the repo's linter ACTUALLY has — core built-ins PLUS
6
+ * every installed plugin's rules — so prose can be matched against the LIVE
7
+ * catalog instead of a static hand-curated map. On this repo one ESLint API call
8
+ * yields ~702 available rules (292 core + 410 plugin: typescript-eslint / sonarjs
9
+ * / boundaries), of which ~140 are enabled — vs the old static map's ~23. That
10
+ * makes an architecture norm enforceable too (`boundaries/dependencies` is in the
11
+ * catalog), which a static map never captured. See
12
+ * `research/rule-compiler-multilang-design.md` §0 (the spike this productizes).
13
+ *
14
+ * SAFETY — this EXECUTES the linter. Loading ESLint resolves the repo's real
15
+ * config (which can run plugin/config code), so `enumerateEslintCatalog` is an
16
+ * OWN-REPO / consented capability, NOT the foreign-safe default. The deterministic
17
+ * default rule-compile tier stays purely TEXTUAL (it parses config, never loads
18
+ * it); reach for this only where executing the repo's toolchain is already
19
+ * consented (own repo, on the user's machine). Mirrors the subprocess pattern of
20
+ * `discoverEslintRules` in `src/core/generate-types.ts`: ESLint is loaded in a
21
+ * child `node -e` process at the repo's cwd so it stays out of our process.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.parseEslintCatalog = parseEslintCatalog;
25
+ exports.parsePylintCatalog = parsePylintCatalog;
26
+ exports.mergeCatalogs = mergeCatalogs;
27
+ exports.enumerateEslintCatalog = enumerateEslintCatalog;
28
+ exports.enumeratePylintCatalog = enumeratePylintCatalog;
29
+ const node_path_1 = require("node:path");
30
+ const node_child_process_1 = require("node:child_process");
31
+ // ---------------------------------------------------------------------------
32
+ // Pure parse: subprocess JSON → typed catalog (covered by the unit test)
33
+ // ---------------------------------------------------------------------------
34
+ function isStringArray(v) {
35
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
36
+ }
37
+ function parsePlugins(v) {
38
+ if (typeof v !== "object" || v === null)
39
+ return {};
40
+ const out = {};
41
+ for (const [prefix, rules] of Object.entries(v)) {
42
+ if (isStringArray(rules))
43
+ out[prefix] = rules;
44
+ }
45
+ return out;
46
+ }
47
+ function buildRules(core, plugins, enabledSet) {
48
+ const rules = [];
49
+ for (const id of core) {
50
+ rules.push({
51
+ id,
52
+ linter: "eslint",
53
+ plugin: null,
54
+ enabled: enabledSet.has(id),
55
+ });
56
+ }
57
+ for (const [prefix, pluginRules] of Object.entries(plugins)) {
58
+ for (const rule of pluginRules) {
59
+ const id = `${prefix}/${rule}`;
60
+ rules.push({
61
+ id,
62
+ linter: "eslint",
63
+ plugin: prefix,
64
+ enabled: enabledSet.has(id),
65
+ });
66
+ }
67
+ }
68
+ return rules;
69
+ }
70
+ /**
71
+ * Parse the enumeration subprocess's JSON payload into a typed {@link RuleCatalog}.
72
+ *
73
+ * Payload shape: `{ core: string[]; enabled: string[]; plugins: Record<prefix, string[]> }`.
74
+ * Returns null on `"null"` / malformed input / an empty catalog (mirrors
75
+ * `discoverEslintRules` returning null when nothing is found).
76
+ */
77
+ function parseEslintCatalog(raw) {
78
+ const trimmed = raw.trim();
79
+ if (!trimmed || trimmed === "null")
80
+ return null;
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(trimmed);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ if (typeof parsed !== "object" || parsed === null)
89
+ return null;
90
+ const obj = parsed;
91
+ const core = isStringArray(obj.core) ? obj.core : [];
92
+ const enabledList = isStringArray(obj.enabled) ? obj.enabled : [];
93
+ const plugins = parsePlugins(obj.plugins);
94
+ const rules = buildRules(core, plugins, new Set(enabledList));
95
+ if (rules.length === 0)
96
+ return null;
97
+ const enabled = rules.reduce((n, r) => (r.enabled ? n + 1 : n), 0);
98
+ return { linter: "eslint", available: rules.length, enabled, rules };
99
+ }
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
+ // ---------------------------------------------------------------------------
203
+ // Real-IO seam: run ESLint in a child process at the repo's cwd
204
+ // ---------------------------------------------------------------------------
205
+ /* v8 ignore start -- spawns a `node -e` subprocess that LOADS ESLint in the
206
+ repo's cwd (the executes-the-linter seam); the pure JSON→typed parse is
207
+ parseEslintCatalog, covered by the unit test, and the gated integration test
208
+ drives this real path when eslint resolves. */
209
+ /**
210
+ * Enumerate the repo's available ESLint rules (core + every installed plugin).
211
+ *
212
+ * EXECUTES the repo's ESLint in a child process — an own-repo / consented
213
+ * capability (see the file header). Returns null if ESLint isn't resolvable, no
214
+ * config applies, or the subprocess fails.
215
+ */
216
+ function enumerateEslintCatalog(root) {
217
+ try {
218
+ // A `.ts` path under src/ so the repo's flat config applies its TypeScript +
219
+ // plugin blocks (typescript-eslint / sonarjs / boundaries all scope to
220
+ // `src/**/*.ts`); the path need not exist — calculateConfigForFile resolves
221
+ // the config, it does not read the file.
222
+ const probeFile = (0, node_path_1.resolve)(root, "src/index.ts");
223
+ const script = `
224
+ const { loadESLint } = require("eslint");
225
+ const { builtinRules } = require("eslint/use-at-your-own-risk");
226
+ (async () => {
227
+ try {
228
+ const ESLint = await loadESLint();
229
+ const eslint = new ESLint({ cwd: ${JSON.stringify(root)} });
230
+ const cfg = await eslint.calculateConfigForFile(${JSON.stringify(probeFile)});
231
+ const core = [...builtinRules.keys()];
232
+ const enabled = Object.entries(cfg.rules || {})
233
+ .filter(([, v]) => {
234
+ const sev = Array.isArray(v) ? v[0] : v;
235
+ return sev !== 0 && sev !== "off";
236
+ })
237
+ .map(([k]) => k);
238
+ const plugins = {};
239
+ for (const [prefix, plugin] of Object.entries(cfg.plugins || {})) {
240
+ plugins[prefix] = Object.keys((plugin && plugin.rules) || {});
241
+ }
242
+ console.log(JSON.stringify({ core, enabled, plugins }));
243
+ } catch (e) {
244
+ console.log("null");
245
+ }
246
+ })();
247
+ `;
248
+ const output = (0, node_child_process_1.execSync)(`node -e '${script.replace(/'/g, "'\\''")}'`, {
249
+ encoding: "utf-8",
250
+ cwd: root,
251
+ stdio: ["pipe", "pipe", "pipe"],
252
+ timeout: 15000,
253
+ });
254
+ return parseEslintCatalog(output);
255
+ }
256
+ catch {
257
+ return null;
258
+ }
259
+ }
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 */
294
+ //# sourceMappingURL=rule-catalog.js.map
package/dist/eval.d.ts CHANGED
@@ -755,7 +755,19 @@ export interface EvalDriver {
755
755
  */
756
756
  readonly harness?: string;
757
757
  }
758
- /** The default (Claude Code) eval driver: real `claude` + stream-json parsing. */
758
+ /**
759
+ * The default (Claude Code) eval driver: real `claude` + stream-json parsing.
760
+ *
761
+ * This lives at the COMPOSITION ROOT (`src/eval.ts`) on purpose, not in
762
+ * `src/adapters/claude-code/` — it is NOT a boundary leak. Claude Code is the
763
+ * wired DEFAULT (`measureTriggerRate`/`runEval` fall back to it), so `eval.ts`
764
+ * must reference it directly; wiring the default is precisely a composition
765
+ * root's job. `codexEvalDriver` lives in its adapter dir instead because Codex
766
+ * is caller-INJECTED (never a default), so `eval.ts` never imports it. Relocating
767
+ * this into the adapter would make `eval.ts → adapters/claude-code → eval.ts` a
768
+ * circular import (the shared `EvalDriver`/`ModelOutputParser` types live here).
769
+ * The asymmetry reflects default-vs-injected, not a hexagonal violation.
770
+ */
759
771
  export declare const claudeEvalDriver: EvalDriver;
760
772
  /**
761
773
  * Package loose `<skillsDir>/<name>/SKILL.md` skills into a throwaway plugin dir
package/dist/eval.js CHANGED
@@ -1240,7 +1240,19 @@ function formatEvalReport(report) {
1240
1240
  }
1241
1241
  return lines.join("\n");
1242
1242
  }
1243
- /** The default (Claude Code) eval driver: real `claude` + stream-json parsing. */
1243
+ /**
1244
+ * The default (Claude Code) eval driver: real `claude` + stream-json parsing.
1245
+ *
1246
+ * This lives at the COMPOSITION ROOT (`src/eval.ts`) on purpose, not in
1247
+ * `src/adapters/claude-code/` — it is NOT a boundary leak. Claude Code is the
1248
+ * wired DEFAULT (`measureTriggerRate`/`runEval` fall back to it), so `eval.ts`
1249
+ * must reference it directly; wiring the default is precisely a composition
1250
+ * root's job. `codexEvalDriver` lives in its adapter dir instead because Codex
1251
+ * is caller-INJECTED (never a default), so `eval.ts` never imports it. Relocating
1252
+ * this into the adapter would make `eval.ts → adapters/claude-code → eval.ts` a
1253
+ * circular import (the shared `EvalDriver`/`ModelOutputParser` types live here).
1254
+ * The asymmetry reflects default-vs-injected, not a hexagonal violation.
1255
+ */
1244
1256
  exports.claudeEvalDriver = {
1245
1257
  runner: spawnAgent,
1246
1258
  parse: parseClaudeRun,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * instruction-sources.ts — the pure policy for WHICH instruction files the audit
3
+ * rule-routing preview reads.
4
+ *
5
+ * A repo's real subdirectory memory (`src/CLAUDE.md`, `research/CLAUDE.md`, a
6
+ * nested `AGENTS.md`) IS worth routing; a fixture/demo/build/test CLAUDE.md is
7
+ * NOISE that would flood the preview. `isFixturePath` is the precision-first
8
+ * discriminator (over-skip a legit `sample-service` before flooding with fixture
9
+ * rules). Pure + unit-tested; the fs discovery/glue lives in cli.ts
10
+ * (`gatherInstructionFiles`). See research/rule-compiler-multilang-design.md §0.
11
+ */
12
+ /** One instruction file gathered from disk, with its canonical (symlink-resolved)
13
+ * path so a mirror can be detected. */
14
+ export interface RawInstructionFile {
15
+ /** The repo-relative path (kept for provenance). */
16
+ readonly path: string;
17
+ /** The realpath (symlink-resolved) — a symlinked mirror shares this. */
18
+ readonly canonical: string;
19
+ readonly text: string;
20
+ }
21
+ /**
22
+ * Dedup instruction files so a `CLAUDE.md`⇄`AGENTS.md` MIRROR is routed ONCE, not
23
+ * double-counted (compose-with-sync-tools: a symlinked or byte-identical synced
24
+ * mirror is ONE logical artifact). Dedups by BOTH the canonical path (a symlink)
25
+ * AND the content hash (a byte-identical sync) — relative-path dedup alone caught
26
+ * neither. First occurrence wins (callers pass root files first). Pure.
27
+ */
28
+ export declare function dedupeInstructionFiles(files: readonly RawInstructionFile[]): {
29
+ path: string;
30
+ text: string;
31
+ }[];
32
+ /**
33
+ * Is this (repo-relative) instruction-file path fixture/demo/build/test noise?
34
+ * True when any DIRECTORY segment (never the filename) is a build/deps/test dir
35
+ * OR starts with a demo/example/sample/fixture/bench/mock/scratch/tmp prefix.
36
+ * Conventional + general (not vigiles-specific), precision over recall.
37
+ */
38
+ export declare function isFixturePath(relPath: string): boolean;
39
+ //# sourceMappingURL=instruction-sources.d.ts.map
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ /**
3
+ * instruction-sources.ts — the pure policy for WHICH instruction files the audit
4
+ * rule-routing preview reads.
5
+ *
6
+ * A repo's real subdirectory memory (`src/CLAUDE.md`, `research/CLAUDE.md`, a
7
+ * nested `AGENTS.md`) IS worth routing; a fixture/demo/build/test CLAUDE.md is
8
+ * NOISE that would flood the preview. `isFixturePath` is the precision-first
9
+ * discriminator (over-skip a legit `sample-service` before flooding with fixture
10
+ * rules). Pure + unit-tested; the fs discovery/glue lives in cli.ts
11
+ * (`gatherInstructionFiles`). See research/rule-compiler-multilang-design.md §0.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.dedupeInstructionFiles = dedupeInstructionFiles;
15
+ exports.isFixturePath = isFixturePath;
16
+ const hash_js_1 = require("./core/hash.js");
17
+ /**
18
+ * Dedup instruction files so a `CLAUDE.md`⇄`AGENTS.md` MIRROR is routed ONCE, not
19
+ * double-counted (compose-with-sync-tools: a symlinked or byte-identical synced
20
+ * mirror is ONE logical artifact). Dedups by BOTH the canonical path (a symlink)
21
+ * AND the content hash (a byte-identical sync) — relative-path dedup alone caught
22
+ * neither. First occurrence wins (callers pass root files first). Pure.
23
+ */
24
+ function dedupeInstructionFiles(files) {
25
+ const seenReal = new Set();
26
+ const seenHash = new Set();
27
+ const out = [];
28
+ for (const f of files) {
29
+ if (seenReal.has(f.canonical))
30
+ continue; // symlinked mirror
31
+ const hash = (0, hash_js_1.sha256short)(f.text);
32
+ if (seenHash.has(hash))
33
+ continue; // byte-identical synced mirror
34
+ seenReal.add(f.canonical);
35
+ seenHash.add(hash);
36
+ out.push({ path: f.path, text: f.text });
37
+ }
38
+ return out;
39
+ }
40
+ /** Directory segments that are unambiguously build / deps / test noise. */
41
+ const FIXTURE_DIR_EXACT = new Set([
42
+ "node_modules",
43
+ "dist",
44
+ "build",
45
+ "out",
46
+ "coverage",
47
+ ".git",
48
+ ".next",
49
+ ".cache",
50
+ "test",
51
+ "tests",
52
+ "__tests__",
53
+ "__fixtures__",
54
+ "__mocks__",
55
+ "vendor",
56
+ "third_party",
57
+ ]);
58
+ /** Directory-name PREFIXES that mark a fixture / demo / sample / scratch dir.
59
+ * Case-INSENSITIVE so `Examples/`, `Demo/` are skipped too. */
60
+ const FIXTURE_DIR_PREFIX = /^(?:demo|example|sample|fixture|bench|benchmark|mock|stub|scratch|tmp|\.tmp)/i;
61
+ /**
62
+ * Is this (repo-relative) instruction-file path fixture/demo/build/test noise?
63
+ * True when any DIRECTORY segment (never the filename) is a build/deps/test dir
64
+ * OR starts with a demo/example/sample/fixture/bench/mock/scratch/tmp prefix.
65
+ * Conventional + general (not vigiles-specific), precision over recall.
66
+ */
67
+ function isFixturePath(relPath) {
68
+ const segs = relPath.split(/[/\\]/).slice(0, -1); // directories only
69
+ return segs.some((s) => FIXTURE_DIR_EXACT.has(s.toLowerCase()) || FIXTURE_DIR_PREFIX.test(s));
70
+ }
71
+ //# sourceMappingURL=instruction-sources.js.map
@@ -84,6 +84,12 @@ export interface RuleInventoryOptions {
84
84
  * non-`[\w/@.-]` character on each side (so `no-console` matches in
85
85
  * `` `no-console` `` and `enforce no-console;` but `no-console-x` does not,
86
86
  * and prose containing the substring elsewhere never trips it).
87
+ *
88
+ * The TRAILING boundary is a lookahead, not a consuming class, so a keyword at
89
+ * SENTENCE END ("No wildcard imports.") matches: a `.` is allowed unless it
90
+ * CONTINUES a code token (`.log` in `console.log`), which still blocks a partial
91
+ * match. `(?![\w/@-])` rejects a word/`/`/`@`/`-` continuation; `(?!\.[\w/@-])`
92
+ * rejects a dotted continuation but permits a trailing sentence `.`.
87
93
  */
88
94
  export declare function matchesWholeToken(text: string, keyword: string): boolean;
89
95
  export declare function buildRuleInventory(instructionText: string, configText: string, options?: RuleInventoryOptions): RuleInventoryItem[];
@@ -37,6 +37,12 @@ exports.buildRuleInventory = buildRuleInventory;
37
37
  * ESLint-only today — Ruff/Clippy/Pylint/RuboCop/Stylelint entries append here
38
38
  * with their own `linter` + rule-name keywords, no code change.
39
39
  */
40
+ // SCOPE (2026-07-14): this hand-curated list is a small high-precision FAST-PATH
41
+ // (alias enrichment for the most common rules), NOT the strategy. The strategy is
42
+ // the DYNAMIC available-rule catalog — enumerate the rules the repo's linter
43
+ // ACTUALLY has (spike: 702 for this repo vs ~23 here) and match prose against
44
+ // THAT, own-repo/consented since it executes the linter. Do NOT keep growing this
45
+ // by hand. See research/rule-compiler-multilang-design.md §0.
40
46
  exports.INTENT_MAP = [
41
47
  {
42
48
  intent: "no console.log / use the logger",
@@ -196,6 +202,155 @@ exports.INTENT_MAP = [
196
202
  rule: "no-warning-comments",
197
203
  configFix: '"no-warning-comments": ["error", {"terms": ["todo", "fixme"], "location": "anywhere"}]',
198
204
  },
205
+ // Grounded in the OSS-corpus sweep — rules real AGENTS.md files actually NAME
206
+ // (cloudflare/workers-sdk names all three inline). Keyword set is rule-name /
207
+ // code-shaped only, so it fires when the doc names the rule, never on prose.
208
+ {
209
+ intent: "require curly braces for control flow",
210
+ linter: "eslint",
211
+ keywords: ["curly", "curly braces"],
212
+ rule: "curly",
213
+ configFix: '"curly": ["error", "all"]',
214
+ },
215
+ {
216
+ intent: "use import type for type-only imports",
217
+ linter: "eslint",
218
+ keywords: [
219
+ "consistent-type-imports",
220
+ "@typescript-eslint/consistent-type-imports",
221
+ ],
222
+ rule: "@typescript-eslint/consistent-type-imports",
223
+ configFix: '"@typescript-eslint/consistent-type-imports": "error"',
224
+ },
225
+ {
226
+ intent: "no focused / .only tests committed",
227
+ linter: "eslint",
228
+ keywords: [
229
+ "no-only-tests",
230
+ "no-focused-tests",
231
+ "describe.only",
232
+ "it.only",
233
+ "test.only",
234
+ ],
235
+ rule: "no-only-tests/no-only-tests",
236
+ configFix: '"no-only-tests/no-only-tests": "error"',
237
+ },
238
+ // --- Pylint (Python) — routing basics. These feed classify() (routing → reuse);
239
+ // buildRuleInventory is gated to eslint (see below) because pylint is
240
+ // ON-BY-DEFAULT (deny-list), so the eslint-shaped config-state check would
241
+ // MISLABEL it (a symbol in `disable=` reads as "in-config", an absent one as
242
+ // "enable it"). Accurate pylint enabled-state needs the inverted-polarity
243
+ // ConfigProbe (research/rule-compiler-multilang-design.md §3), deferred —
244
+ // classify() needs NO enabled-state, so pylint prose still routes honestly.
245
+ // Keywords are code-shaped symbols + Python-UNAMBIGUOUS compounds (singular AND
246
+ // plural, since matchesWholeToken is boundary-exact); bare ambiguous words
247
+ // (`snake_case` — Rust/Ruby too, `import *` — JS `import * as`, "unused imports"
248
+ // — collides with eslint) are deliberately EXCLUDED to avoid cross-language FPs.
249
+ {
250
+ intent: "no bare except (Python)",
251
+ linter: "pylint",
252
+ keywords: ["bare-except", "bare except", "W0702"],
253
+ rule: "bare-except",
254
+ configFix: "pylint enables bare-except (W0702) by default; keep it out of the disable list",
255
+ },
256
+ {
257
+ intent: "no broad exception catch (Python)",
258
+ linter: "pylint",
259
+ keywords: [
260
+ "broad-exception-caught",
261
+ "broad except",
262
+ "broad exception",
263
+ "W0718",
264
+ ],
265
+ rule: "broad-exception-caught",
266
+ configFix: "pylint enables broad-exception-caught (W0718) by default; keep it out of the disable list",
267
+ },
268
+ {
269
+ intent: "require docstrings (Python)",
270
+ linter: "pylint",
271
+ // Bare "docstring"/"docstrings" removed — it over-fires on docstring
272
+ // CONTENT/STYLE rules (the dogfood caught langchain's "docstring warnings" /
273
+ // "backticks in docstrings"). Presence ("add docstrings", "docstrings for
274
+ // each") is handled by the PATTERN_RULE_MAP docstring-presence pattern in
275
+ // rule-routing.ts; only the rule SYMBOL matches here.
276
+ keywords: ["missing-docstring", "missing-function-docstring", "C0116"],
277
+ rule: "missing-function-docstring",
278
+ configFix: "pylint enables missing-function-docstring (C0116) by default; keep it out of the disable list",
279
+ },
280
+ {
281
+ intent: "no mutable default arguments (Python)",
282
+ linter: "pylint",
283
+ keywords: [
284
+ "dangerous-default-value",
285
+ "mutable default",
286
+ "mutable default argument",
287
+ "mutable default arguments",
288
+ "W0102",
289
+ ],
290
+ rule: "dangerous-default-value",
291
+ configFix: "pylint enables dangerous-default-value (W0102) by default; keep it out of the disable list",
292
+ },
293
+ {
294
+ intent: "prefer f-strings (Python)",
295
+ linter: "pylint",
296
+ keywords: ["f-string", "f-strings", "consider-using-f-string", "C0209"],
297
+ rule: "consider-using-f-string",
298
+ configFix: "pylint enables consider-using-f-string (C0209) by default; keep it out of the disable list",
299
+ },
300
+ {
301
+ intent: "consistent naming (Python)",
302
+ linter: "pylint",
303
+ keywords: ["invalid-name", "C0103"],
304
+ rule: "invalid-name",
305
+ configFix: "pylint enables invalid-name (C0103) by default; set naming-style in [tool.pylint], keep it out of disable",
306
+ },
307
+ {
308
+ intent: "limit function arguments (Python)",
309
+ linter: "pylint",
310
+ keywords: ["too-many-arguments", "R0913"],
311
+ rule: "too-many-arguments",
312
+ configFix: "pylint enables too-many-arguments (R0913) by default; set max-args in [tool.pylint]",
313
+ },
314
+ {
315
+ intent: "limit function length (Python)",
316
+ linter: "pylint",
317
+ keywords: ["too-many-statements", "R0915"],
318
+ rule: "too-many-statements",
319
+ configFix: "pylint enables too-many-statements (R0915) by default; set max-statements in [tool.pylint]",
320
+ },
321
+ {
322
+ intent: "no wildcard imports (Python)",
323
+ linter: "pylint",
324
+ keywords: [
325
+ "wildcard-import",
326
+ "wildcard import",
327
+ "wildcard imports",
328
+ "W0401",
329
+ ],
330
+ rule: "wildcard-import",
331
+ configFix: "pylint enables wildcard-import (W0401) by default; keep it out of the disable list",
332
+ },
333
+ {
334
+ intent: "no global statement (Python)",
335
+ linter: "pylint",
336
+ keywords: ["global-statement", "global statement", "W0603"],
337
+ rule: "global-statement",
338
+ configFix: "pylint enables global-statement (W0603) by default; keep it out of the disable list",
339
+ },
340
+ {
341
+ intent: "max line length (Python)",
342
+ linter: "pylint",
343
+ keywords: ["line-too-long", "C0301"],
344
+ rule: "line-too-long",
345
+ configFix: "pylint enables line-too-long (C0301) by default; set max-line-length in [tool.pylint]",
346
+ },
347
+ {
348
+ intent: "no unused imports (Python)",
349
+ linter: "pylint",
350
+ keywords: ["unused-import", "W0611"],
351
+ rule: "unused-import",
352
+ configFix: "pylint enables unused-import (W0611) by default; keep it out of the disable list",
353
+ },
199
354
  ];
200
355
  /** Escape a keyword for use inside a RegExp. */
201
356
  function escapeRe(s) {
@@ -206,9 +361,15 @@ function escapeRe(s) {
206
361
  * non-`[\w/@.-]` character on each side (so `no-console` matches in
207
362
  * `` `no-console` `` and `enforce no-console;` but `no-console-x` does not,
208
363
  * and prose containing the substring elsewhere never trips it).
364
+ *
365
+ * The TRAILING boundary is a lookahead, not a consuming class, so a keyword at
366
+ * SENTENCE END ("No wildcard imports.") matches: a `.` is allowed unless it
367
+ * CONTINUES a code token (`.log` in `console.log`), which still blocks a partial
368
+ * match. `(?![\w/@-])` rejects a word/`/`/`@`/`-` continuation; `(?!\.[\w/@-])`
369
+ * rejects a dotted continuation but permits a trailing sentence `.`.
209
370
  */
210
371
  function matchesWholeToken(text, keyword) {
211
- const re = new RegExp(`(^|[^\\w/@.-])${escapeRe(keyword)}([^\\w/@.-]|$)`, "i");
372
+ const re = new RegExp(`(^|[^\\w/@.-])${escapeRe(keyword)}(?![\\w/@-])(?!\\.[\\w/@-])`, "i");
212
373
  return re.test(text);
213
374
  }
214
375
  /**
@@ -292,6 +453,14 @@ function buildRuleInventory(instructionText, configText, options = {}) {
292
453
  for (const m of exports.INTENT_MAP) {
293
454
  if (linters && !linters.includes(m.linter))
294
455
  continue;
456
+ // ROUTE-ONLY for non-eslint linters: the config-state check below
457
+ // (ruleSetOff/ruleInConfig) is eslint-config-shaped and would MISLABEL a
458
+ // pylint rule, which is ON-BY-DEFAULT (a symbol in `disable=` reads as
459
+ // "in-config"; an absent one as "enable it" — both inverted). The routing
460
+ // preview (classify) still reuses these; accurate pylint enabled-state waits
461
+ // on the inverted-polarity ConfigProbe (design doc §3). Don't cry wolf.
462
+ if (m.linter !== "eslint")
463
+ continue;
295
464
  const matched = m.keywords.find((kw) => matchesWholeToken(instructionText, kw));
296
465
  if (!matched)
297
466
  continue;