diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index ddfb316..96fa066 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -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)`); diff --git a/scripts/openapi/lib.ts b/scripts/openapi/lib.ts index 70294d8..513cebf 100644 --- a/scripts/openapi/lib.ts +++ b/scripts/openapi/lib.ts @@ -550,11 +550,29 @@ export function analyzeHandlerBody(body: string, pathParamNames: string[] = []): const bodyFields = new Set(); // `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,