auditor: skip serde-derived structs in unread-field check

Fields on structs that derive Serialize or Deserialize ARE read — by
the macro, on every JSON round-trip — but the static check only
looked for explicit `.field` references in the diff. Result: every
new response/request struct shipped through `/v1/*` was flagged as
"placeholder state without a consumer."

PR #11 head 0844206 surfaced 8 such false positives across mode.rs,
respond.rs, truth.rs, and profiles/memory.rs — same shape as the
existing string-literal exemption for BLOCK_PATTERNS, just at a
different syntactic layer.

Two helpers added:
- extractNewFieldsWithLine: keeps each field's diff-line index so the
  caller can locate the parent struct.
- parentStructHasSerdeDerive: walks back ≤80 lines for a `pub struct`
  boundary, then ≤8 lines above it for `#[derive(...)]` lines
  containing Serialize or Deserialize. Stops on closing-brace-at-col-0
  to avoid escaping the enclosing scope.

Verified on PR #11's actual diff: unread-field warnings dropped from
8 → 0. Synthetic cases confirm the check still fires on plain
(non-serde) structs with no in-diff reader, so the
genuine-placeholder catch is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-04-26 20:49:06 -05:00
parent 0844206660
commit 107a68224d

View File

@ -105,10 +105,20 @@ export function runStaticCheck(diff: string): Finding[] {
// elsewhere might exist). The point is: if NEITHER this diff nor // elsewhere might exist). The point is: if NEITHER this diff nor
// any other line in the diff reads the field, the PR is shipping // any other line in the diff reads the field, the PR is shipping
// state without a consumer. // state without a consumer.
//
// Serde exemption: if the field's parent struct derives Serialize
// or Deserialize, the read-site is the macro itself — JSON
// round-trips consume every public field. Without this exemption
// the check produces false positives on every response/request
// struct shipped through `/v1/*`.
const addedLines = lines.filter(l => l.startsWith("+") && !l.startsWith("+++")) const addedLines = lines.filter(l => l.startsWith("+") && !l.startsWith("+++"))
.map(l => l.slice(1)); .map(l => l.slice(1));
const newFields = extractNewFields(addedLines); const newFields = extractNewFieldsWithLine(lines);
for (const field of newFields) { const seenNames = new Set<string>();
for (const { name: field, lineIdx } of newFields) {
if (seenNames.has(field)) continue;
seenNames.add(field);
if (parentStructHasSerdeDerive(lines, lineIdx)) continue;
const readPattern = new RegExp(`[\\.:]\\s*${escape(field)}\\b|\\b${escape(field)}\\s*:`); const readPattern = new RegExp(`[\\.:]\\s*${escape(field)}\\b|\\b${escape(field)}\\s*:`);
// The definition line itself matches readPattern — filter it out // The definition line itself matches readPattern — filter it out
// by requiring at least TWO lines in the diff mention the field // by requiring at least TWO lines in the diff mention the field
@ -146,18 +156,68 @@ function splitDiffByFile(diff: string): Map<string, string[]> {
return out; return out;
} }
// Extract new `pub name: Type,` fields from added lines. Rust syntax. // Extract new `pub name: Type,` fields from the per-file diff lines,
// Narrowly-scoped: only matches at the start of a trimmed line, // keeping each occurrence's line index so the caller can resolve the
// requires `pub ` prefix, ignores `pub fn` / `pub struct` / etc. // parent struct. Same narrow rules as before: starts with `pub `,
function extractNewFields(addedLines: string[]): string[] { // excludes `pub fn` / `pub struct` / etc.
const fields = new Set<string>(); function extractNewFieldsWithLine(lines: string[]): Array<{ name: string; lineIdx: number }> {
for (const line of addedLines) { const out: Array<{ name: string; lineIdx: number }> = [];
const t = line.trim(); for (let i = 0; i < lines.length; i++) {
// pub NAME: Type, const line = lines[i];
if (!line.startsWith("+") || line.startsWith("+++")) continue;
const t = line.slice(1).trim();
const m = t.match(/^pub\s+(?!fn\b|struct\b|enum\b|mod\b|use\b|trait\b|impl\b|const\b|static\b|type\b)(\w+)\s*:/); const m = t.match(/^pub\s+(?!fn\b|struct\b|enum\b|mod\b|use\b|trait\b|impl\b|const\b|static\b|type\b)(\w+)\s*:/);
if (m) fields.add(m[1]); if (m) out.push({ name: m[1], lineIdx: i });
} }
return Array.from(fields); return out;
}
// True if the field at `fieldLineIdx` lives inside a struct whose
// declaration carries `#[derive(... Serialize|Deserialize ...)]`. We
// walk backward through the diff (added + context lines both count —
// a struct declaration unchanged by the PR still appears as context)
// to find the nearest `pub struct` boundary, then scan a few lines
// above it for derive attributes. Conservative bounds:
// - 80 lines back to find `struct` (struct definitions can grow large)
// - 8 lines above the `struct` keyword for attribute lines
// Stops the struct-search early if we hit a `}` at zero indent
// (the previous scope) or another `pub struct` (we left ours).
function parentStructHasSerdeDerive(lines: string[], fieldLineIdx: number): boolean {
let structLineIdx = -1;
for (let i = fieldLineIdx - 1; i >= 0 && i >= fieldLineIdx - 80; i--) {
const raw = lines[i];
if (typeof raw !== "string" || raw.length === 0) continue;
const body = stripDiffPrefix(raw);
const trimmed = body.trim();
if (/^pub\s+struct\s+\w/.test(trimmed)) {
structLineIdx = i;
break;
}
// Closing brace at column 0 means the enclosing scope ended above
// the field — we're not actually inside a struct.
if (body.startsWith("}")) return false;
}
if (structLineIdx < 0) return false;
for (let j = structLineIdx - 1; j >= 0 && j >= structLineIdx - 8; j--) {
const raw = lines[j];
if (typeof raw !== "string") continue;
const trimmed = stripDiffPrefix(raw).trim();
if (trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("///")) continue;
if (!trimmed.startsWith("#[")) break;
if (/derive\s*\([^)]*\b(Serialize|Deserialize)\b/.test(trimmed)) return true;
}
return false;
}
// Strip leading +/-/space from a unified-diff line, leaving the raw
// source line. Handles the case where the line is shorter than 1 char
// (rare but real for empty-context lines).
function stripDiffPrefix(line: string): string {
if (line.length === 0) return line;
const c = line[0];
if (c === "+" || c === "-" || c === " ") return line.slice(1);
return line;
} }
// True if `pos` falls inside a double- or single-quoted string on this // True if `pos` falls inside a double- or single-quoted string on this