vigiles 12.7.0 → 13.0.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.
@@ -21,7 +21,7 @@ const glob_1 = require("glob");
21
21
  // ---------------------------------------------------------------------------
22
22
  // Internals
23
23
  // ---------------------------------------------------------------------------
24
- const DEFAULT_INCLUDE = ["docs/**/*.md", "research/**/*.md"];
24
+ const DEFAULT_INCLUDE = ["docs/**/*.md"];
25
25
  const DEFAULT_IGNORE = [
26
26
  "node_modules/**",
27
27
  "dist/**",
@@ -35,24 +35,48 @@ const DEFAULT_IGNORE = [
35
35
  * that nothing else links to but is not rot.
36
36
  */
37
37
  const DISABLE_RE = /<!--\s*vigiles-disable\s+orphan-docs\s*-->/;
38
+ /** The one universal cross-harness skill-entry filename (CC + Codex). */
39
+ const SKILL_FILE = "SKILL.md";
38
40
  /**
39
- * Files the HARNESS loads directly — an instruction file (`CLAUDE.md` /
40
- * `AGENTS.md`), a skill (`SKILL.md`), a subagent (`agents/*.md`), or a slash
41
- * command (`commands/*.md`) — are load-bearing by their NAME/LOCATION, not
42
- * because another `.md` links to them. They are categorically NOT docs, so they
43
- * are never orphans, even if a project broadens `orphans.include` to scan the
44
- * whole repo. (They are still scanned as REFERENCERS, so a real doc that only
45
- * a CLAUDE.md links to is still credited — this exemption only removes them from
46
- * the orphan-CANDIDATE set.)
41
+ * Files the HARNESS loads directly — its instruction file
42
+ * (`layout.instructionFile`, e.g. `CLAUDE.md` / `AGENTS.md`), a skill
43
+ * (`SKILL.md`), a subagent (`<agentDir>/*.md`), or a slash command
44
+ * (`<commandDir>/*.md`) — are load-bearing by their NAME/LOCATION, not because
45
+ * another `.md` links to them. They are categorically NOT docs, so they are
46
+ * never orphans even when `orphans.include` broadens to the whole repo.
47
+ *
48
+ * The surface names come from the INJECTED layouts, so core stays
49
+ * harness-agnostic — no Claude Code literal here; the CLI passes every
50
+ * registered adapter's layout, and `SKILL.md` is the one universal convention.
51
+ * (They are still scanned as REFERENCERS, so a real doc that only a `CLAUDE.md`
52
+ * links to is still credited — this exemption only removes them from the
53
+ * orphan-CANDIDATE set.)
47
54
  */
48
- function isHarnessLoadedFile(path) {
55
+ function isHarnessLoadedFile(path, layouts) {
49
56
  const norm = normalizePath(path);
50
57
  const base = norm.slice(norm.lastIndexOf("/") + 1);
51
- if (base === "CLAUDE.md" || base === "AGENTS.md" || base === "SKILL.md") {
58
+ if (base === SKILL_FILE)
52
59
  return true;
60
+ for (const layout of layouts) {
61
+ if (base === layout.instructionFile)
62
+ return true;
63
+ // Subagent / slash-command surfaces live at a REAL surface root — the repo
64
+ // root, the user-surface root (e.g. `.claude/`), or the materialize root —
65
+ // NOT any nested dir that merely shares the name. A doc under `docs/prompts/`
66
+ // is documentation, not Codex's `prompts` command surface.
67
+ const roots = [
68
+ "",
69
+ ...[layout.userSurfaceRoot, layout.materializeRoot]
70
+ .filter((r) => !!r)
71
+ .map((r) => `${r}/`),
72
+ ];
73
+ for (const dir of [layout.agentDir, layout.commandDir]) {
74
+ if (dir && roots.some((r) => norm.startsWith(`${r}${dir}/`))) {
75
+ return true;
76
+ }
77
+ }
53
78
  }
54
- // Subagent / slash-command surfaces the harness enumerates by directory.
55
- return /(^|\/)(agents|commands)\//.test(norm);
79
+ return false;
56
80
  }
57
81
  // Match markdown links ](path.md) or ](path.md#anchor)
58
82
  const LINK_RE = /\]\(([^)\s]+\.md)(?:#[^)]*)?\)/g;
@@ -71,12 +95,12 @@ function isOrphanExempt(absPath) {
71
95
  }
72
96
  }
73
97
  /** Discover docs under `include`, dropping any that carry the inline opt-out. */
74
- function collectDocs(basePath, include, ignore) {
98
+ function collectDocs(basePath, include, ignore, layouts) {
75
99
  const docs = new Set();
76
100
  for (const pattern of include) {
77
101
  for (const p of (0, glob_1.globSync)(pattern, { cwd: basePath, ignore: [...ignore] })) {
78
- if (isHarnessLoadedFile(p))
79
- continue; // instruction files are never orphans
102
+ if (isHarnessLoadedFile(p, layouts))
103
+ continue; // harness files are never orphans
80
104
  if (isOrphanExempt((0, node_path_1.resolve)(basePath, p)))
81
105
  continue;
82
106
  docs.add(normalizePath(p));
@@ -121,15 +145,17 @@ function refTargets(sourcePath, ref) {
121
145
  * links to itself is still an orphan.
122
146
  *
123
147
  * `include` and `exclude` are tsconfig-style glob arrays. Default include
124
- * is the vigiles-repo convention `["docs/**\/*.md", "research/**\/*.md"]`;
125
- * override per-project via `.vigilesrc.json` → `orphans.include`.
148
+ * is `["docs/**\/*.md"]` (the common convention); override per-project via
149
+ * `.vigilesrc.json` → `orphans.include` (whose presence also opts the repo
150
+ * into the scan — see the CLI gate in `vigiles lint`).
126
151
  */
127
152
  function findOrphanDocs(options = {}) {
128
153
  const basePath = options.basePath ?? process.cwd();
129
154
  const include = options.include ?? DEFAULT_INCLUDE;
130
155
  const userExclude = options.exclude ?? [];
131
156
  const ignore = [...DEFAULT_IGNORE, ...userExclude];
132
- const allDocs = collectDocs(basePath, include, ignore);
157
+ const layouts = options.layouts ?? [];
158
+ const allDocs = collectDocs(basePath, include, ignore, layouts);
133
159
  const allMarkdown = (0, glob_1.globSync)("**/*.md", {
134
160
  cwd: basePath,
135
161
  ignore: [...DEFAULT_IGNORE],
@@ -256,10 +256,10 @@ exports.RULE_META = {
256
256
  // --- Docs hygiene ---------------------------------------------------------
257
257
  "orphan-docs": {
258
258
  id: "orphan-docs",
259
- bucket: "external-decidable",
259
+ bucket: "heuristic-behavioral",
260
260
  surface: ["docs"],
261
261
  defaultSeverity: "warn",
262
- summary: "Every docs/ + research/ .md is referenced by another .md.",
262
+ summary: "Opt-in: a doc in a configured dir (default docs/) that no other .md references.",
263
263
  detector: "findOrphanDocs",
264
264
  },
265
265
  };
@@ -54,13 +54,19 @@ export interface CoverageThresholds {
54
54
  /** Min % of npm scripts documented in spec commands. */
55
55
  scripts?: number;
56
56
  }
57
- /** Options for the orphan-docs check. */
57
+ /**
58
+ * Options for the orphan-docs check. The PRESENCE of this block in
59
+ * `.vigilesrc.json` OPTS THE REPO IN — the scan is off unless declared,
60
+ * because "unreferenced" only means "rot" for a hand-cross-linked corpus,
61
+ * not for a nav-managed doc site (Docusaurus/MkDocs) where the page graph
62
+ * lives in config. `include` is the optional dir override.
63
+ */
58
64
  export interface OrphansConfig {
59
65
  /**
60
66
  * Glob patterns of `.md` files to scan for orphans. A doc is "orphaned"
61
- * when no other markdown file references it. Defaults to vigiles-repo
62
- * convention: `["docs/**\/*.md", "research/**\/*.md"]`. Set to `[]` to
63
- * disable orphan detection entirely.
67
+ * when no other markdown file references it. Omitted → `["docs/**\/*.md"]`
68
+ * (the common convention); add your own dirs (e.g. a `research/` notes
69
+ * tree) explicitly. Set to `[]` to opt in but scan nothing.
64
70
  */
65
71
  include?: readonly string[];
66
72
  /**
@@ -9,7 +9,7 @@
9
9
  * the `claude` CLI) + a running session tally. We deliberately do NOT show a
10
10
  * "% of your subscription" — Anthropic does not expose a subscription's quota or
11
11
  * limit programmatically (and the real limits are rolling rate windows, not a
12
- * dollar bucket), so any percentage would be fiction. See docs/eval-architecture.md.
12
+ * dollar bucket), so any percentage would be fiction. See research/eval-architecture.md.
13
13
  *
14
14
  * Pure + injectable (env + an output sink), so the whole thing is unit-tested
15
15
  * without a model or a real key.
package/dist/eval.d.ts CHANGED
@@ -43,7 +43,7 @@ export interface EvalArm {
43
43
  * opus: { model: "claude-opus-4-8" } }` — so model-as-an-arm answers "does my
44
44
  * harness still hold on the cheaper tier / after a model upgrade?" through the
45
45
  * same significance machinery, with no separate model-matrix runner. Omit to
46
- * use the eval-level model. See `docs/eval-architecture.md` (model strategy).
46
+ * use the eval-level model. See `research/eval-architecture.md` (model strategy).
47
47
  */
48
48
  readonly model?: string;
49
49
  }
@@ -491,7 +491,7 @@ export declare function aggregateUsage(usages: readonly EvalUsage[]): ArmUsage;
491
491
  * e.g. `claude-haiku-4-5-20251001`. A floating alias (`haiku`, `sonnet`, or even
492
492
  * `claude-sonnet-4-6` with no date) can change underneath you — so a cached or
493
493
  * baselined result pinned to it can silently hide model drift. See
494
- * `docs/eval-architecture.md` (honest model pinning).
494
+ * `research/eval-architecture.md` (honest model pinning).
495
495
  */
496
496
  export declare function isDatedModel(model: string): boolean;
497
497
  /**
package/dist/eval.js CHANGED
@@ -665,7 +665,7 @@ function isRecord(v) {
665
665
  * e.g. `claude-haiku-4-5-20251001`. A floating alias (`haiku`, `sonnet`, or even
666
666
  * `claude-sonnet-4-6` with no date) can change underneath you — so a cached or
667
667
  * baselined result pinned to it can silently hide model drift. See
668
- * `docs/eval-architecture.md` (honest model pinning).
668
+ * `research/eval-architecture.md` (honest model pinning).
669
669
  */
670
670
  function isDatedModel(model) {
671
671
  return /\d{8}$/.test(model);
@@ -214,6 +214,12 @@ function computeIntegrityScore(deductions) {
214
214
  }
215
215
  return { score: Math.max(0, 100 - penalty), penalty };
216
216
  }
217
+ /** Resolve the terse "thing(s)" plural placeholder against a count:
218
+ * n===1 drops the "(s)" ("1 tool"); otherwise it becomes "s" ("3 tools").
219
+ * (Kept local — audit-score.ts has its own copy to avoid a circular import.) */
220
+ function pluralizeLabel(n, label) {
221
+ return label.replace(/\(s\)/g, n === 1 ? "" : "s");
222
+ }
217
223
  /** Deterministic structural-health score for one scanned plugin. */
218
224
  function scoreReport(r) {
219
225
  // An empty/unloadable machine isn't healthy — it's a non-plugin or a broken
@@ -230,7 +236,7 @@ function scoreReport(r) {
230
236
  for (const d of deductions) {
231
237
  if (d.n === 0)
232
238
  continue;
233
- issues.push(`${String(d.n)} ${d.label}`);
239
+ issues.push(`${String(d.n)} ${pluralizeLabel(d.n, d.label)}`);
234
240
  }
235
241
  // Sort issues by cost (worst first) so the report leads with what matters.
236
242
  issues.sort((a, b) => Number(b.split(" ")[0]) - Number(a.split(" ")[0]));
@@ -241,10 +247,10 @@ function scoreReport(r) {
241
247
  // - untested surfaces: a hardening gap, not breakage.
242
248
  const noContract = r.agents.filter((a) => a.tools === null).length;
243
249
  if (noContract > 0) {
244
- issues.push(`${String(noContract)} agent(s) inherit all tools (no contract) (advisory)`);
250
+ issues.push(`${String(noContract)} ${pluralizeLabel(noContract, "agent(s) inherit all tools (no contract) (advisory)")}`);
245
251
  }
246
252
  if (r.untested > 0) {
247
- issues.push(`${String(r.untested)} untested surface(s) (advisory)`);
253
+ issues.push(`${String(r.untested)} ${pluralizeLabel(r.untested, "untested surface(s) (advisory)")}`);
248
254
  }
249
255
  return { score, issues };
250
256
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Rule-inventory — the deterministic, foreign-safe teaser surface of the
3
+ * `audit` rule-compile tier (design: `research/audit-rule-compile-tier.md`).
4
+ *
5
+ * Finds prose rules in a `CLAUDE.md` / `AGENTS.md` that map to an off-the-shelf
6
+ * lint rule, and whether that rule already appears in the repo's lint config.
7
+ * The remedy for a documented-but-unconfigured intent is a one-line config
8
+ * change, not synthesis — so this is a cheap, high-value nudge.
9
+ *
10
+ * NO model. NO config execution (textual grep only — never resolves/executes
11
+ * `eslint.config.js`, which would be the RCE path). HIGH PRECISION by
12
+ * construction: only rule-name / code-token-shaped keywords are matched, and
13
+ * only as whole tokens. Bare prose words are excluded on purpose — a raw
14
+ * keyword match (`token`, `!`, `await`, `secret`, `silently`, …) sprays false
15
+ * positives on real instruction files (measured: 107 raw hits over 4 real
16
+ * CLAUDE.md files, ~all garbage). The model-driven analysis (extract → classify
17
+ * → compile → gate → run) lives in the OPT-IN tier, not here.
18
+ *
19
+ * MULTI-LINTER by shape, ESLint-first by data. The matcher is linter-agnostic;
20
+ * each mapping is keyed by linter, so adding Ruff / Clippy / Pylint / RuboCop /
21
+ * Stylelint is additive DATA (a curation task), not a refactor. The OPT-IN tier
22
+ * should resolve "is this rule enabled" via vigiles's existing multi-linter
23
+ * `checkLinterRule` engine (which execs the config) — kept out of this
24
+ * exec-free, foreign-safe surface on purpose.
25
+ */
26
+ /** Linters vigiles's cross-reference engine already understands. */
27
+ export type LinterName = "eslint" | "ruff" | "clippy" | "pylint" | "rubocop" | "stylelint";
28
+ /** One prose→rule mapping for a single linter. `keywords` are rule-name/token-shaped. */
29
+ export interface IntentMapping {
30
+ readonly intent: string;
31
+ readonly linter: LinterName;
32
+ /** Whole-token, code-shaped triggers only (no bare English words). */
33
+ readonly keywords: readonly string[];
34
+ /** The off-the-shelf rule that enforces this intent. */
35
+ readonly rule: string;
36
+ /** The one-line config change that turns it on. */
37
+ readonly configFix: string;
38
+ /** True if a `recommended` preset typically enables this rule (so a bare
39
+ * recommended-extends is evidence it may already be on). */
40
+ readonly inRecommended?: boolean;
41
+ }
42
+ /**
43
+ * Curated from agent-rules-compiler's `rule-map.json`, keeping ONLY the
44
+ * specific (rule-name / code-token) keywords and dropping every bare-word
45
+ * trigger the FP measurement flagged (`token`, `secret`, `password`, `await`,
46
+ * `!`, `aria`, `silently`, `prefix`, `complexity`, `barrel`, `cycle`, …).
47
+ *
48
+ * ESLint-only today — Ruff/Clippy/Pylint/RuboCop/Stylelint entries append here
49
+ * with their own `linter` + rule-name keywords, no code change.
50
+ */
51
+ export declare const INTENT_MAP: readonly IntentMapping[];
52
+ /**
53
+ * Whether the mapped rule is visible in the lint config text (textual grep —
54
+ * imperfect, labelled). `contradiction` is the sharpest state: the harness
55
+ * documents the rule as a norm, yet the config EXPLICITLY sets it to off/0 —
56
+ * the docs and the config disagree.
57
+ */
58
+ export type ConfigState = "in-config" | "not-in-config" | "preset-maybe" | "contradiction";
59
+ /** One documented-intent → off-the-shelf-rule finding. */
60
+ export interface RuleInventoryItem {
61
+ readonly intent: string;
62
+ readonly linter: LinterName;
63
+ /** The rule-name/token that matched in the instruction file. */
64
+ readonly matched: string;
65
+ /** The off-the-shelf rule that enforces it. */
66
+ readonly rule: string;
67
+ /** Whether `rule` appears anywhere in the provided config text. */
68
+ readonly configState: ConfigState;
69
+ /** The one-line config change to enforce it (shown when not in config). */
70
+ readonly configFix: string;
71
+ }
72
+ /** Options for {@link buildRuleInventory}. */
73
+ export interface RuleInventoryOptions {
74
+ /**
75
+ * Restrict to the repo's detected linter(s). When omitted, all linters are
76
+ * considered — safe because the keywords are rule-name-specific, but a caller
77
+ * that knows the repo is Python-only can pass `["ruff"]` to avoid a stray
78
+ * cross-language rule-name collision.
79
+ */
80
+ readonly linters?: readonly LinterName[];
81
+ }
82
+ /**
83
+ * A keyword matches only as a WHOLE token: bounded by start/end or a
84
+ * non-`[\w/@.-]` character on each side (so `no-console` matches in
85
+ * `` `no-console` `` and `enforce no-console;` but `no-console-x` does not,
86
+ * and prose containing the substring elsewhere never trips it).
87
+ */
88
+ export declare function matchesWholeToken(text: string, keyword: string): boolean;
89
+ export declare function buildRuleInventory(instructionText: string, configText: string, options?: RuleInventoryOptions): RuleInventoryItem[];
90
+ //# sourceMappingURL=rule-inventory.d.ts.map
@@ -0,0 +1,327 @@
1
+ "use strict";
2
+ /**
3
+ * Rule-inventory — the deterministic, foreign-safe teaser surface of the
4
+ * `audit` rule-compile tier (design: `research/audit-rule-compile-tier.md`).
5
+ *
6
+ * Finds prose rules in a `CLAUDE.md` / `AGENTS.md` that map to an off-the-shelf
7
+ * lint rule, and whether that rule already appears in the repo's lint config.
8
+ * The remedy for a documented-but-unconfigured intent is a one-line config
9
+ * change, not synthesis — so this is a cheap, high-value nudge.
10
+ *
11
+ * NO model. NO config execution (textual grep only — never resolves/executes
12
+ * `eslint.config.js`, which would be the RCE path). HIGH PRECISION by
13
+ * construction: only rule-name / code-token-shaped keywords are matched, and
14
+ * only as whole tokens. Bare prose words are excluded on purpose — a raw
15
+ * keyword match (`token`, `!`, `await`, `secret`, `silently`, …) sprays false
16
+ * positives on real instruction files (measured: 107 raw hits over 4 real
17
+ * CLAUDE.md files, ~all garbage). The model-driven analysis (extract → classify
18
+ * → compile → gate → run) lives in the OPT-IN tier, not here.
19
+ *
20
+ * MULTI-LINTER by shape, ESLint-first by data. The matcher is linter-agnostic;
21
+ * each mapping is keyed by linter, so adding Ruff / Clippy / Pylint / RuboCop /
22
+ * Stylelint is additive DATA (a curation task), not a refactor. The OPT-IN tier
23
+ * should resolve "is this rule enabled" via vigiles's existing multi-linter
24
+ * `checkLinterRule` engine (which execs the config) — kept out of this
25
+ * exec-free, foreign-safe surface on purpose.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.INTENT_MAP = void 0;
29
+ exports.matchesWholeToken = matchesWholeToken;
30
+ exports.buildRuleInventory = buildRuleInventory;
31
+ /**
32
+ * Curated from agent-rules-compiler's `rule-map.json`, keeping ONLY the
33
+ * specific (rule-name / code-token) keywords and dropping every bare-word
34
+ * trigger the FP measurement flagged (`token`, `secret`, `password`, `await`,
35
+ * `!`, `aria`, `silently`, `prefix`, `complexity`, `barrel`, `cycle`, …).
36
+ *
37
+ * ESLint-only today — Ruff/Clippy/Pylint/RuboCop/Stylelint entries append here
38
+ * with their own `linter` + rule-name keywords, no code change.
39
+ */
40
+ exports.INTENT_MAP = [
41
+ {
42
+ intent: "no console.log / use the logger",
43
+ linter: "eslint",
44
+ keywords: ["console.log", "no-console"],
45
+ rule: "no-console",
46
+ configFix: '"no-console": "error"',
47
+ },
48
+ {
49
+ intent: "no `any` type",
50
+ linter: "eslint",
51
+ keywords: ["no-explicit-any", "@typescript-eslint/no-explicit-any"],
52
+ rule: "@typescript-eslint/no-explicit-any",
53
+ inRecommended: true,
54
+ configFix: '"@typescript-eslint/no-explicit-any": "error"',
55
+ },
56
+ {
57
+ intent: "no eslint-disable / no linter suppressors",
58
+ linter: "eslint",
59
+ keywords: ["eslint-disable", "eslint-comments/no-use"],
60
+ rule: "eslint-comments/no-use",
61
+ configFix: "enable eslint-comments/no-use OR linterOptions.noInlineConfig: true",
62
+ },
63
+ {
64
+ intent: "no @ts-ignore / @ts-expect-error abuse",
65
+ linter: "eslint",
66
+ keywords: [
67
+ "@ts-ignore",
68
+ "ts-expect-error",
69
+ "ban-ts-comment",
70
+ "@typescript-eslint/ban-ts-comment",
71
+ ],
72
+ rule: "@typescript-eslint/ban-ts-comment",
73
+ inRecommended: true,
74
+ configFix: '"@typescript-eslint/ban-ts-comment": "error"',
75
+ },
76
+ {
77
+ intent: "no hardcoded secrets",
78
+ linter: "eslint",
79
+ keywords: ["no-secrets"],
80
+ rule: "no-secrets/no-secrets",
81
+ configFix: "add eslint-plugin-no-secrets rule no-secrets/no-secrets",
82
+ },
83
+ {
84
+ intent: "no empty catch / no swallowed errors",
85
+ linter: "eslint",
86
+ keywords: ["no-empty"],
87
+ rule: "no-empty",
88
+ inRecommended: true,
89
+ configFix: '"no-empty": ["error", {"allowEmptyCatch": false}]',
90
+ },
91
+ {
92
+ intent: "no var / prefer const-let",
93
+ linter: "eslint",
94
+ keywords: ["no-var", "prefer-const"],
95
+ rule: "no-var",
96
+ configFix: '"no-var": "error"',
97
+ },
98
+ {
99
+ intent: "strict equality ===",
100
+ linter: "eslint",
101
+ keywords: ["eqeqeq"],
102
+ rule: "eqeqeq",
103
+ configFix: '"eqeqeq": "error"',
104
+ },
105
+ {
106
+ intent: "template literals over concatenation",
107
+ linter: "eslint",
108
+ keywords: ["prefer-template"],
109
+ rule: "prefer-template",
110
+ configFix: '"prefer-template": "error"',
111
+ },
112
+ {
113
+ intent: "no debugger",
114
+ linter: "eslint",
115
+ keywords: ["no-debugger"],
116
+ rule: "no-debugger",
117
+ inRecommended: true,
118
+ configFix: '"no-debugger": "error"',
119
+ },
120
+ {
121
+ intent: "restricted / deprecated imports",
122
+ linter: "eslint",
123
+ keywords: ["no-restricted-imports", "import/no-restricted-paths"],
124
+ rule: "no-restricted-imports",
125
+ configFix: '"no-restricted-imports": ["error", {…}]',
126
+ },
127
+ {
128
+ intent: "no circular deps",
129
+ linter: "eslint",
130
+ keywords: ["import/no-cycle", "no-cycle"],
131
+ rule: "import/no-cycle",
132
+ configFix: '"import/no-cycle": "error"',
133
+ },
134
+ {
135
+ intent: "function length / complexity caps",
136
+ linter: "eslint",
137
+ keywords: [
138
+ "max-lines-per-function",
139
+ "max-depth",
140
+ "max-params",
141
+ "max-statements",
142
+ ],
143
+ rule: "max-lines-per-function",
144
+ configFix: '"max-lines-per-function": ["error", 40]',
145
+ },
146
+ {
147
+ intent: "no unused vars",
148
+ linter: "eslint",
149
+ keywords: ["no-unused-vars", "@typescript-eslint/no-unused-vars"],
150
+ rule: "@typescript-eslint/no-unused-vars",
151
+ inRecommended: true,
152
+ configFix: '"@typescript-eslint/no-unused-vars": "error"',
153
+ },
154
+ {
155
+ intent: "no non-null assertion",
156
+ linter: "eslint",
157
+ keywords: [
158
+ "no-non-null-assertion",
159
+ "@typescript-eslint/no-non-null-assertion",
160
+ ],
161
+ rule: "@typescript-eslint/no-non-null-assertion",
162
+ inRecommended: true,
163
+ configFix: '"@typescript-eslint/no-non-null-assertion": "error"',
164
+ },
165
+ {
166
+ intent: "no floating promises",
167
+ linter: "eslint",
168
+ keywords: [
169
+ "no-floating-promises",
170
+ "@typescript-eslint/no-floating-promises",
171
+ ],
172
+ rule: "@typescript-eslint/no-floating-promises",
173
+ inRecommended: true,
174
+ configFix: '"@typescript-eslint/no-floating-promises": "error"',
175
+ },
176
+ {
177
+ intent: "react hooks deps",
178
+ linter: "eslint",
179
+ keywords: ["react-hooks/exhaustive-deps", "exhaustive-deps"],
180
+ rule: "react-hooks/exhaustive-deps",
181
+ configFix: '"react-hooks/exhaustive-deps": "error"',
182
+ },
183
+ // Added from the hand-verified rule-adherence corpus (real repos: motion,
184
+ // mapbox) — both are documented in the wild and have an off-the-shelf rule.
185
+ {
186
+ intent: "no default exports",
187
+ linter: "eslint",
188
+ keywords: ["import/no-default-export", "no-default-export"],
189
+ rule: "import/no-default-export",
190
+ configFix: '"import/no-default-export": "error"',
191
+ },
192
+ {
193
+ intent: "no TODO / FIXME comments",
194
+ linter: "eslint",
195
+ keywords: ["no-warning-comments"],
196
+ rule: "no-warning-comments",
197
+ configFix: '"no-warning-comments": ["error", {"terms": ["todo", "fixme"], "location": "anywhere"}]',
198
+ },
199
+ ];
200
+ /** Escape a keyword for use inside a RegExp. */
201
+ function escapeRe(s) {
202
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
203
+ }
204
+ /**
205
+ * A keyword matches only as a WHOLE token: bounded by start/end or a
206
+ * non-`[\w/@.-]` character on each side (so `no-console` matches in
207
+ * `` `no-console` `` and `enforce no-console;` but `no-console-x` does not,
208
+ * and prose containing the substring elsewhere never trips it).
209
+ */
210
+ function matchesWholeToken(text, keyword) {
211
+ const re = new RegExp(`(^|[^\\w/@.-])${escapeRe(keyword)}([^\\w/@.-]|$)`, "i");
212
+ return re.test(text);
213
+ }
214
+ /**
215
+ * Build the deterministic rule inventory. Pure: caller passes the instruction
216
+ * file text (concatenated CLAUDE.md/AGENTS.md) and the lint config text (any
217
+ * `eslint.config.*` / `.eslintrc*` / `ruff.toml` / … contents concatenated, or
218
+ * "" if none). Returns one item per documented intent whose keyword resolves.
219
+ * `not-in-config` items are the actionable nudges.
220
+ */
221
+ /** ESLint re-exports some core rules under `@typescript-eslint/`; treat the base
222
+ * and scoped names as the same rule when checking the config text (so a repo that
223
+ * has base `no-unused-vars` satisfies the `@typescript-eslint/no-unused-vars`
224
+ * intent, and vice-versa). oxlint/biome use the SAME rule names, so once their
225
+ * config files are in the read set this handles them for free. */
226
+ function variantsOf(rule) {
227
+ const TS = "@typescript-eslint/";
228
+ if (rule.startsWith(TS))
229
+ return [rule, rule.slice(TS.length)];
230
+ if (!rule.includes("/"))
231
+ return [rule, TS + rule];
232
+ return [rule];
233
+ }
234
+ /** True if the rule (or a base/scoped variant) appears in the config text. */
235
+ function ruleInConfig(configText, rule) {
236
+ return variantsOf(rule).some((v) => matchesWholeToken(configText, v));
237
+ }
238
+ /**
239
+ * Whether the rule (or a variant) is EXPLICITLY disabled in the config text —
240
+ * `"no-console": "off"`, `no-console: 0`, `"no-console": ["off", …]`. Distinct
241
+ * from mere presence ({@link ruleInConfig}): a documented rule that the config
242
+ * turns OFF is a contradiction (docs say enforce, config disables), not an
243
+ * enforcement. Textual only — never resolves/executes the config (the RCE path).
244
+ * Conservative: matches only a literal off/0 severity right after the rule key,
245
+ * so a real `"error"`/`"warn"`/`1`/`2` never trips it.
246
+ */
247
+ function ruleSetOff(configText, rule) {
248
+ return variantsOf(rule).some((v) => {
249
+ const re = new RegExp(`["']?${escapeRe(v)}["']?\\s*:\\s*(?:\\[\\s*)?["']?(?:off|0)\\b`, "i");
250
+ return re.test(configText);
251
+ });
252
+ }
253
+ /** Index of the first WHOLE-token occurrence of `keyword` in `text` (the keyword
254
+ * itself, not the boundary char), or -1. */
255
+ function firstTokenIndex(text, keyword) {
256
+ const re = new RegExp(`(^|[^\\w/@.-])(${escapeRe(keyword)})([^\\w/@.-]|$)`, "i");
257
+ const m = re.exec(text);
258
+ return m ? m.index + m[1].length : -1;
259
+ }
260
+ /**
261
+ * Whether the matched mention is a documented opt-OUT ("`no-explicit-any` is off
262
+ * intentionally", "we disable X") rather than a norm to enforce. When the author
263
+ * deliberately turns a rule off, a not-in-config state is CONSISTENT, not a gap —
264
+ * nudging them to enable it is actively wrong advice (found dogfooding
265
+ * pmndrs/react-spring). Deterministic negation window around the matched token;
266
+ * conservative on purpose — only strong off/disable cues, so it never suppresses
267
+ * a genuine "enforce this" nudge. */
268
+ function isDocumentedOptOut(text, keyword) {
269
+ const idx = firstTokenIndex(text, keyword);
270
+ if (idx < 0)
271
+ return false;
272
+ // Test the context on EITHER SIDE of the token, never the token itself — else
273
+ // the cue `disable` would match inside the rule name `eslint-disable` and
274
+ // self-suppress every mention of it. Left/right windows are separate strings.
275
+ // NB: `disabled` (full word) not `disable` — `disable` would match inside the
276
+ // rule name `eslint-disable`, which can appear in a NEIGHBOURING token's window.
277
+ const CUE = /\b(?:off|disabled|not enforced|not enabled|turned off)\b/i;
278
+ const left = text.slice(Math.max(0, idx - 48), idx);
279
+ const right = text.slice(idx + keyword.length, idx + keyword.length + 48);
280
+ return CUE.test(left) || CUE.test(right);
281
+ }
282
+ /** A `recommended`-style preset extend anywhere in the config text — evidence
283
+ * that preset-enabled rules may already be on even when not named literally.
284
+ * Coarse on purpose: presence of a preset downgrades a not-named preset rule to
285
+ * `preset-maybe` (no false "unenforced" alarm) rather than claiming it's off. */
286
+ function extendsRecommended(configText) {
287
+ return /recommended/i.test(configText);
288
+ }
289
+ function buildRuleInventory(instructionText, configText, options = {}) {
290
+ const linters = options.linters;
291
+ const items = [];
292
+ for (const m of exports.INTENT_MAP) {
293
+ if (linters && !linters.includes(m.linter))
294
+ continue;
295
+ const matched = m.keywords.find((kw) => matchesWholeToken(instructionText, kw));
296
+ if (!matched)
297
+ continue;
298
+ const off = ruleSetOff(configText, m.rule);
299
+ const inConfig = ruleInConfig(configText, m.rule);
300
+ // A rule the author documents as deliberately OFF, and whose config agrees
301
+ // (absent, or literally set to off), is consistent — not a gap. Skip it so we
302
+ // never nudge "enable X" against an intentional opt-out, and never flag a
303
+ // documented opt-out as a contradiction.
304
+ if (isDocumentedOptOut(instructionText, matched) && (off || !inConfig)) {
305
+ continue;
306
+ }
307
+ // Precedence: an explicit off/0 is a contradiction even though the rule name
308
+ // is technically "in config" — so it must be checked before `in-config`.
309
+ const configState = off
310
+ ? "contradiction"
311
+ : inConfig
312
+ ? "in-config"
313
+ : m.inRecommended && extendsRecommended(configText)
314
+ ? "preset-maybe"
315
+ : "not-in-config";
316
+ items.push({
317
+ intent: m.intent,
318
+ linter: m.linter,
319
+ matched,
320
+ rule: m.rule,
321
+ configState,
322
+ configFix: m.configFix,
323
+ });
324
+ }
325
+ return items;
326
+ }
327
+ //# sourceMappingURL=rule-inventory.js.map