diff --git a/registry/blocks/gooey-split/gooey-split.html b/registry/blocks/gooey-split/gooey-split.html index b4037d1081..adbafc9532 100644 --- a/registry/blocks/gooey-split/gooey-split.html +++ b/registry/blocks/gooey-split/gooey-split.html @@ -280,13 +280,9 @@ var copies = samples + 1; var alpha = String(1 / samples); - // A copy is styled by nothing that selected the original: dropping the id also - // drops every `#id` rule that gave it size, colour and font. So each copy carries - // its own resolved style inline. Chrome returns "" for a computed style's cssText, - // so the declarations are enumerated rather than taken wholesale. - // Visibility is not frozen into a copy: its root takes the element's current one from - // the group each frame, and a descendant keeps its own only where it differs from its - // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + // A copy loses the `#id` rules that styled the original, so it carries its resolved style inline + // (enumerated: Chrome returns "" for a computed cssText). Visibility is not frozen: the root + // follows the group each frame, so a target hidden at attach still smears. function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; diff --git a/registry/components/motion-blur/demo.html b/registry/components/motion-blur/demo.html index 025e427ffe..85a8f83476 100644 --- a/registry/components/motion-blur/demo.html +++ b/registry/components/motion-blur/demo.html @@ -402,13 +402,9 @@ var copies = samples + 1; var alpha = String(1 / samples); - // A copy is styled by nothing that selected the original: dropping the id also - // drops every `#id` rule that gave it size, colour and font. So each copy carries - // its own resolved style inline. Chrome returns "" for a computed style's cssText, - // so the declarations are enumerated rather than taken wholesale. - // Visibility is not frozen into a copy: its root takes the element's current one from - // the group each frame, and a descendant keeps its own only where it differs from its - // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + // A copy loses the `#id` rules that styled the original, so it carries its resolved style inline + // (enumerated: Chrome returns "" for a computed cssText). Visibility is not frozen: the root + // follows the group each frame, so a target hidden at attach still smears. function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; diff --git a/registry/components/motion-blur/motion-blur.html b/registry/components/motion-blur/motion-blur.html index aebd983b8f..17f6374392 100644 --- a/registry/components/motion-blur/motion-blur.html +++ b/registry/components/motion-blur/motion-blur.html @@ -271,13 +271,9 @@ var copies = samples + 1; var alpha = String(1 / samples); - // A copy is styled by nothing that selected the original: dropping the id also - // drops every `#id` rule that gave it size, colour and font. So each copy carries - // its own resolved style inline. Chrome returns "" for a computed style's cssText, - // so the declarations are enumerated rather than taken wholesale. - // Visibility is not frozen into a copy: its root takes the element's current one from - // the group each frame, and a descendant keeps its own only where it differs from its - // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + // A copy loses the `#id` rules that styled the original, so it carries its resolved style inline + // (enumerated: Chrome returns "" for a computed cssText). Visibility is not frozen: the root + // follows the group each frame, so a target hidden at attach still smears. function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; diff --git a/registry/components/shutter-slam/demo.html b/registry/components/shutter-slam/demo.html index 9aa2ced4d2..59f4e71a2c 100644 --- a/registry/components/shutter-slam/demo.html +++ b/registry/components/shutter-slam/demo.html @@ -232,13 +232,9 @@ var copies = samples + 1; var alpha = String(1 / samples); - // A copy is styled by nothing that selected the original: dropping the id also - // drops every `#id` rule that gave it size, colour and font. So each copy carries - // its own resolved style inline. Chrome returns "" for a computed style's cssText, - // so the declarations are enumerated rather than taken wholesale. - // Visibility is not frozen into a copy: its root takes the element's current one from - // the group each frame, and a descendant keeps its own only where it differs from its - // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + // A copy loses the `#id` rules that styled the original, so it carries its resolved style inline + // (enumerated: Chrome returns "" for a computed cssText). Visibility is not frozen: the root + // follows the group each frame, so a target hidden at attach still smears. function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; diff --git a/registry/components/shutter-slam/shutter-slam.html b/registry/components/shutter-slam/shutter-slam.html index e74355141b..b2cd02b33e 100644 --- a/registry/components/shutter-slam/shutter-slam.html +++ b/registry/components/shutter-slam/shutter-slam.html @@ -266,13 +266,9 @@ var copies = samples + 1; var alpha = String(1 / samples); - // A copy is styled by nothing that selected the original: dropping the id also - // drops every `#id` rule that gave it size, colour and font. So each copy carries - // its own resolved style inline. Chrome returns "" for a computed style's cssText, - // so the declarations are enumerated rather than taken wholesale. - // Visibility is not frozen into a copy: its root takes the element's current one from - // the group each frame, and a descendant keeps its own only where it differs from its - // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + // A copy loses the `#id` rules that styled the original, so it carries its resolved style inline + // (enumerated: Chrome returns "" for a computed cssText). Visibility is not frozen: the root + // follows the group each frame, so a target hidden at attach still smears. function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; diff --git a/scripts/catalog-drift.ts b/scripts/catalog-drift.ts index 76f0ffdeb6..2870390f3b 100644 --- a/scripts/catalog-drift.ts +++ b/scripts/catalog-drift.ts @@ -6,13 +6,11 @@ import { join, relative, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { generateCatalog } from "./generate-catalog.ts"; import { GENERATED_CATALOG_PATHS } from "./catalog-generated-paths.mjs"; +import { VECTORS, vectorsAgree } from "./catalog-vectors.mjs"; import { runAsCommand } from "./entrypoint.ts"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const MAX_LISTED = 40; -// The embedding model's float output differs slightly between CPUs, so vectors are compared by direction, not bytes. -const VECTORS = /-vectors\.bin$/; -const MIN_ROW_COSINE = 0.99; function filesUnder(root: string): string[] { return readdirSync(root, { recursive: true, withFileTypes: true }) @@ -47,29 +45,6 @@ function sameContent(generatedRoot: string, committedRoot: string, file: string) return vectorsAgree(generated, committed, dimensions); } -/** Same row count, and every row of Float32 vectors points the same way within MIN_ROW_COSINE. */ -export function vectorsAgree(a: Buffer, b: Buffer, dimensions: number): boolean { - const rowBytes = 4 * dimensions; - if (a.length !== b.length || a.length % rowBytes !== 0) return false; - return Array.from({ length: a.length / rowBytes }, (_, row) => - rowCosine(a, b, row * rowBytes, (row + 1) * rowBytes), - ).every((cosine) => cosine >= MIN_ROW_COSINE); -} - -function rowCosine(a: Buffer, b: Buffer, start: number, end: number): number { - let dot = 0; - let normA = 0; - let normB = 0; - for (let i = start; i < end; i += 4) { - const x = a.readFloatLE(i); - const y = b.readFloatLE(i); - dot += x * y; - normA += x * x; - normB += y * y; - } - return dot / Math.sqrt(normA * normB); -} - async function main(): Promise { await generateCatalog(); for (const script of [ diff --git a/scripts/catalog-publication.test.mjs b/scripts/catalog-publication.test.mjs index 92abc36032..30a556d831 100644 --- a/scripts/catalog-publication.test.mjs +++ b/scripts/catalog-publication.test.mjs @@ -1,6 +1,14 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; @@ -93,19 +101,10 @@ function publicationFixture(t, changed, apiEnv = {}) { mkdirSync(dirname(join(root, target)), { recursive: true }); writeFileSync(join(root, target), "published"); } - git("add", "."); - git( - "-c", - "user.name=Test", - "-c", - "user.email=test@example.invalid", - "-c", - "commit.gpgsign=false", - "commit", - "-qm", - "fixture", - ); - const base = git("rev-parse", "HEAD"); + const base = commitPaths(git, "fixture", ["."]); + git("init", "-q", "--bare", "origin.git"); + git("remote", "add", "origin", join(root, "origin.git")); + git("push", "-q", "origin", "HEAD:refs/heads/bot/catalog-publish"); if (changed) writeFileSync(join(root, "registry/registry.json"), "new publication"); const executable = join(root, "gh"); writeFileSync( @@ -127,7 +126,6 @@ else if (endpoint.endsWith("/dispatches") && process.env.DISPATCH_FAIL) process. else if (endpoint.endsWith("/ref/heads/main")) console.log(process.env.BASE); else if (endpoint.includes("/matching-refs/")) console.log("refs/heads/bot/catalog-publish"); else if (endpoint.includes("/ref/heads/bot/catalog-publish")) console.log("a".repeat(40)); -else if (endpoint.includes("/commits/")) console.log("b".repeat(40)); else if (endpoint.includes("/pulls?")) console.log("42"); `, { mode: 0o755 }, @@ -153,7 +151,7 @@ catch (error) { env: { ...process.env, PATH: `${root}:${process.env.PATH}`, - BASE: base, + BASE: git("rev-parse", "HEAD"), API_CALLS: calls, FIXTURE_ROOT: root, GITHUB_REPOSITORY: "test/catalog", @@ -167,6 +165,7 @@ catch (error) { return { root, base, + git, run, calls: () => readFileSync(calls, "utf8") @@ -328,3 +327,101 @@ test("regression accepts completed shards after successful change detection", () const result = runSummary("success", "true", "success"); assert.equal(result.status, 0, result.stdout + result.stderr); }); + +function commitPaths(git, message, paths) { + git("add", "-A", "--", ...paths); + git( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + message, + ); + return git("rev-parse", "HEAD"); +} + +const VECTOR_META = "registry/catalog-artifact/local-vectors.json"; +const VECTOR_BIN = "registry/catalog-artifact/local-vectors.bin"; +// Two rows of two dimensions. +const vectors = (...values) => Buffer.from(new Float32Array(values).buffer); + +// Publishes the working tree's files at `paths` and leaves them in place, as the next run would regenerate them. +function publish(fixture, paths = GENERATED_CATALOG_PATHS) { + commitPaths(fixture.git, "publication", paths); + fixture.git("push", "-q", "-f", "origin", "HEAD:bot/catalog-publish"); + fixture.git("reset", "-q", "HEAD~"); +} + +function commitToMain(fixture, path, contents) { + const kept = existsSync(join(fixture.root, path)) + ? readFileSync(join(fixture.root, path)) + : undefined; + writeFileSync(join(fixture.root, path), contents); + commitPaths(fixture.git, "main push", [path]); + if (kept !== undefined) writeFileSync(join(fixture.root, path), kept); +} + +const rewrites = (fixture) => + fixture + .calls() + .some((call) => call.endpoint === "graphql" || call.endpoint.includes("/refs/heads/bot/")); + +function vectorFixture(t) { + const fixture = publicationFixture(t, true, { PUBLISH_SUCCESS: "1" }); + writeFileSync(join(fixture.root, VECTOR_META), JSON.stringify({ dimensions: 2 })); + writeFileSync(join(fixture.root, VECTOR_BIN), vectors(1, 0, 0, 1)); + return fixture; +} + +test("an unrelated main push leaves the approved publication alone", (t) => { + const fixture = publicationFixture(t, true, { PUBLISH_SUCCESS: "1" }); + publish(fixture); + commitToMain(fixture, "notes.txt", "unrelated"); + fixture.run(); + assert.equal(rewrites(fixture), false); +}); + +test("regenerated vectors replace the publication only when they point elsewhere", (t) => { + for (const [regenerated, rewritten] of [ + [vectors(1, 0.001, 0.001, 1), false], + [vectors(1, 0, 1, 0), true], + ]) { + const fixture = vectorFixture(t); + publish(fixture); + commitToMain(fixture, "notes.txt", "unrelated"); + writeFileSync(join(fixture.root, VECTOR_BIN), regenerated); + fixture.run(); + assert.equal(rewrites(fixture), rewritten); + } +}); + +test("vectors the publication lacks replace it", (t) => { + const fixture = vectorFixture(t); + rmSync(join(fixture.root, VECTOR_BIN)); + publish(fixture); + commitToMain(fixture, "notes.txt", "unrelated"); + writeFileSync(join(fixture.root, VECTOR_BIN), vectors(1, 0, 0, 1)); + fixture.run(); + assert.equal(rewrites(fixture), true); +}); + +test("a main commit to a generated file replaces the publication", (t) => { + const fixture = publicationFixture(t, true, { PUBLISH_SUCCESS: "1" }); + publish(fixture); + commitToMain(fixture, "registry/registry.json", "edited on main"); + fixture.run(); + assert.equal(rewrites(fixture), true); +}); + +test("a publication carrying anything but generated files is replaced", (t) => { + const fixture = publicationFixture(t, true, { PUBLISH_SUCCESS: "1" }); + writeFileSync(join(fixture.root, "extra.sh"), "not generated"); + publish(fixture, [...GENERATED_CATALOG_PATHS, "extra.sh"]); + commitToMain(fixture, "notes.txt", "unrelated"); + fixture.run(); + assert.equal(rewrites(fixture), true); +}); diff --git a/scripts/catalog-vectors.mjs b/scripts/catalog-vectors.mjs new file mode 100644 index 0000000000..007b6548b3 --- /dev/null +++ b/scripts/catalog-vectors.mjs @@ -0,0 +1,26 @@ +// The embedding model's float output differs slightly between CPUs, so vectors are compared by direction, not bytes. +export const VECTORS = /-vectors\.bin$/; +const MIN_ROW_COSINE = 0.99; + +/** Same row count, and every row of Float32 vectors points the same way within MIN_ROW_COSINE. */ +export function vectorsAgree(a, b, dimensions) { + const rowBytes = 4 * dimensions; + if (a.length !== b.length || a.length % rowBytes !== 0) return false; + return Array.from({ length: a.length / rowBytes }, (_, row) => + rowCosine(a, b, row * rowBytes, (row + 1) * rowBytes), + ).every((cosine) => cosine >= MIN_ROW_COSINE); +} + +function rowCosine(a, b, start, end) { + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = start; i < end; i += 4) { + const x = a.readFloatLE(i); + const y = b.readFloatLE(i); + dot += x * y; + normA += x * x; + normB += y * y; + } + return dot / Math.sqrt(normA * normB); +} diff --git a/scripts/publish-catalog.mjs b/scripts/publish-catalog.mjs index 3c73e5e97b..00c8cf5c12 100644 --- a/scripts/publish-catalog.mjs +++ b/scripts/publish-catalog.mjs @@ -12,6 +12,7 @@ import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { GENERATED_CATALOG_PATHS } from "./catalog-generated-paths.mjs"; +import { VECTORS, vectorsAgree } from "./catalog-vectors.mjs"; const BRANCH = "bot/catalog-publish"; const TITLE = "chore(catalog): publish generated catalog"; @@ -249,13 +250,58 @@ function clearObsoletePublication(repository, base, exists) { console.log("No unpublished catalog changes; obsolete publication cleared."); } -function snapshotMatches(root, endpoint, exists) { - if (!exists) return false; - const previous = commitOid( - api(`${endpoint}/ref/heads/${BRANCH}`, "GET", undefined, ".object.sha"), +// The base of the published snapshot while it still matches what main generates: rewriting the branch for an +// unrelated main push would dismiss its approval. +function keptSnapshotBase(root, exists) { + if (!exists) return undefined; + git(root, ["fetch", "-q", "--no-tags", "origin", BRANCH]); + const published = git(root, ["rev-parse", "FETCH_HEAD"]); + const forkPoint = git(root, ["merge-base", published, "HEAD"]); + const current = onlyGeneratedSince(root, forkPoint, published) && sameGenerated(root, published); + return current ? forkPoint : undefined; +} + +// Main edited no generated file since the fork, and the branch carries nothing else. +function onlyGeneratedSince(root, forkPoint, published) { + const elsewhere = GENERATED_CATALOG_PATHS.map((path) => `:(exclude)${path}`); + return ( + !git(root, ["diff", "--name-only", forkPoint, "HEAD", "--", ...GENERATED_CATALOG_PATHS]) && + !git(root, ["diff", "--name-only", forkPoint, published, "--", ".", ...elsewhere]) ); - const tree = commitOid(api(`${endpoint}/commits/${previous}`, "GET", undefined, ".tree.sha")); - return tree === catalogTree(root); +} + +function sameGenerated(root, published) { + const generated = catalogTree(root); + const changes = git(root, [ + "diff", + "--name-status", + "--no-renames", + "-z", + published, + generated, + "--", + ...GENERATED_CATALOG_PATHS, + ]) + .split("\0") + .filter(Boolean); + const pairs = Array.from({ length: changes.length / 2 }, (_, i) => + changes.slice(2 * i, 2 * i + 2), + ); + return pairs.every( + ([status, path]) => + status === "M" && VECTORS.test(path) && sameVectors(root, published, generated, path), + ); +} + +function sameVectors(root, published, generated, path) { + const blob = (tree, file) => + execFileSync("git", ["cat-file", "blob", `${tree}:${file}`], { + cwd: root, + maxBuffer: 64 * 1024 * 1024, + }); + const meta = path.replace(/\.bin$/, ".json"); + const { dimensions } = JSON.parse(blob(generated, meta).toString("utf8")); + return vectorsAgree(blob(published, path), blob(generated, path), dimensions); } function cleanStagingBranch(endpoint, staging, errors) { @@ -303,8 +349,9 @@ export function publish(root) { ); const exists = refs.includes(`refs/heads/${BRANCH}`); if (batches.length === 0) return clearObsoletePublication(repository, base, exists); - if (snapshotMatches(root, endpoint, exists)) { - openPublishPr(repository, base); + const kept = keptSnapshotBase(root, exists); + if (kept) { + openPublishPr(repository, kept); dispatchPublishChecks(repository); console.log("Standing catalog PR already contains this snapshot."); return;