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:
parent
0844206660
commit
107a68224d
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user