Write Lint Rules
Lexicons can contribute three kinds of validation: imperative rules (TypeScript compiler API), declarative rules (pattern matching DSL), and post-synth checks (validate serialized output).
Rules target a resource kind by name, so a rule works the same whether the kind is a built-in resource or one generated from an imported CRD. The k8s lexicon’s ARGO001–ARGO005 are the worked example: they lint the Argo Application kind, which arrives through a CRD source, not hand-written types.
Imperative Rules
Section titled “Imperative Rules”Imperative rules use the TypeScript compiler API to inspect AST nodes:
import type { LintRule, LintContext, LintDiagnostic } from "@intentius/chant";
export const myRule: LintRule = { id: "MYD001", severity: "warning", category: "style", description: "Example custom rule",
check(context: LintContext): LintDiagnostic[] { // Your rule logic here return []; },};LintContext (packages/core/src/lint/rule.ts) carries sourceFile, entities, filePath, and the optional lexicon, intrinsics and projectConfig. Each LintDiagnostic you return needs file, line, column, ruleId, severity and message, plus an optional fix.
Wire imperative and declarative rules by importing them into the lintRules() array in your plugin.ts. They are not auto-discovered, unlike post-synth checks (see Post-Synth Checks).
import { argoAutomatedPruneRule } from "./lint/rules/argo-automated-prune";
lintRules(): LintRule[] { return [argoAutomatedPruneRule /* , ...others */];}Declarative Rules
Section titled “Declarative Rules”Declarative rules use the rule() builder for common patterns:
import type { RuleSpec } from "@intentius/chant";
const spec: RuleSpec = { id: "MYD002", severity: "warning", category: "style", description: "Resource names must be lowercase", selector: "resource > property", match: { pattern: /^[A-Z]/ }, message: "Property name '{node}' should be lowercase",};Return these from your plugin’s declarativeRules() method.
RuleSpec is defined in packages/core/src/lint/declarative.ts. selector is a
selector name or a compound "parent > child" expression resolved by
resolveSelector (packages/core/src/lint/selectors.ts); match filters the
selected nodes by pattern, within, require or a named check; message
substitutes {node} with the node’s text.
Post-synth Checks
Section titled “Post-synth Checks”Post-synth checks validate the serialized output after the build pipeline completes:
import type { PostSynthCheck, PostSynthDiagnostic } from "@intentius/chant";
const myCheck: PostSynthCheck = { id: "MYD010", description: "Check output constraints",
check(context): PostSynthDiagnostic[] { // Validate the serialized output return []; },};Drop the file in src/lint/post-synth/, run npm run generate:barrels from the
repo root, and the committed barrel src/lint/post-synth/index.ts picks it up.
Your plugin’s postSynthChecks() returns that barrel. See
Post-Synth Checks.
Four more things a post-synth check or its catalog entry can do (chant #2113):
- A diagnostic can report an absence rather than a defect, via the optional
missing: { kind, scope }field, covered under PostSynthDiagnosticmissing. - A rule’s catalog entry can carry
aliases/deprecatedso a renamed or retired id keeps resolving, covered under Aliases anddeprecated. - A check reading a loosely-typed parsed body (HCL or a similar format) can
read attributes through a
{kind, value, refs, raw}value envelope instead of re-deriving “is this a reference” by hand, covered under the value-envelope section. - A lexicon with enough rules to need a “the good ones” subset exports one
via
lintPresets(), covered under Presets for the lexicon side and Rule Configuration for how a project selects one.
Rule ID Conventions
Section titled “Rule ID Conventions”A rule id is {PREFIX}{NUMBER}. The prefix is your serializer’s rulePrefix
(packages/core/src/serializer.ts:83), and chant dev check-lexicon fails at
tier 1 on any rule or check id that does not start with it or with one of the
serializer’s extraRulePrefixes. k8s declares rulePrefix: "WK8" plus
extraRulePrefixes: ["ARGO", "FLUX"] (lexicons/k8s/src/serializer.ts:223);
cedar declares "CED" plus ["DWD"]. Core’s own cross-cutting ids (COR*,
EXT*) are exempt.
The number is three digits. Most lexicons group families by the hundreds digit
rather than by a letter: k3s uses K3S001 for its source-level rule and
K3S101-K3S107 for its post-synth checks, gcp uses WGC001-WGC003 then
WGC1xx through WGC5xx, and github runs a flat GHA001-GHA068.
A category letter after the prefix is a local convention in two lexicons, not a
rule the tooling enforces. cedar splits CEDC (correctness), CEDE
(evaluability) and CEDS (style); docker splits DKRD and DKRS. Copy it only
if you want it; nothing checks for it, and fourteen lexicons do without.
Testing
Section titled “Testing”Post-synth checks
Section titled “Post-synth checks”Every post-synth check needs both positive and negative tests. Build the context
with makePostSynthCtx from @intentius/chant-test-utils, which wires up
outputs, entities, buildResult and the lazy docs accessor the same way a
real build does:
import { describe, test, expect } from "vitest";import { makePostSynthCtx } from "@intentius/chant-test-utils";import { myCheck } from "./my-check";
describe("MYD010: description", () => { test("flags bad pattern", () => { const diags = myCheck.check(makePostSynthCtx("my-lexicon", `...bad yaml...`)); expect(diags.length).toBeGreaterThanOrEqual(1); expect(diags[0].checkId).toBe("MYD010"); });
test("no diagnostic when correct", () => { const diags = myCheck.check(makePostSynthCtx("my-lexicon", `...good yaml...`)); expect(diags).toHaveLength(0); });});Sibling helpers cover the other output shapes: makePostSynthCtxFromJSON for a
JSON template, makePostSynthCtxFromFiles for a multi-file SerializerResult.
All three take an optional entities map as their last argument.
Imperative and declarative rules
Section titled “Imperative and declarative rules”Test an imperative rule by building a LintContext around a ts.SourceFile and
asserting on the diagnostics. lexicons/k3s/src/lint/rules/token-literal.test.ts
is the smallest worked example:
import * as ts from "typescript";import type { LintContext } from "@intentius/chant/lint/rule";
function createContext(code: string, fileName = "cluster.ts"): LintContext { const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true); return { sourceFile, entities: [], filePath: fileName };}
const diags = myRule.check(createContext(`const s = new Server({ token: "literal" });`));expect(diags[0].ruleId).toBe("MYD001");Test a declarative rule the same way, against the LintRule that rule(spec)
returns.
See Testing Your Lexicon for the full testing guide, including patterns for all test file types.
Next Steps
Section titled “Next Steps”With rules in place, add LSP & MCP providers for editor integration.