Skip to content

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 ARGO001ARGO005 are the worked example: they lint the Argo Application kind, which arrives through a CRD source, not hand-written types.

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).

plugin.ts
import { argoAutomatedPruneRule } from "./lint/rules/argo-automated-prune";
lintRules(): LintRule[] {
return [argoAutomatedPruneRule /* , ...others */];
}

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 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 PostSynthDiagnostic missing.
  • A rule’s catalog entry can carry aliases/deprecated so a renamed or retired id keeps resolving, covered under Aliases and deprecated.
  • 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.

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.

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.

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.

With rules in place, add LSP & MCP providers for editor integration.