fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session (#1981)

* fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session

Root-causes the setTiming element_not_found resolver-shadow divergence class:
timeline edits carry hf-ids read from the live preview DOM, but the preview
minted ids AFTER rewriting attributes (and never persisted them for sub-comps),
while the SDK session mints from the raw file — content-keyed minting then
yields different ids for the same element. Template-based comps were worse:
the SDK excluded the whole <template> subtree, so the session had zero
elements and every edit diverged.

- parsers: ensureHfIds now descends into <template> subtrees (linkedom's
  querySelectorAll does not), minting and pinning inner ids
- sdk: buildRoots/buildElement treat <template> as a transparent container,
  and resolution (resolveScoped, animation-id map) searches template subtrees
  via querySelectorAllDeep — template comps now model, resolve, and edit
- studio-server: the sub-comp preview route persists hf-ids to the raw file
  BEFORE the rewrite pipeline (mirrors the main route), pinning one id space
  across served DOM, disk, and SDK session
- studio: resolver-shadow skips structurally-empty sessions (no event, no
  attempt) and tags fail-open emissions with sourceReadFailed so read errors
  are distinguishable from unwired readers in telemetry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(parsers,sdk,studio-server,studio): scope template descent, guard persist route

Addresses the 10 verified findings from the PR #1981 review:

- Restrict template transparency to COMPOSITION templates
  (<template data-composition-id>) everywhere — ensureHfIds, SDK
  buildChildren, querySelectorAllDeep. A plain <template> (runtime
  clone-source) keeps its old fully-excluded behavior: stamping its
  interior would duplicate one persisted id across every runtime clone,
  and modeling it would show phantom timeline clips.
- Guard the sub-comp persist: only .html files (the wildcard route can
  serve any project path — stamping an SVG corrupted it on disk),
  try/catch the read (file-removed race becomes 404, not 500), salt the
  etag (v2) so pre-fix cached clients don't 304 past the id pin, and
  thread the stamped content into buildSubCompositionHtml so served ids
  match the mint even when the disk write is skipped.
- Rewrite querySelectorAllDeep as a document-order DOM walk — appending
  template matches after top-level matches made duplicate-id tiebreaks
  disagree with the preview's unwrapped DOM (wrong-element edits).
- Recurse sourceMutation.querySelectorAllWithTemplates so server-side
  ops resolve ids at any template depth, matching SDK resolution.
- Replace the empty-session silent skip with ONE tagged session_empty
  event per session — silence would blind the tripwire to exactly the
  modeling-gap class that exposed the template bug. Attempts stay
  uncounted (an unmodelable comp can't cut over).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio-server): close TOCTOU in sub-comp hf-id persist (CodeQL js/file-system-race)

Replace the route-level stat/read/persist sequence with stampFileHfIds:
validation (fstat), read, mint, and write-back all go through ONE open
file descriptor (O_NOFOLLOW where supported), so the path cannot be
swapped between validation and write. Falls back to read-only stamping
when the file isn't writable — content-keyed minting means the SDK
derives the same ids from the same bytes even without the disk write.

Addresses miguel-heygen's blocking review on PR #1981.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio-server): linear-time template-attr match (CodeQL js/polynomial-redos)

promoteTemplateCompositionId's single-pattern regex backtracked
polynomially on crafted input. Two-step match: grab each <template>
open tag linearly, then find data-composition-id within that short
tag text. Same semantics (first template carrying the attr wins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-06 00:16:07 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent e9076324e7
commit 3a717fa719
14 changed files with 628 additions and 43 deletions
@@ -98,6 +98,23 @@ describe("A. Flag gating", () => {
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
});
it("A2c: empty session → ONE tagged session_empty event per session, no attempt", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
flushAttemptCounts(); // drain counts left by earlier tests
const session = await openComposition("<!DOCTYPE html><html><body></body></html>");
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
runResolverShadow(session, "hf-anything", ops);
runResolverShadow(session, "hf-other", ops); // repeat edits do not re-emit
const events = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow");
// The modeling gap stays VISIBLE (silence would blind the tripwire to the
// exact class that exposed the template-comp bug) but is distinguishable
// and rate-limited to once per session instance.
expect(events).toHaveLength(1);
expect(events[0]?.props.sessionEmpty).toBe(true);
expect(JSON.stringify(events[0]?.props.mismatches)).toContain("session_empty");
expect(flushAttemptCounts()).toBeNull(); // can't cut over → not in the denominator
});
it("A3: shadow depends ONLY on shadow flag, not on STUDIO_SDK_CUTOVER_ENABLED", async () => {
// The mock always returns STUDIO_SDK_CUTOVER_ENABLED=false. Use a divergence
// (poisoned session) so the flag-on case emits; flag-off must stay silent.
@@ -268,7 +285,12 @@ describe("C. Resolver-parity detection", () => {
it("C8 sourceHfIdCount: emitted element_not_found carries source occurrence count", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = { getElement: () => null, getElements: () => [] } as unknown as Composition;
// One unrelated element so the session isn't "empty" (empty sessions are
// skipped as structural modeling gaps) — it just can't resolve hf-dup.
const session = {
getElement: () => null,
getElements: () => [{ id: "hf-other" }],
} as unknown as Composition;
// id present twice in source (duplicate-id ambiguity) but absent from session
const source = `<div data-hf-id="hf-dup">a</div><div data-hf-id="hf-dup">b</div>`;
runResolverShadow(
@@ -282,7 +304,10 @@ describe("C. Resolver-parity detection", () => {
it("C8 sourceLooseMatchOnly: hfId matches source only as plain text, not a data-hf-id attribute", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = { getElement: () => null, getElements: () => [] } as unknown as Composition;
const session = {
getElement: () => null,
getElements: () => [{ id: "hf-other" }],
} as unknown as Composition;
// "hf-widget" appears only inside a class name, never as data-hf-id="hf-widget".
const source = `<div class="hf-widget-container">no attribute match here</div>`;
runResolverShadow(
@@ -436,7 +461,7 @@ describe("F. recordResolverParity", () => {
expect(ev?.sourceHfIdCount).toBeUndefined();
});
it("fails open: a readSource error still emits (no suppression)", async () => {
it("fails open: a readSource error still emits (no suppression), tagged sourceReadFailed", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-missing", "setTiming", () =>
@@ -445,6 +470,31 @@ describe("F. recordResolverParity", () => {
const ev = lastShadow();
expect(ev?.mismatchCount).toBe(1);
expect(ev?.sourceHfIdCount).toBeUndefined();
// Distinguishes "reader threw" from "no reader wired" — every wild emission
// of the setTiming class had an absent sourceHfIdCount and the two cases
// were indistinguishable in telemetry.
expect(ev?.sourceReadFailed).toBe(true);
});
it("does not tag sourceReadFailed when no reader is supplied", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
const session = await openComposition(BASE_HTML);
await recordResolverParity(session, "hf-missing", "setTiming");
expect(lastShadow()?.sourceReadFailed).toBeUndefined();
});
it("empty session → ONE tagged session_empty event, no attempt, no element_not_found", async () => {
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
flushAttemptCounts(); // drain any counts left by earlier tests
const session = await openComposition("<!DOCTYPE html><html><body></body></html>");
await recordResolverParity(session, "hf-anything", "setTiming");
await recordResolverParity(session, "hf-other", "setTiming"); // no re-emit
const events = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow");
expect(events).toHaveLength(1);
expect(events[0]?.props.sessionEmpty).toBe(true);
expect(JSON.stringify(events[0]?.props.mismatches)).toContain("session_empty");
expect(JSON.stringify(events[0]?.props.mismatches)).not.toContain("element_not_found");
expect(flushAttemptCounts()).toBeNull(); // can't cut over → not in the denominator
});
it("tags sourceLooseMatchOnly when hfId matches source only as plain text, not a data-hf-id attribute", async () => {
+41 -1
View File
@@ -24,7 +24,12 @@ import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface SdkResolverMismatch {
kind: "element_not_found" | "value_mismatch" | "dispatch_error" | "animation_not_found";
kind:
| "element_not_found"
| "value_mismatch"
| "dispatch_error"
| "animation_not_found"
| "session_empty";
hfId?: string;
animationId?: string;
property?: string;
@@ -347,6 +352,34 @@ function redactMismatches(mismatches: SdkResolverMismatch[]): SdkResolverMismatc
* (see below). The session is shared with the cutover path, so it MUST end the
* call exactly as it started.
*/
// Sessions whose empty-session modeling gap has already been reported — one
// event per session instance, not one per edit (the per-edit storm is noise;
// the EXISTENCE of the gap is the signal).
const emptySessionReported = new WeakSet<Composition>();
/**
* An empty session structurally cannot resolve ANY id — a modeling gap (empty
* file, comp shape the SDK can't parse into elements), not a resolver
* divergence, and it can't cut over either, so it stays out of the attempt
* denominator. But silence would blind the tripwire to exactly the class that
* exposed the template-comp bug — so emit ONE distinguishable `session_empty`
* event per session, then skip. Returns true when the caller should skip.
*/
function reportEmptySession(session: Composition, opLabel: string): boolean {
if (session.getElements().length !== 0) return false;
if (!emptySessionReported.has(session)) {
emptySessionReported.add(session);
trackStudioEvent("sdk_resolver_shadow", {
opLabel,
sessionEmpty: true,
sessionElementCount: 0,
mismatchCount: 1,
mismatches: JSON.stringify([{ kind: "session_empty" } satisfies SdkResolverMismatch]),
});
}
return true;
}
export function runResolverShadow(
session: Composition,
hfId: string | null | undefined,
@@ -356,6 +389,7 @@ export function runResolverShadow(
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
if (!hfId) return;
try {
if (reportEmptySession(session, "dom-edit")) return;
recordAttempt("dom-edit");
const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
// Emit only on divergence — parity is silent, matching recordResolverParity
@@ -409,6 +443,7 @@ export async function recordResolverParity(
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
if (!session || !hfId) return;
try {
if (reportEmptySession(session, opLabel)) return;
recordAttempt(opLabel);
if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
// Capture BEFORE any await: this call is fire-and-forget (`void recordResolverParity(...)`)
@@ -419,11 +454,13 @@ export async function recordResolverParity(
const sessionElementCount = session.getElements().length;
// Cheap check passed above, so the source read only runs on a real divergence.
let source: string | undefined;
let sourceReadFailed = false;
if (readSource) {
try {
source = await readSource();
} catch {
source = undefined; // fail-open: a read error must not drop a real divergence
sourceReadFailed = true;
}
}
// Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path).
@@ -444,6 +481,9 @@ export async function recordResolverParity(
// Lets telemetry consumers filter this cohort without parsing the
// sourceHfIdCount comment above.
...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
// The reader was wired but threw — distinguishes "read failed, emitted
// fail-open without the suppression/count checks" from "no reader wired".
...(sourceReadFailed ? { sourceReadFailed: true } : {}),
mismatchCount: 1,
mismatches: JSON.stringify([
{ kind: "element_not_found", hfId } satisfies SdkResolverMismatch,