Add a linter to vigiles's cross-referencing engine (the enforce("eslint/...")
moat). This is a contributor task, not a third-party extension point: the
LINTERS registry is a Record<BuiltinLinter, LinterAdapter> — a closed
set baked into core — so a linter is added by editing vigiles itself, and the
type system + the conformance test make the parity un-forgettable.
The whole reason this skill exists: a linter used to be smeared across ~7
scattered sites (existence check, config checker, CLI-tool map, suggestion
enumerator, generate-types discoverer, docs, site) with nothing enforcing
that you touched all of them — miss one and it failed silently. Now tsc fails
if the registry entry is missing, and src/core/linter-contract.test.ts fails
if the docs table or the marketing site drifts. Follow the steps; let the
gates catch what you forget. See research/linter-adapter-architecture.md.
The one invariant
A linter is one LinterAdapter in one registry. Everything else —
existence, config-enabled, suggestions, type-gen, docs, site — is a field or a
method on that adapter, cross-checked by the conformance test. You never again
hunt for "the other place this linter is registered."
Steps
Work in this order — each step's gate tells you the next is needed.
Name it (the single source). Add the lowercase name to BUILTIN_LINTERS
in src/core/spec.ts. BuiltinLinter derives from this array, so the moment
you save, tsc fails on LINTERS in linters.ts with "property <name> is
missing" — that error is your to-do list.
Pick the existence-check kind (LinterCapabilities.existenceCheck in
src/core/linter-adapter.ts) — this decides which helper builds the adapter:
node-api — the rule set is resolved from an installed npm package
(eslint, stylelint). Use
nodeApiAdapter(name, resolver, configEnabled, discover).
cli — a real command asks the tool whether a rule exists (ruff, clippy,
pylint, rubocop, detekt, ktlint, checkstyle, golangci-lint). Use
cliAdapter(name, cliTool, checkExists, configEnabled, discover, enumerate?).
filesystem — presence in a project file counts, no tool (cedar). Write a
literal adapter (see cedar in linters.ts).
format-only — only the reference shape is validated, no tool exists
to list rules (ktlint's catalog is unlistable). Still a cli adapter, just
omit the enumerate arg; the existence check is the qualified-shape rule.
Implement the discoverer discover<Name>Rules(basePath): DiscoveredRules | null in linters.ts — reads the project's real linter config and returns
its enabled rules for generate-types (fail open: return null, never
flag every rule, when you can't enumerate). If it's a cli linter, also write
its <name>CheckExists existence probe (throws when the rule is unknown) and,
for a real config-enabled read, its <name>ConfigEnabled checker — plain
named functions the adapter references directly in the LINTERS registry
(there is no separate map to touch). Parse structured config with a real
parser (js-yaml / @iarna/toml / the shared markdown-it helper), never a
hand-rolled regex — see the parse-structured-input-with-a-real-parser rule;
detekt's parseDetektConfig (js-yaml) is the model.
Register it in LINTERS (linters.ts) via the matching helper. tsc
goes green here — the registry is now complete.
Document it — docs/linter-support.md: add a row to the
## Supported Linters table AND a ## <Linter> section (config conventions,
rule-prefix, any capability caveat like "format-only" or "whitelist-only").
The conformance test set-matches the table against the registry, so a missing
row fails CI.
The site updates itself — the vigiles.sh chip strip (Wedge.tsx) DERIVES
from BUILTIN_LINTERS, so a new linter appears automatically; there's no array
to edit. Optionally add a display label to LINTER_LABELS in Wedge.tsx if it
needs special casing (e.g. ESLint, RuboCop); with no entry it renders under
its lowercase name. The conformance test guards that the derivation stays in
place (a revert to a hand-typed list fails CI).
If it's a cli linter, make CI actually run it — no silent skips. The
real-binary tests are describe.skipIf(!hasBinary("<tool>")) in
src/core/linters.test.ts; a binary absent from CI means those tests skip
silently (a hidden gap — the no-silent-skips rule). Install the tool in the
test job of .github/workflows/ci.yml (pin a version via a job env, cache
it) AND add it to the command -v sanity loop so a missing binary fails the
build instead of skipping. Then write the two complementary tests: a
real-binary test (describe.skipIf(!hasBinary)) and a missing-binary
honest-error test (it.skipIf(hasBinary)) — one always runs, the pair is
loud either way.
Add the parity test data. The conformance loop in
src/core/linter-contract.test.ts is generic (it iterates the registry), so
it covers the new linter automatically — but add a targeted
config-parse/discover unit test in linters.test.ts for the new linter's own
parser, and a per-linter capability assertion if it has an unusual variance
(e.g. format-only, alwaysEnabled).
The gates that make this safe
Run npm test (or at least npx vitest run src/core/linter-contract.test.ts src/core/linters.test.ts + tsc --noEmit). You are done only when:
tsc is clean — the registry entry exists (completeness).
linter-contract.test.ts is green — key === name, every capability flag
matches its method's presence, existenceCheck === "cli" ⟺ cliTool present,
and the registry keys set-match BUILTIN_LINTERS and docs/linter-support.md
and the site chip list (docs + site parity).
- The new linter's config-parse unit test passes with no binary, and its
real-binary test runs in CI (installed + sanity-gated), not skipped.
1---2name: add-a-linter3description: Add a new linter to vigiles's cross-referencing engine as one cohesive, type-enforced unit — a LinterAdapter in the LINTERS registry, with the conformance test enforcing docs + site parity so no site is forgotten4---5
6Add a linter to vigiles's cross-referencing engine (the `enforce("eslint/...")`
7moat). This is a **contributor** task, not a third-party extension point: the
8`LINTERS` registry is a `Record<BuiltinLinter, LinterAdapter>` — a **closed
9set** baked into core — so a linter is added by editing vigiles itself, and the
10type system + the conformance test make the parity **un-forgettable**.
11
12The whole reason this skill exists: a linter used to be smeared across ~7
13scattered sites (existence check, config checker, CLI-tool map, suggestion
14enumerator, generate-types discoverer, docs, site) with **nothing** enforcing
15that you touched all of them — miss one and it failed silently. Now `tsc` fails
16if the registry entry is missing, and `src/core/linter-contract.test.ts` fails
17if the docs table or the marketing site drifts. **Follow the steps; let the
18gates catch what you forget.** See `research/linter-adapter-architecture.md`.
19
20## The one invariant
21
22A linter is **one `LinterAdapter`** in **one registry**. Everything else —
23existence, config-enabled, suggestions, type-gen, docs, site — is a field or a
24method on that adapter, cross-checked by the conformance test. You never again
25hunt for "the other place this linter is registered."
26
27## Steps
28
29Work in this order — each step's gate tells you the next is needed.
30
311. **Name it (the single source).** Add the lowercase name to `BUILTIN_LINTERS`
32 in `src/core/spec.ts`. `BuiltinLinter` derives from this array, so the moment
33 you save, `tsc` fails on `LINTERS` in `linters.ts` with "property `<name>` is
34 missing" — that error **is** your to-do list.
35
362. **Pick the existence-check kind** (`LinterCapabilities.existenceCheck` in
37 `src/core/linter-adapter.ts`) — this decides which helper builds the adapter:
38 - `node-api` — the rule set is resolved from an installed npm package
39 (eslint, stylelint). Use
40 `nodeApiAdapter(name, resolver, configEnabled, discover)`.
41 - `cli` — a real command asks the tool whether a rule exists (ruff, clippy,
42 pylint, rubocop, detekt, ktlint, checkstyle, golangci-lint). Use
43 `cliAdapter(name, cliTool, checkExists, configEnabled, discover, enumerate?)`.
44 - `filesystem` — presence in a project file counts, no tool (cedar). Write a
45 literal adapter (see cedar in `linters.ts`).
46 - `format-only` — only the reference **shape** is validated, no tool exists
47 to list rules (ktlint's catalog is unlistable). Still a `cli` adapter, just
48 omit the `enumerate` arg; the existence check is the qualified-shape rule.
49
503. **Implement the discoverer** `discover<Name>Rules(basePath): DiscoveredRules
51| null` in `linters.ts` — reads the project's real linter config and returns
52 its enabled rules for `generate-types` (fail **open**: return `null`, never
53 flag every rule, when you can't enumerate). If it's a `cli` linter, also write
54 its `<name>CheckExists` existence probe (throws when the rule is unknown) and,
55 for a real config-enabled read, its `<name>ConfigEnabled` checker — plain
56 named functions the adapter references directly in the `LINTERS` registry
57 (there is no separate map to touch). Parse structured config with a **real
58 parser** (js-yaml / @iarna/toml / the shared markdown-it helper), never a
59 hand-rolled regex — see the `parse-structured-input-with-a-real-parser` rule;
60 detekt's `parseDetektConfig` (js-yaml) is the model.
61
624. **Register it** in `LINTERS` (`linters.ts`) via the matching helper. `tsc`
63 goes green here — the registry is now complete.
64
655. **Document it** — `docs/linter-support.md`: add a **row** to the
66 `## Supported Linters` table AND a `## <Linter>` section (config conventions,
67 rule-prefix, any capability caveat like "format-only" or "whitelist-only").
68 The conformance test set-matches the table against the registry, so a missing
69 row fails CI.
70
716. **The site updates itself** — the vigiles.sh chip strip (`Wedge.tsx`) DERIVES
72 from `BUILTIN_LINTERS`, so a new linter appears automatically; there's no array
73 to edit. Optionally add a display label to `LINTER_LABELS` in `Wedge.tsx` if it
74 needs special casing (e.g. `ESLint`, `RuboCop`); with no entry it renders under
75 its lowercase name. The conformance test guards that the derivation stays in
76 place (a revert to a hand-typed list fails CI).
77
787. **If it's a `cli` linter, make CI actually run it — no silent skips.** The
79 real-binary tests are `describe.skipIf(!hasBinary("<tool>"))` in
80 `src/core/linters.test.ts`; a binary absent from CI means those tests skip
81 silently (a hidden gap — the `no-silent-skips` rule). Install the tool in the
82 `test` job of `.github/workflows/ci.yml` (pin a version via a job `env`, cache
83 it) AND add it to the `command -v` sanity loop so a missing binary **fails the
84 build** instead of skipping. Then write the two complementary tests: a
85 real-binary test (`describe.skipIf(!hasBinary)`) and a missing-binary
86 honest-error test (`it.skipIf(hasBinary)`) — one always runs, the pair is
87 loud either way.
88
898. **Add the parity test data.** The conformance loop in
90 `src/core/linter-contract.test.ts` is generic (it iterates the registry), so
91 it covers the new linter automatically — but add a targeted
92 config-parse/discover unit test in `linters.test.ts` for the new linter's own
93 parser, and a per-linter capability assertion if it has an unusual variance
94 (e.g. `format-only`, `alwaysEnabled`).
95
96## The gates that make this safe
97
98Run `npm test` (or at least `npx vitest run src/core/linter-contract.test.ts
99src/core/linters.test.ts` + `tsc --noEmit`). You are done only when:
100
101- **`tsc`** is clean — the registry entry exists (completeness).
102- **`linter-contract.test.ts`** is green — key === `name`, every capability flag
103 matches its method's presence, `existenceCheck === "cli"` ⟺ `cliTool` present,
104 and the registry keys set-match `BUILTIN_LINTERS` **and** `docs/linter-support.md`
105 **and** the site chip list (docs + site parity).
106- The new linter's config-parse unit test passes with **no binary**, and its
107 real-binary test runs in CI (installed + sanity-gated), not skipped.