add an openapi body-field drift check to the generator
This commit is contained in:
2 files changed
+50
-2
No files matched your search
@@ -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.
|
||||
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 mode = args.includes('--check') ? 'check' : args.includes('--scaffold') ? 'scaffold' : 'generate';
|
||||
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)`);
|
||||
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 statusDrift = 0;
|
||||
let bodyDrift = 0;
|
||||
const driftLines: string[] = [];
|
||||
for (const route of routes) {
|
||||
const perMethod = annotationsByPath[route.openapiPath];
|
||||
@@ -232,13 +243,32 @@ function runCheck(): number {
|
||||
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 4] Status-code drift: ${statusDrift} issue(s)`);
|
||||
report.push(`[Gate 7] Body-field drift: ${bodyDrift} issue(s)`);
|
||||
report.push(...driftLines);
|
||||
if (queryDrift > 0) hardFailures++;
|
||||
if (statusDrift > 0) hardFailures++;
|
||||
if (bodyDrift > 0) hardFailures++;
|
||||
|
||||
// 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)`);
|
||||
|
||||
+19
-1
@@ -550,11 +550,29 @@ export function analyzeHandlerBody(body: string, pathParamNames: string[] = []):
|
||||
|
||||
const bodyFields = new Set<string>();
|
||||
// `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;
|
||||
while ((dm = destructureRe.exec(body)) !== null) {
|
||||
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 {
|
||||
queryParams,
|
||||
|
||||
Reference in new issue
Block a user