The lint report is the machine-readable result of pg-sprite lint — the offline checker
that runs the same parse-and-classify pipeline as the front doors, with zero live facts and
no database. It exists so CI can gate a DDL script before any environment sees it. This
document is the contract: the fields, the closed vocabularies, and the behavior required of
a consumer. The Go source of truth is pkg/lint; tests in pkg/lint and internal/cli pin
everything documented here.
Every report carries format_version. A consumer that does not recognize the version must
reject the report — never guess at field semantics. The version covers the field shape
and the closed vocabularies below (codes, severities, and the embedded planner reasons):
adding a value to any of them is a contract change and bumps format_version, even if no
field is added or renamed.
The lint report and the plan report are versioned independently — each
carries its own format_version, and they move separately. They share one vocabulary: a
finding's reason draws from the plan report's Reasons set, and the lint format_version
pins the reason set a lint consumer must understand, exactly as the plan format_version
does for plan consumers. A consumer of both must track both versions; understanding plan v1
says nothing about lint v1.
Every enum field draws from a closed vocabulary listed here. A consumer that meets a value it does not recognize must treat the finding as an error and fail the gate — never ignore it and proceed. This mirrors the linter's own posture: an unknown planner route becomes an error-severity refusal, never a silent pass.
The linter sees only the script — no catalog, no column types, no server. Every judgment is therefore the planner's fail-closed judgment: a change lint passes without findings can still sharpen at execution time, but a change lint flags will never quietly get worse. Two codes make the conservatism visible instead of burying it:
possible-table-rewritemarks a decision the planner could not verify (see the codes table) — the engine would take the heavy path, but a live database might prove the change free.destructiveincludes every index drop, because the linter cannot see whether an index is unique — and a dropped unique index whose gap admitted duplicate writes cannot be recreated at all.
| Field | Type | Presence | Meaning |
|---|---|---|---|
format_version |
int | always | Contract version; reject unknown versions. |
postgres_versions |
string | always | The inclusive PostgreSQL major-version range the offline rules are derived for (14-18, see postgresql-version-support.md). The linter never sees a server, so a stored report names the assumptions behind it instead. |
findings |
array | always | The findings in statement order; [] (never null) means the script is clean. |
errors |
int | always | Count of error-severity findings. |
warnings |
int | always | Count of warning-severity findings. |
| Field | Type | Presence | Meaning |
|---|---|---|---|
statement |
int | always | 1-based index of the statement in the script. |
line |
int | always | 1-based source line of the statement's first token, for CI annotations. |
column |
int | always | 1-based source column of the statement's first token. |
sql |
string | always | The statement's verbatim source text (without the trailing semicolon), so it can be found in the source by exact match. Unlike the plan report's canonical rendering, a lint finding points back at the file the author wrote. |
operation |
string | always | Operator-facing label of the flagged operation (DROP COLUMN legacy_a). Display only — never branch on it. |
code |
string | always | The typed finding kind (see Codes). Automation branches on this, never on prose. |
severity |
string | always | What the engine would do about it (see Severities). |
reason |
string | classifier findings | The planner's typed cause, drawn from the plan report's Reasons vocabulary. Absent for destructive findings, which are a property of the operation, not a routing decision. |
suggestion |
array | when constructible | The ordered safer SQL, present only for blocking-idiom findings where the planner constructed the rewrite. A safer form of the submitted statement, not a semantic equivalent — running it by hand forgoes the engine's execution-time guards (invalid-index detection after a concurrent build). Its absence still means the submitted form blocks — the planner does not construct rewrites for multi-operation statements or for operations that need catalog knowledge (ATTACH PARTITION's proving CHECK); the suggest report emits typed guidance naming the manual path for exactly those cases. |
suggestion_execution |
string | with suggestion |
The typed execution contract for suggestion, drawn from the plan report's Execution contracts vocabulary (autocommit-each-step: each step in its own implicit transaction, never inside an enclosing transaction block; a failed step leaves partial state the runner must detect and recover). A consumer that runs the suggestion branches on this. Present exactly when suggestion is. |
| Value | Severity | Example statement | Meaning |
|---|---|---|---|
unsupported-operation |
error | ALTER TABLE t ADD CONSTRAINT x EXCLUDE USING gist (room WITH =) |
No known safe path — the engine refuses it. |
blocking-idiom |
warning | CREATE INDEX i ON t (c) |
The submitted form blocks readers or writers and a safer native form exists; suggestion carries it when the linter can construct one. |
table-rewrite |
warning | ALTER TABLE t ALTER COLUMN c TYPE jsonb USING c::jsonb |
The operation provably rewrites the table — only the engine's copy-and-swap path can run it online. |
possible-table-rewrite |
warning | ALTER TABLE t ALTER COLUMN c TYPE bigint |
The linter cannot verify the operation against live column facts, so the engine would fail closed to the rewrite path — but the change may be a free relabel a live database would prove. |
app-breaking-rename |
warning | ALTER TABLE t RENAME COLUMN email TO email_address |
PostgreSQL runs a column or table rename as a metadata-only catalog flip, but a rename cannot land atomically across running application instances — code still referencing the old name breaks the instant it commits. For a column, expand/contract instead: add the new column, dual-write and backfill, switch reads, then drop the old column as its own reviewed change. For a table, coordinate the rename with the application deploy that adopts the new name. Index renames are not flagged — SQL never references an index by name. |
destructive |
warning | ALTER TABLE t DROP COLUMN legacy |
The operation discards live structure (a column, constraint, or index drop) and cannot be undone by re-running the schema. |
A lint code is what the finding is; the finding's reason field is the classifier's
typed cause for it, drawn from the plan report's Reason vocabulary
(postgres-online-ddl-reference.md#dry-run-diagnostic-codes) —
the text output prints the code as the diagnostic label and links the reason's reference
anchor. The mapping:
code |
reason carried |
|---|---|
blocking-idiom |
safer-idiom |
table-rewrite, possible-table-rewrite |
the specific rewrite cause: volatile-default, generated-stored, type-rewrite, or relocation |
app-breaking-rename |
app-breaking-rename |
unsupported-operation |
unsupported-operation |
destructive |
none — destructiveness is a property of the operation, not a routing cause |
| Value | Meaning | Exit behavior |
|---|---|---|
error |
The engine would refuse the statement — it cannot execute as written. | pg-sprite lint exits non-zero. |
warning |
The engine would execute the statement, but it has a safer form, needs a heavier path, or discards live structure. | Warnings alone exit zero. |
Only error-severity findings flip the exit code today; a policy layer (per-code gating, inline suppression) is a planned extension and will be introduced as a contract change.
Without --json, findings render in the same compiler-diagnostic grammar as the dry-run
and diff reports: each flagged statement leads its group under the conventional
name:line:column: label (where name is the linted file path or <stdin>) so a reader
can jump to the source, each finding is a severity[code]: entry beneath it with the
impact prose, any safer form follows as a help: entry with its execution caveat, a
docs: entry links the reference anchors, and the report closes with a lint: summary.
The text form is for humans; it is not a machine surface — the one-line
file:line:column: severity: … shape errorformat-style annotators parse is not emitted,
so automation (including CI annotation) consumes the JSON report and supplies the file
name it passed in. A clean script prints nothing and exits zero.