Skip to content

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.

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;
},
};

The ctx parameter provides:

MemberTypeWhat it holds
ctx.outputsMap<string, string | SerializerResult>serialized output per lexicon
ctx.entitiesMap<string, Declarable>every declared entity
ctx.docsreadonly OutputDoc[] | undefinedctx.outputs already parsed, lazily and once per run. See below
ctx.envstring | undefinedthe 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.buildResultobjectoutputs, 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.

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.

Organize checks into categories for clarity:

Encryption, TLS, HTTPS, access control, identity configuration.

// Example: Missing encryption
if (!props.encryption) {
diagnostics.push({
checkId: "MYD015",
severity: "warning",
message: `Resource "${name}" has no encryption — enable encryption to protect data at rest`,
});
}

Required fields, valid values, correct dependencies.

// Example: Missing required field
if (!resource.apiVersion) {
diagnostics.push({
checkId: "MYD011",
severity: "error",
message: `Resource "${name}" is missing apiVersion`,
});
}

Naming conventions, tagging/labeling, resource configuration.

// Example: Redundant dependency
if (propertyRefs.has(depName)) {
diagnostics.push({
checkId: "MYD010",
severity: "warning",
message: `Resource "${name}" has redundant dependency on "${depName}"`,
});
}

Outdated API versions, legacy features.

// Example: Old API version
if (apiDate < deprecationThreshold) {
diagnostics.push({
checkId: "MYD012",
severity: "warning",
message: `Resource "${name}" uses outdated apiVersion`,
});
}

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.

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:

HelperReturns
allManifests(ctx)every parsed manifest across all lexicon outputs
manifestsOfKind(manifests, kind)the manifests of one kind
docsToManifests(ctx)the ctx.docsK8sManifest[] 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.

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.

These patterns apply to most IaC formats:

PatternDescription
Missing encryptionData at rest should be encrypted
Overly permissive accessWildcard rules, public access, admin credentials
Deprecated versionsOld API versions, deprecated features
Missing identity/authNo managed identity, no RBAC, shared credentials
Public access enabledResources exposed to the internet unintentionally
Missing diagnosticsNo logging, monitoring, or audit trail
Missing TLS/HTTPSUnencrypted transport
Missing network isolationNo 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.

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:

plugin.ts
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.

Terminal window
npm run generate:barrels # scripts/generate-post-synth-barrels.ts, all lexicons

npm 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 your plugin.ts. See Write Lint Rules.

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:

lint/audit-catalog.ts
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.ts
auditCatalog() {
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() hardcodes yamlBased: 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 with yamlBased: false. cpln, fly, fountain, k3s and render do this, each with a small local constructor at the top of its audit-catalog.ts. The exception: a lexicon that ships auditEntities (parse-to-graph) has its classified files parsed back into the entity graph during an audit, so its entity-reading checks do fire and stay yamlBased: true — fountain does this; only its source-level lint rule (FTN001) is yamlBased: 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.

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.

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:

plugin.ts
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.

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:

lint/audit-lineage.ts
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 catalog
applyLineage(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.

Terminal window
npm run sweep:prior-art # scripts/prior-art-sweep.ts, against the committed snapshot
npm run sweep:prior-art -- --update-snapshot

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

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.