// Diff-aware QA planner: classify a page change against a baseline signature // and emit a machine-readable review manifest BEFORE any expensive work runs. // // The motivating failure is spend without proportionality: a one-word copy // edit and a theme rewrite both trigger the same full-page, every-context // vision review. This module makes review proportional to the change: // - text/data-only edits produce zero vision calls; // - a local component change reviews only that component's stable root; // - global CSS, unprovable blast radius, or a missing baseline widen to // full coverage — ambiguity always widens, never silently downgrades. // // Everything here is pure data-in/data-out: signatures are captured elsewhere // (the browser client), baselines are persisted elsewhere (qa-snapshot.ts), // and execution happens elsewhere (the critique engine). Keeping the planner // pure means the classification can be reviewed, logged, and overridden // before a single model call is made. // // One deliberate portability boundary: a stylesheet change classifies as // `large-structural` here because CSS blast radius is not provable from // digests alone. A host that can map component-scoped stylesheets to // selectors can downgrade after the fact; the portable layer never does. /** FNV-1a 32-bit over a string, hex-encoded, with a length suffix so the * (tiny) collision surface also has to match on length. Used for text and * stylesheet digests — a comparison fingerprint, not a security hash. */ export function fnv1a32(text: string): string { let hash = 0x811c9dc5; for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } return `${(hash >>> 0).toString(16).padStart(8, "0")}-${text.length}`; } /** One element's structural fingerprint inside a captured page. */ export interface QaNodeSignature { /** Structural path from body: `body>div:0>table:1>tr:4>td:2` (index among * element siblings). Position-sensitive by design — reordering IS a * structural change. */ path: string; tag: string; /** Canonical attribute string: sorted `name=value` pairs joined with \x1f. * Raw (not digested) so a reviewer can see WHICH attribute changed. */ attrs: string; /** Digest of the element's direct text-node content (whitespace-normalized), * absent when the element has no direct text. */ text?: string; /** Digest of opaque rendered content whose pixels can change without a DOM * structure or attribute change (for example SVG descendants or a canvas * bitmap). `unavailable` widens to unknown because text-only cannot be * proven without this evidence. */ visual?: string; /** Nearest strict-ancestor stable anchor (unique `#id` or * `[data-qa-scope="…"]`), with the anchor element's own path so nested * anchors can be deduped to the outermost root. Absent for nodes with no * anchored ancestor (e.g. direct children of body on an anchor-free page). */ anchor?: { selector: string; path: string }; } export interface QaStylesheetSignature { /** External sheet href, or `inline:` for a `