Files
TREK/server/scripts/pg-probe/report.ts
T
Maurice 12bf6c0da3 ci(server): fail the Postgres probe on an unmeasured baseline
The probe shipped with scripts/pg-probe-baseline.json at `failing: null`,
and an unmeasured baseline only reported. Every repository statement
Postgres refused, new SQLite-only SQL included, still passed the job;
only the dialect helper cases could fail it, so the ratchet was off until
some later commit seeded the file.

An unmeasured baseline now fails the run outside --update, and the log,
step summary and annotation say which artifact file to commit. The job
stays red until the first measurement lands, so the gate cannot reach dev
holding nothing. A run that called no repository method (an unreadable
tsconfig, a moved repositories directory) fails as well, also under
--update, so a broken plan can neither pass nor seed an empty baseline.
2026-10-08 18:39:34 +02:00

214 lines
9.0 KiB
TypeScript

/**
* Turns what the Postgres probe saw into counts, a console log and the
* Markdown the CI job writes to its step summary.
*/
import type { BaselineVerdict } from './baseline';
import type { HelperResult } from './helper-cases';
import type { StatementRecord } from './recorder';
export interface MethodResult {
/** `Class.method`. */
key: string;
statements: readonly StatementRecord[];
/** Why the method was not called at all. */
unprobeable?: string;
/** The method threw (in JS, after or before its SQL); the statements it sent still count. */
threw?: string;
timedOut?: boolean;
}
export interface FailedStatement {
method: string;
code: string;
message: string;
sql: string;
}
export interface ProbeSummary {
methods: number;
called: number;
unprobeable: number;
/** Called, but sent no SQL (a pure helper, or it threw before reaching the database). */
withoutSql: number;
timedOut: number;
/** Distinct statements per method, summed. */
statements: number;
failedStatements: FailedStatement[];
/** `Class.method` → distinct statements Postgres refused; every called method appears, zeros included. */
failingByMethod: Map<string, number>;
/** SQLSTATE → distinct failing statements. */
failuresByCode: Map<string, number>;
}
export function summarize(results: readonly MethodResult[]): ProbeSummary {
const summary: ProbeSummary = {
methods: results.length,
called: 0,
unprobeable: 0,
withoutSql: 0,
timedOut: 0,
statements: 0,
failedStatements: [],
failingByMethod: new Map(),
failuresByCode: new Map(),
};
for (const result of results) {
if (result.unprobeable !== undefined) {
summary.unprobeable += 1;
continue;
}
summary.called += 1;
if (result.timedOut) summary.timedOut += 1;
if (result.statements.length === 0) summary.withoutSql += 1;
const seen = new Set<string>();
const failed = new Map<string, StatementRecord>();
for (const statement of result.statements) {
seen.add(statement.sql);
if (!statement.outcome.ok && !failed.has(statement.sql)) failed.set(statement.sql, statement);
}
summary.statements += seen.size;
summary.failingByMethod.set(result.key, failed.size);
for (const statement of failed.values()) {
const outcome = statement.outcome;
if (!('code' in outcome)) continue;
summary.failedStatements.push({ method: result.key, code: outcome.code, message: outcome.message, sql: statement.sql });
summary.failuresByCode.set(outcome.code, (summary.failuresByCode.get(outcome.code) ?? 0) + 1);
}
}
return summary;
}
/** SQL on one line, cut to `max` characters, for a log or a table cell. */
export function oneLine(sql: string, max = 160): string {
const flat = sql.replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
function cell(text: string): string {
return text.replace(/\|/g, '\\|').replace(/`/g, "'");
}
export interface ReportInput {
summary: ProbeSummary;
verdict: BaselineVerdict;
helpers: readonly HelperResult[];
schemaFailures: readonly { statement: string; message: string }[];
results: readonly MethodResult[];
}
export function helperFailures(helpers: readonly HelperResult[]): HelperResult[] {
return helpers.filter((result) => result.failure !== null);
}
/**
* True when the probe called no repository method at all. A broken plan (an
* unreadable tsconfig, a moved repositories directory) would otherwise pass
* with nothing failing, and its empty measurement could seed the baseline.
*/
export function probedNothing(summary: Pick<ProbeSummary, 'called'>): boolean {
return summary.called === 0;
}
/**
* True when the run passes: every helper case on both engines, at least one
* repository method called, a measured baseline, and the ratchet held. An
* unmeasured baseline fails, so the gate cannot merge before it holds anything.
*/
export function passed(input: Pick<ReportInput, 'verdict' | 'helpers' | 'summary'>): boolean {
return (
helperFailures(input.helpers).length === 0 &&
!probedNothing(input.summary) &&
!input.verdict.unseeded &&
input.verdict.grown.length === 0 &&
input.verdict.stale.length === 0
);
}
export function formatConsole(input: ReportInput): string {
const { summary, verdict } = input;
const lines: string[] = [];
const failures = helperFailures(input.helpers);
lines.push(`Dialect helpers: ${input.helpers.length - failures.length}/${input.helpers.length} cases pass on SQLite and Postgres.`);
for (const failure of failures) lines.push(` FAIL [${failure.engine}] ${failure.name}: ${failure.failure}`);
if (input.schemaFailures.length > 0) {
lines.push(`Schema: ${input.schemaFailures.length} DDL statement(s) Postgres refused (their tables are missing below):`);
for (const failure of input.schemaFailures) lines.push(` ${failure.message} <- ${oneLine(failure.statement, 120)}`);
}
lines.push(
`Repositories: ${summary.methods} methods, ${summary.called} called, ${summary.unprobeable} unprobeable, ` +
`${summary.withoutSql} sent no SQL, ${summary.timedOut} timed out.`,
);
lines.push(`Statements: ${summary.statements} distinct, ${summary.failedStatements.length} refused by Postgres.`);
for (const [code, count] of [...summary.failuresByCode.entries()].sort((a, b) => b[1] - a[1])) {
lines.push(` ${code}: ${count}`);
}
for (const failed of summary.failedStatements) {
lines.push(` ${failed.method} ${failed.code} ${failed.message}`);
lines.push(` ${oneLine(failed.sql)}`);
}
if (probedNothing(summary)) {
lines.push(`FAIL No repository method was called (${summary.methods} planned); there is nothing to hold to the baseline.`);
}
if (verdict.unseeded) {
lines.push(
'FAIL Baseline: not measured yet (failing is null). Commit the next baseline this run wrote ' +
'(the pg-probe-baseline artifact in CI) as scripts/pg-probe-baseline.json.',
);
}
for (const entry of verdict.grown) {
lines.push(`FAIL ${entry.method} sends ${entry.now} statement(s) Postgres refuses, ${entry.allowed} allowed.`);
}
for (const entry of verdict.stale) {
lines.push(`FAIL ${entry.method} is held at ${entry.allowed} failing statement(s) but fails ${entry.now} now; lower the baseline.`);
}
lines.push(passed(input) ? 'Postgres probe passed.' : 'Postgres probe failed.');
return lines.join('\n');
}
export function formatMarkdown(input: ReportInput): string {
const { summary, verdict } = input;
const failures = helperFailures(input.helpers);
const out: string[] = ['## Postgres probe', ''];
out.push(passed(input) ? 'Result: **passed**' : 'Result: **failed**', '');
out.push('| | |', '|---|---|');
out.push(`| Dialect helper cases (SQLite and Postgres) | ${input.helpers.length - failures.length} of ${input.helpers.length} pass |`);
out.push(`| Schema statements refused | ${input.schemaFailures.length} |`);
out.push(`| Repository methods | ${summary.methods} (${summary.called} called, ${summary.unprobeable} unprobeable) |`);
out.push(`| Distinct statements | ${summary.statements} |`);
out.push(`| Refused by Postgres | ${summary.failedStatements.length} |`);
out.push('');
if (probedNothing(summary)) out.push('No repository method was called, so there is nothing to hold to the baseline.', '');
if (verdict.unseeded) {
out.push(
'The baseline has not been measured yet, which fails the run. Commit `pg-probe-baseline.json` from the `pg-probe-baseline` artifact as `server/scripts/pg-probe-baseline.json`.',
'',
);
}
if (verdict.grown.length > 0 || verdict.stale.length > 0) {
out.push('### Ratchet', '', '| Method | Allowed | Now |', '|---|---|---|');
for (const entry of [...verdict.grown, ...verdict.stale]) out.push(`| ${cell(entry.method)} | ${entry.allowed} | ${entry.now} |`);
out.push('');
}
if (failures.length > 0) {
out.push('### Helper cases that failed', '', '| Engine | Case | Why |', '|---|---|---|');
for (const failure of failures) out.push(`| ${failure.engine} | ${cell(failure.name)} | ${cell(failure.failure ?? '')} |`);
out.push('');
}
if (summary.failuresByCode.size > 0) {
out.push('### Refused statements by SQLSTATE', '', '| SQLSTATE | Statements |', '|---|---|');
for (const [code, count] of [...summary.failuresByCode.entries()].sort((a, b) => b[1] - a[1])) out.push(`| ${code} | ${count} |`);
out.push('');
out.push('<details><summary>Every refused statement</summary>', '', '| Method | SQLSTATE | Message |', '|---|---|---|');
for (const failed of summary.failedStatements) out.push(`| ${cell(failed.method)} | ${failed.code} | ${cell(failed.message)} |`);
out.push('', '</details>', '');
}
const unprobeable = input.results.filter((result) => result.unprobeable !== undefined);
if (unprobeable.length > 0) {
out.push('<details><summary>Methods the probe could not call</summary>', '', '| Method | Why |', '|---|---|');
for (const result of unprobeable) out.push(`| ${cell(result.key)} | ${cell(result.unprobeable ?? '')} |`);
out.push('', '</details>', '');
}
return `${out.join('\n')}\n`;
}