Post-Synth Check Guide
Post-synth checks validate the serialized output after the build pipeline completes. They catch issues in the generated templates that pre-synth rules (which operate on TypeScript AST) cannot detect.
Anatomy of a PostSynthCheck
Section titled “Anatomy of a PostSynthCheck”import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic,} from "@intentius/chant/lint/post-synth";
export const myCheck: PostSynthCheck = { id: "MYD010", description: "Human-readable description of what this check validates",
check(ctx: PostSynthContext): PostSynthDiagnostic[] { const diagnostics: PostSynthDiagnostic[] = [];
for (const [_lexicon, output] of ctx.outputs) { // Parse the output (JSON, YAML, etc.) // Iterate resources // Push diagnostics for violations }
return diagnostics; },};PostSynthContext
Section titled “PostSynthContext”The ctx parameter provides:
| Member | Type | What it holds |
|---|---|---|
ctx.outputs | Map<string, string | SerializerResult> | serialized output per lexicon |
ctx.entities | Map<string, Declarable> | every declared entity |
ctx.docs | readonly OutputDoc[] | undefined | ctx.outputs already parsed, lazily and once per run. See below |
ctx.env | string | undefined | the environment or stack being built, from --env or the project’s ownership.env. Undefined when none is set. Lets an organizational policy branch on environment |
ctx.buildResult | object | outputs, entities, warnings, errors, sourceFileCount |
The definitions are in packages/core/src/lint/post-synth.ts.
ctx.docs — the parsed-output view (chant #975)
Section titled “ctx.docs — the parsed-output view (chant #975)”ctx.outputs is raw serialized text. Reasoning about structure — “does this
manifest set hostNetwork: true”, “does this CFN resource have an
Encrypted property” — means parsing it first, and every check that reaches
for ctx.outputs directly reinvents that parse. ctx.docs is the shared,
parse-once alternative: ctx.outputs run through parseOutputDocs once per
build and cached, so every check reads the same array instead of re-parsing.
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
interface K8sManifest { kind?: string; metadata?: { name?: string; labels?: Record<string, string> };}
export const myCheck: PostSynthCheck = { id: "MYD020", description: "every Deployment carries a team label", check(ctx: PostSynthContext): PostSynthDiagnostic[] { return (ctx.docs ?? []) .filter((d) => !d.error && (d.value as K8sManifest).kind === "Deployment") .filter((d) => !(d.value as K8sManifest).metadata?.labels?.team) .map((d) => ({ checkId: "MYD020", severity: "warning", message: `Deployment "${(d.value as K8sManifest).metadata?.name}" has no team label`, })); },};Each OutputDoc is:
interface OutputDoc { lexicon: string; // the ctx.outputs map key this document came from index: number; // position within a multi-document source (0 for a single doc) format: "yaml" | "json"; value: unknown; // the parsed tree file?: string; // set when this came from a SerializerResult.files entry error?: string; // set when parsing failed — value is undefined; see below}Format detection is whole-source: if an output’s entire text parses as JSON
it is one "json" document; otherwise it is treated as a (possibly
multi-document) YAML stream, split on --- separators, with one OutputDoc
per document. SerializerResult.files entries (a nested CloudFormation
stack template, a sidecar manifest) are parsed the same way and tagged with
file.
Malformed documents are never thrown — a document parseOutputDocs
cannot make sense of (invalid syntax, or content that parses to a bare
scalar rather than an object/array) is still returned, with error set and
value: undefined, rather than crashing the whole build or silently
vanishing. Filter with .filter((d) => !d.error) to work with only the
usable documents, the way the example above does.
Two small helpers round out the ,remain-style ergonomic — decode the
fields you care about, leave the rest untouched and unvalidated:
import { pick, get } from "@intentius/chant/lint/post-synth";
// Deep walk to a nested field, without throwing on a missing intermediate key.const podSpec = get(doc.value, "spec.template.spec");
// A shallow, typed, partial view of the top-level fields you name.const { kind, metadata } = pick<K8sManifest>(doc.value, ["kind", "metadata"]);pick/get are deliberately not a query DSL or JSONPath engine — no
wildcards, no predicates. Selector languages over the source AST belong in
declarative.ts; this is output-side, and stays small.
ctx.docs is undefined-typed only for lexicon test files that build a
PostSynthContext object literal by hand and predate this field — every
context chant itself constructs (chant build, chant lint, policyGate,
the sandboxed policy runner, and @intentius/chant-test-utils’s
createPostSynthContext/makePostSynthCtx/makePostSynthCtxFromFiles)
populates it, so ctx.docs ?? [] is a defensive habit, not something you
should expect to hit in a normal build.
PostSynthDiagnostic
Section titled “PostSynthDiagnostic”Each diagnostic requires:
{ checkId: "MYD010", // Matches the check ID severity: "warning", // "warning" | "error" message: "Human-readable message with resource name", entity: "resourceName", // Optional: the resource that triggered it lexicon: "my-lexicon", // Optional: lexicon identifier missing: { kind: "backend", scope: "root-name" }, // Optional, see below}missing, the finding is an absence, not a defect (chant #2113)
Section titled “missing, the finding is an absence, not a defect (chant #2113)”Most checks flag something present and wrong, misconfigured or invalid. Some
checks flag the opposite. Nothing is there at all. The terraform lexicon’s
TF001 is the example. A root module with no backend/cloud block has
nothing to set entity to, because the thing that’s wrong is that a block
doesn’t exist.
Snyk’s policy-engine spec names this the missing-resource archetype, a
policy whose info carries a resource_type instead of a resource,
“since we don’t have one.” It’s the only tool in a 2026 survey of Terraform
lint/scan tools with a first-class shape for it. trivy’s own inline-ignore
comments admit they cannot suppress an absence finding, for exactly this
reason.
{ checkId: "TF001", severity: "warning", message: 'Root module "prod" declares no remote backend.', entity: "prod/terraform", // the terraform block this fired from, by convenience missing: { kind: "backend", scope: "prod" }, // what's missing, and where}Set missing alongside entity, not instead of it, when a check has some
convenient anchor to report. TF001’s entity still names the terraform
block it walked to reach the verdict. That block isn’t what’s wrong, but
it’s where the check happened to be looking. kind names what’s absent
("backend"). scope names where it’s absent from, typically a root module
name or a file. Every reporter (stylish, JSON, SARIF) renders missing
distinctly instead of falling back to entity. It also gives a
block-anchored suppression (a separate change) something to key an absence
finding on.
Category Taxonomy
Section titled “Category Taxonomy”Organize checks into categories for clarity:
Security
Section titled “Security”Encryption, TLS, HTTPS, access control, identity configuration.
// Example: Missing encryptionif (!props.encryption) { diagnostics.push({ checkId: "MYD015", severity: "warning", message: `Resource "${name}" has no encryption — enable encryption to protect data at rest`, });}Correctness
Section titled “Correctness”Required fields, valid values, correct dependencies.
// Example: Missing required fieldif (!resource.apiVersion) { diagnostics.push({ checkId: "MYD011", severity: "error", message: `Resource "${name}" is missing apiVersion`, });}Best Practices
Section titled “Best Practices”Naming conventions, tagging/labeling, resource configuration.
// Example: Redundant dependencyif (propertyRefs.has(depName)) { diagnostics.push({ checkId: "MYD010", severity: "warning", message: `Resource "${name}" has redundant dependency on "${depName}"`, });}Deprecation
Section titled “Deprecation”Outdated API versions, legacy features.
// Example: Old API versionif (apiDate < deprecationThreshold) { diagnostics.push({ checkId: "MYD012", severity: "warning", message: `Resource "${name}" uses outdated apiVersion`, });}Testing Pattern
Section titled “Testing Pattern”Every check should have both a positive (flagged) and negative (clean) test:
import { describe, test, expect } from "vitest";import { createPostSynthContext } from "@intentius/chant-test-utils";import { myCheck } from "./my-check";
describe("MYD010: My Check", () => { test("flags when condition is violated", () => { const ctx = createPostSynthContext({ "my-lexicon": { /* template with violation */ }, }); const diags = myCheck.check(ctx); expect(diags).toHaveLength(1); expect(diags[0].checkId).toBe("MYD010"); });
test("passes when condition is satisfied", () => { const ctx = createPostSynthContext({ "my-lexicon": { /* template without violation */ }, }); const diags = myCheck.check(ctx); expect(diags).toHaveLength(0); });});createPostSynthContext always sets entities to an empty Map
(packages/test-utils/src/fixtures.ts:95), so a check that reads ctx.entities
will see nothing and pass vacuously. For those, reach for makePostSynthCtx from
the same package. It takes an entity map as its third argument. Building the
PostSynthContext literal by hand also works.
Cross-resource (bundle-join) checks
Section titled “Cross-resource (bundle-join) checks”The examples above judge one resource in isolation. The higher-value checks judge a resource against the rest of the bundle — a reference that must resolve, an owner that must exist, a name that must be unique across the output. The shape is always the same: collect every manifest of one kind, then validate each manifest of another kind against that set.
ARGO003 in the k8s lexicon (lint/post-synth/argo003.ts) is the reference. It gathers the registered cluster Secrets, then checks that every Argo Application targets one of them:
import { allManifests, manifestsOfKind, isClusterSecret, secretField } from "./argo-helpers";
check(ctx: PostSynthContext): PostSynthDiagnostic[] { const diagnostics: PostSynthDiagnostic[] = []; const manifests = allManifests(ctx);
// Pass 1 — collect the set to validate against. const registered = new Set<string>(); for (const secret of manifests.filter(isClusterSecret)) { const name = secretField(secret, "name") ?? secret.metadata?.name; if (typeof name === "string") registered.add(name); }
// Pass 2 — validate each consumer against that set. for (const app of manifestsOfKind(manifests, "Application")) { const target = app.spec?.destination?.name; if (typeof target === "string" && !registered.has(target)) { diagnostics.push({ checkId: "ARGO003", severity: "error", message: `Application "${app.metadata?.name}" targets unregistered cluster "${target}".`, entity: app.metadata?.name, lexicon: "k8s", }); } } return diagnostics;}The helpers do the parsing so the check stays about the rule, not YAML wrangling:
| Helper | Returns |
|---|---|
allManifests(ctx) | every parsed manifest across all lexicon outputs |
manifestsOfKind(manifests, kind) | the manifests of one kind |
docsToManifests(ctx) | the ctx.docs → K8sManifest[] bridge, from k8s-helpers.ts |
Keep helpers in a *-helpers.ts file — the barrel generator skips them (they export no check), so a shared-utility module never registers as a rule.
This join is the pattern behind conformance checks generally — a custom resource against the schema that defines it, a composition against the kinds it references. Any check that needs to know about other resources in the output is a post-synth check, and this is its shape.
One build root at a time (chant #1939)
Section titled “One build root at a time (chant #1939)”A bundle-join check only sees the build root of the current chant build invocation, since ctx.outputs, ctx.docs and ctx.entities are all scoped to that one invocation. Split the two halves of a join across separate build roots and the check goes silent rather than wrong, because a PostSynthContext has no way to tell “wired up in the other build” apart from “forgotten entirely.”
The k8s lexicon’s WK8505 (lint/post-synth/wk8505.ts) shows why this matters. It warns when a committed-encrypted secret has no Flux Kustomization setting spec.decryption, and that join needs the secret and the Kustomization declared in the same build root. A project that splits workload and Flux wiring into separate roots, a common shape as a project grows, gets no warning even when the decryption wiring is genuinely missing.
Chant #1939 tracks whether checks like this should get an opt-in project-level mode that runs across every build root. Until that lands, a check that joins across resources stays single-build-root-scoped by construction, and an author has two options today: keep the resources a check joins across in the same build root, or accept that the check will not fire once those resources live apart.
Universal Check Patterns
Section titled “Universal Check Patterns”These patterns apply to most IaC formats:
| Pattern | Description |
|---|---|
| Missing encryption | Data at rest should be encrypted |
| Overly permissive access | Wildcard rules, public access, admin credentials |
| Deprecated versions | Old API versions, deprecated features |
| Missing identity/auth | No managed identity, no RBAC, shared credentials |
| Public access enabled | Resources exposed to the internet unintentionally |
| Missing diagnostics | No logging, monitoring, or audit trail |
| Missing TLS/HTTPS | Unencrypted transport |
| Missing network isolation | No NSG, no network policy, no firewall rules |
A value envelope over a loosely-typed body (chant #2113)
Section titled “A value envelope over a loosely-typed body (chant #2113)”A check that reads a raw parsed body (hcl2json output, a YAML/JSON document
via ctx.docs) usually needs to know more than “is this key present.” Is
the value a plain literal, or an expression that references something else
that isn’t known statically? Re-deriving that by hand in every check is how
you end up with a dozen slightly different regexes for “is this a reference.”
tflint’s OPA ruleset hands a policy {value, unknown, sensitive, ephemeral, range} per attribute for exactly this reason. One accessor, read uniformly,
instead of every rule re-deriving it. The terraform lexicon’s attr()
(src/hcl/value.ts) is chant’s version of the same idea, scoped to what a
static JSON-round-tripped parse (no live evaluation, no position data) can
actually answer:
import { attr } from "./hcl/value";
const backend = attr(body, "backend");// { kind: "literal" | "reference" | "template" | "absent", value?, refs?, raw }
if (backend.kind === "reference") { // backend.refs[0] is the expression inside `${...}`}kind is "absent" when the key isn’t present, "literal" for a plain
value (a nested block hcl2json encodes the same way counts too),
"reference" when the whole value is one ${...} interpolation, and
"template" when interpolation mixes with literal text. raw is always the
untouched source value. A check that needs something this envelope doesn’t
model can still reach for it directly.
The lesson generalizes past HCL. Any lexicon parsing a format where “is this attribute a literal or does it point somewhere else” comes up more than once across its checks is a candidate for the same small accessor, instead of each check answering that question its own way.
Wiring Checks into Plugin
Section titled “Wiring Checks into Plugin”Checks are discovered by directory, not registered by hand. Any file in lint/post-synth/ that exports a PostSynthCheck is picked up automatically — the generator scans the directory and emits a committed barrel (lint/post-synth/index.ts, marked DO NOT EDIT) with explicit static imports, and the plugin’s postSynthChecks() returns that barrel:
import { postSynthChecks as postSynthCheckList } from "./lint/post-synth";// ...postSynthChecks() { return postSynthCheckList;}So adding a check is two steps: drop the file, then regenerate the barrel.
npm run generate:barrels # scripts/generate-post-synth-barrels.ts, all lexiconsnpm run generate at the repo root ends with the same step, and each lexicon’s
prepack runs its own generate. Files that export no check (helpers) are
skipped, so name shared utilities *-helpers.ts and keep them beside the checks.
Detection reuses the same isPostSynthCheck predicate the runtime uses, so the
barrel contains exactly what discovery would.
Pre-synth rules (imperative and declarative) are the exception — they are not auto-discovered. Import them into the
lintRules()array in yourplugin.ts. See Write Lint Rules.
Audit metadata — auditCatalog()
Section titled “Audit metadata — auditCatalog()”A check’s own definition says what it looks for. It does not say how severe a
finding is, whether a fix is mechanical, what authority backs the rule, or what
to title it in a report. chant audit needs all four, and gets them from the
lexicon’s auditCatalog() — a map from check id to RuleMeta, merged over
core’s small static catalog by resolveAuditCatalog:
import { auditRule, type Authority, type RuleMeta } from "@intentius/chant/audit/catalog";
const CIS_BENCHMARK: Authority = { name: "CIS Benchmark 2.1.1", url: "https://www.cisecurity.org/benchmark/",};
export const myAuditCatalog: Record<string, RuleMeta> = { XYZ010: auditRule( "XYZ010", "merge-worthy", // tier: "merge-worthy" | "report-only" "guidance", // fixKind: "deterministic" | "guidance" "Bucket allows public reads", "Set `publicAccess: false` on the bucket.", { authority: [CIS_BENCHMARK], category: "security" }, ),};
// plugin.tsauditCatalog() { return myAuditCatalog;},opts.category is one of security, correctness, best-practice,
efficiency, and defaults to best-practice. Passing any authority forces
security regardless of what you pass for category
(packages/core/src/audit/catalog.ts:362). Core ships reusable Authority
constants (K8S_PSS, SCORECARD_PINNED, GH_SECRETS, and others) from the same
module; reach for one before writing your own.
Contributing nothing is not neutral. resolveAuditCatalog skips a lexicon that
omits the method silently, so every one of its findings reaches the report
with no title, tier, fix kind, or category — which is how fly’s and render’s
six checks surfaced before chant #1346. chant dev check-lexicon warns at tier
2 when a shipped check has no entry, and a repo-level test enumerates every
lexicon and fails on a gap in either direction.
Two details worth knowing:
auditRule()hardcodesyamlBased: true. A check that reads the chant model (ctx.entities) rather than the emitted output (ctx.outputs) cannot fire on an audit of standalone YAML, so it must be constructed directly withyamlBased: false. cpln, fly, fountain, k3s and render do this, each with a small local constructor at the top of itsaudit-catalog.ts. The exception: a lexicon that shipsauditEntities(parse-to-graph) has its classified files parsed back into the entity graph during an audit, so its entity-reading checks do fire and stayyamlBased: true— fountain does this; only its source-level lint rule (FTN001) isyamlBased: false.- Cross-cutting core ids (
COR*,EXT*) belong to core’s static catalog, not to any lexicon, and are excluded from both the check and the test.
Aliases and deprecated (chant #2113)
Section titled “Aliases and deprecated (chant #2113)”A rule id is public API the day it ships. A project’s lint.rules, a
suppression comment, someone’s dashboard, all name it. trivy’s checks carry
id, long_id, aliases and a deprecated: true field for exactly this
reason. A rule can be renamed, or retired, without every existing reference
to its old id going dark. RuleMeta carries the same two fields:
export const myAuditCatalog: Record<string, RuleMeta> = { XYZ011: { ...auditRule("XYZ011", "merge-worthy", "guidance", "Bucket allows public reads", "Set `publicAccess: false`."), aliases: ["XYZ010"], // the id this rule used to ship as }, XYZ099: { ...auditRule("XYZ099", "report-only", "guidance", "Retired check", "No longer applicable, superseded by XYZ011."), deprecated: "superseded by XYZ011", // or `true`, with no reason string },};canonicalRuleId(id, catalog) (@intentius/chant/audit/catalog) resolves an
alias to the id it belongs to. A canonical or unrecognized id passes through
unchanged. A lint.rules entry that names an alias resolves through it too.
Pass a resolved catalog as resolveConfiguredSeverity’s (and
applyConfiguredSeverity’s) optional fourth argument; see
packages/core/src/lint/config.ts. The generated rules
reference lists a rule’s aliases and greys
out a deprecated one instead of dropping it from the page. A deprecated id
stays in the catalog even after its check stops shipping (the catalog drift
test’s “no stale entries” check exempts a deprecated entry for this
reason), so old references keep resolving to an explanation instead of
silently matching nothing.
Presets, lintPresets() (chant #2113)
Section titled “Presets, lintPresets() (chant #2113)”A family of more than a handful of rules eventually needs a “the good ones”
subset distinct from “every rule we ship.” tflint-ruleset-terraform ships
recommended (13 of its 20) and all for exactly this. A lexicon opts in by
adding lintPresets() to its plugin, returning a preset name mapped to the
check ids that preset enables:
lintPresets() { return { recommended: Object.keys(myAuditCatalog).filter((id) => myAuditCatalog[id].tier === "merge-worthy"), all: Object.keys(myAuditCatalog), };},Deriving both from the catalog, as above, rather than hand-keeping two id
lists, means a new rule’s preset membership is decided once, where its tier
already lives. A project picks a preset through the presets field under
lint in its config, keyed by lexicon name. The Rule
Configuration page
covers the project-facing side, including the default and the precedence
against lint.rules. A lexicon that omits lintPresets() is unaffected.
Every one of its findings is reported regardless of lint.presets, same as
before this existed.
Prior art in audit-lineage.ts
Section titled “Prior art in audit-lineage.ts”Most of what an audit rule checks was checked first by a dedicated open-source
tool, and chant credits that tool per rule. The credits live in a sibling
lint/audit-lineage.ts, one Record<ruleId, Lineage[]>, attached at the bottom
of the catalog with applyLineage:
import type { Lineage } from "@intentius/chant/audit/catalog";
export const myAuditLineage: Record<string, Lineage[]> = { XYZ010: [ { tool: "checkov", rule: "CKV_AWS_53", url: "https://www.checkov.io/5.Policy%20Index/cloudformation.html", relation: "overlaps" }, ],};
// lint/audit-catalog.ts, after the catalogapplyLineage(myAuditCatalog, myAuditLineage);tool must be a key of PRIOR_ART in packages/core/src/audit/prior-art.ts.
That registry names and links each tool once and records its licence and kind
(scanner, vendor-validator or specification), so add a new tool there
before crediting it. rule is optional, for a tool with no per-rule ids.
relation is equivalent, overlaps or extends, defined in the same file.
applyLineage throws at module load if the lineage names an id the catalog does
not define, so a renamed rule fails fast instead of silently losing its credit.
Fourteen lexicons ship an audit-lineage.ts;
lexicons/k3s/src/lint/audit-lineage.ts is a short one to copy.
Lineage is credit rather than authority. It never changes a rule’s tier or category. A rule can have lineage without an authority, or the reverse. The credits render on the audit rules reference and ride along in the JSON and SARIF reports.
npm run sweep:prior-art # scripts/prior-art-sweep.ts, against the committed snapshotnpm run sweep:prior-art -- --update-snapshotThe sweep fetches each credited tool’s published rule index and reports three
things: ids new upstream since the last snapshot, ids gone upstream, and a
credit pointing at a rule the index no longer lists. Because it reads the
network, the sweep runs as a scheduled workflow
(.github/workflows/prior-art-sweep.yml) rather than as a test, and it only
ever reports. No lineage file is edited for you.
Target Count
Section titled “Target Count”A mature lexicon should have at least 15 post-synth checks covering all four
categories (security, correctness, best practices, deprecation). The largest
sets today are aws with 61 and github with 55; k8s ships 40 and azure 24. chant dev check-lexicon fails at tier 1 if postSynthChecks() returns none at all,
and warns at tier 2 below fifteen.