add an openapi body-field drift check to the generator

This commit is contained in:
Jarek Krochmalski committed 2026-09-06 17:38:22 +05:30
1 parent 4663aa6d2a
commit 6893286e04
2 files changed
+50 -2

No files matched your search

+31 -1
View File
@@ -57,6 +57,16 @@ const LIB_OUT_FILE = join(ROOT_DIR, 'src', 'lib', 'openapi.generated.json');
// though /api/docs no longer reads from this path. // though /api/docs no longer reads from this path.
const STATIC_OUT_FILE = join(ROOT_DIR, 'static', 'openapi.json'); const STATIC_OUT_FILE = join(ROOT_DIR, 'static', 'openapi.json');
// `body` can be `request.body` (a ReadableStream), not the parsed JSON. Only the
// UNAMBIGUOUS stream/Body methods belong here: names that could plausibly be a real
// JSON request field (json, text, blob, bytes, on, once, locked, cancel, tee) are
// deliberately EXCLUDED - keeping them would let a genuinely-undocumented field of
// that name slip past the drift check. A JSON-object-body handler never calls these
// stream methods, so this narrow set is safe.
const STREAM_BODY_MEMBERS = new Set([
'arrayBuffer', 'formData', 'pipe', 'pipeTo', 'pipeThrough', 'getReader'
]);
const args = process.argv.slice(2); const args = process.argv.slice(2);
const mode = args.includes('--check') ? 'check' : args.includes('--scaffold') ? 'scaffold' : 'generate'; const mode = args.includes('--check') ? 'check' : args.includes('--scaffold') ? 'scaffold' : 'generate';
const strictCoverage = args.includes('--strict-coverage'); const strictCoverage = args.includes('--strict-coverage');
@@ -189,9 +199,10 @@ function runCheck(): number {
report.splice(report.length - pathParamIssues, 0, `[Gate 2] Path-param consistency: ${pathParamIssues} issue(s)`); report.splice(report.length - pathParamIssues, 0, `[Gate 2] Path-param consistency: ${pathParamIssues} issue(s)`);
if (pathParamIssues > 0) hardFailures++; if (pathParamIssues > 0) hardFailures++;
// Gates 3+4: query-param drift, status-code drift (only for annotated handlers) // Gates 3+4+7: query-param, status-code, body-field drift (only for annotated handlers)
let queryDrift = 0; let queryDrift = 0;
let statusDrift = 0; let statusDrift = 0;
let bodyDrift = 0;
const driftLines: string[] = []; const driftLines: string[] = [];
for (const route of routes) { for (const route of routes) {
const perMethod = annotationsByPath[route.openapiPath]; const perMethod = annotationsByPath[route.openapiPath];
@@ -232,13 +243,32 @@ function runCheck(): number {
statusDrift++; statusDrift++;
} }
} }
// Body-field drift (ONE direction: read in code but not documented).
// Only for handlers that DO document a JSON object body - a missing body:
// annotation is a coverage concern, not drift. body-raw/body-multipart
// are non-JSON, so they have no field list to compare. The other direction
// (documented but not read locally) is deliberately NOT hard: a field can be
// forwarded to a helper without a local `body.x`.
if (ann.body && ann.body.kind === 'object') {
const docBody = new Set(Object.keys(ann.body.properties));
for (const f of analysis.bodyFields) {
if (STREAM_BODY_MEMBERS.has(f)) continue; // request.body stream methods, not JSON fields
if (!docBody.has(f)) {
driftLines.push(` [Gate 7] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: body field "${f}" read in code but not documented`);
bodyDrift++;
}
}
}
} }
} }
report.push(`[Gate 3] Query-param drift: ${queryDrift} issue(s)`); report.push(`[Gate 3] Query-param drift: ${queryDrift} issue(s)`);
report.push(`[Gate 4] Status-code drift: ${statusDrift} issue(s)`); report.push(`[Gate 4] Status-code drift: ${statusDrift} issue(s)`);
report.push(`[Gate 7] Body-field drift: ${bodyDrift} issue(s)`);
report.push(...driftLines); report.push(...driftLines);
if (queryDrift > 0) hardFailures++; if (queryDrift > 0) hardFailures++;
if (statusDrift > 0) hardFailures++; if (statusDrift > 0) hardFailures++;
if (bodyDrift > 0) hardFailures++;
// Gate 5: orphan JSDoc (a @openapi marker that didn't attach to an export) // Gate 5: orphan JSDoc (a @openapi marker that didn't attach to an export)
report.push(`[Gate 5] Orphan @openapi blocks: ${orphanMarkers.length} file(s)`); report.push(`[Gate 5] Orphan @openapi blocks: ${orphanMarkers.length} file(s)`);
+19 -1
View File
@@ -550,11 +550,29 @@ export function analyzeHandlerBody(body: string, pathParamNames: string[] = []):
const bodyFields = new Set<string>(); const bodyFields = new Set<string>();
// `const { a, b } = await request.json();` and `const { a, b } = body;` // `const { a, b } = await request.json();` and `const { a, b } = body;`
const destructureRe = /const\s*\{([^}]*)\}\s*=\s*(?:await\s+)?(?:request\.json\(\)|body)\b/g; // The `\b(?!\.)` guards the bare `body` alternative: it must not match `bodyText`
// (\b) NOR `body.config` (the (?!\.) - destructuring a NESTED object off the body
// would otherwise record its keys as top-level fields). `request.json()` ends in
// `)`, where a trailing boundary would never match, so it needs no guard.
const destructureRe = /const\s*\{([^}]*)\}\s*=\s*(?:await\s+)?(?:request\.json\(\)|body\b(?!\.))/g;
let dm; let dm;
while ((dm = destructureRe.exec(body)) !== null) { while ((dm = destructureRe.exec(body)) !== null) {
for (const f of parseDestructuredFields(dm[1])) bodyFields.add(f); for (const f of parseDestructuredFields(dm[1])) bodyFields.add(f);
} }
// Member access: `body.foo` / `body?.foo`. Many handlers read the parsed body
// field-by-field instead of destructuring, so without this a documented `body:`
// annotation could omit a field the code actually reads and drift silently.
// Skip a WRITE (`body.foo = ...` and compound `+= ??= ||= &&= *= ...`): the request
// body is only ever read, so an assignment means `body` is a local variable (e.g. a
// response object literally named `body`), not the parsed request - counting it is a
// false positive. The write group matches an optional assignment operator (any op
// suffix ending in a single `=`) but NOT a comparison `==`/`===`.
const memberRe = /\bbody\??\.([A-Za-z_$][\w$]*)\s*((?:\*\*|<<|>>>?|\?\?|\|\||&&|[+\-*/%&|^])?=(?!=))?/g;
let mm;
while ((mm = memberRe.exec(body)) !== null) {
if (mm[2]) continue; // an assignment (plain or compound) -> a write, skip
bodyFields.add(mm[1]);
}
return { return {
queryParams, queryParams,