From d72ca001ce698b46dd356534c30d1ab5ba373512 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 19:03:37 +0000 Subject: [PATCH 1/4] Blocks catalog and provenance UI: pin the verdict, the catalog and the lifecycle, fail-first Step 4 of the Phase 2 release plan (sections 7 H3 and 8). Tests only: - composer-core: provenanceVerdict earns "made with block X vN" only by exact expansion identity (a trusted record whose call re-expands to the digest it recorded, on a surface that is that expansion); everything else is inert with its specific reason (reference, base, block, version, validation, expansion, edited, not-offered, stored) and no label; imported and repository projects are unavailable; a verdict carries no action or grant, and a labelled surface's altered messages still bind nothing. parseProvenance keeps the reason it reduced a record for. - runtime: blocksCatalog lists every bundled block with reference, version, status, intents, declared actions and streaming policy; provenanceVerdicts per surface; import keeps reasons. - e2e (experimental): the read-only Catalog section; Preview's provenance line and host-owned note, with no request on any action; edited, duplicated, deleted, imported, forged and stale provenance; an Example session's save pruning its provenance (it does not today: the duplicate carries 1 stale record). - e2e (default build): an imported beta file's provenance is never stored and no block UI appears (a guard; passes today). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- .../experimental/blocks-runtime.test.ts | 100 +++++++ e2e/composer-blocks-provenance.spec.ts | 266 ++++++++++++++++++ e2e/composer-prod-smoke.spec.ts | 27 ++ .../src/blocks/provenance-verdict.test.ts | 231 +++++++++++++++ .../src/blocks/provenance.test.ts | 8 +- playwright.composer-experimental.config.ts | 2 +- 6 files changed, 629 insertions(+), 5 deletions(-) create mode 100644 e2e/composer-blocks-provenance.spec.ts create mode 100644 packages/composer-core/src/blocks/provenance-verdict.test.ts diff --git a/apps/composer/experimental/blocks-runtime.test.ts b/apps/composer/experimental/blocks-runtime.test.ts index 3c922aa..a1fa43b 100644 --- a/apps/composer/experimental/blocks-runtime.test.ts +++ b/apps/composer/experimental/blocks-runtime.test.ts @@ -16,8 +16,12 @@ import { blockBinding, blockContext, blockRunFor, + blocksCatalog, chooseBlock, + importProvenance, + memoryProvenanceStore, planGoalWithBlocks, + provenanceVerdicts, readBlocksBeta, runHostedBlockBuild, withActionManifest, @@ -515,3 +519,99 @@ describe("the beta's Catalog export carries the action manifest (docs/blocks/HOS for (const s of surfaces) expect(s.block).toBeNull(); }); }); + +describe("the Blocks catalog (Phase 2 plan, §7, H3): every bundled block, read-only", () => { + it("each packaged reference lists its blocks with version, status, intents, declared actions and streaming policy", async () => { + const catalog = await blocksCatalog(); + expect(catalog.map((r) => r.referenceId)).toEqual(["shadcn", "astryx"]); + const astryxRef = catalog.find((r) => r.referenceId === "astryx")!; + expect(astryxRef).toMatchObject({ label: "Astryx", reference: { name: "Astryx", dspack: "0.4", version: "0.1.4" }, binding: "ok", sidecarStatus: "approved", issues: [] }); + expect(astryxRef.withheld).toBeUndefined(); + expect(astryxRef.blocks.map((b) => b.id)).toEqual(["records-table", "auth-form", "guide-steps"]); + expect(astryxRef.blocks.find((b) => b.id === "auth-form")).toEqual({ + id: "auth-form", + name: "Account access", + version: 1, + status: "approved", + offered: true, + disabled: [], + intents: ["data-collection"], + actions: ["auth_submit", "auth_forgot", "auth_provider_google", "auth_provider_github", "auth_provider_microsoft", "auth_provider_apple"], + streaming: "disable-actions-until-complete", + }); + expect(astryxRef.blocks.find((b) => b.id === "guide-steps")).toMatchObject({ actions: ["guide_start", "guide_save"], streaming: "static-safe" }); + const shadcnRef = catalog.find((r) => r.referenceId === "shadcn")!; + expect(shadcnRef.reference).toEqual({ name: "shadcn/ui", dspack: "0.4", version: "3.2.0" }); + expect(shadcnRef.withheld).toMatch(/withheld from this beta/); + }); + + it("it is data only: no handler, no function, nothing that could run an action", async () => { + const catalog = await blocksCatalog(); + expect(JSON.parse(JSON.stringify(catalog))).toEqual(catalog); + }); +}); + +describe("provenance verdicts for a project's surfaces (Phase 2 plan, §8)", () => { + async function accepted() { + const base = (await blockBinding(reference))!; + const block = base.sidecar.blocks.find((b) => b.id === "auth-form")!; + const example = block.examples[0]!; + const expanded = expandBlock(block, example.call, { reference: base.sidecar.reference, intent: example.intent }); + if (!expanded.ok) throw new Error(expanded.message); + const surface = expanded.surface as unknown as Json; + const entry = { + v: 1, + reference: base.sidecar.reference, + baseSha256: base.sidecar.baseSha256, + block: "auth-form", + version: 1, + intent: example.intent, + blockSchemaSha256: "a".repeat(64), + encoding: "named", + call: example.call, + expansionDigest: await expansionDigest(surface), + }; + const edited = structuredClone(surface); + edited.root.children[0].text = "Edited by hand"; + return { entry, surface, edited }; + } + + it("a reference project: the accepted block surface is made with auth-form v1 and refinable; an edited one is inert and unlabelled", async () => { + const { entry, surface, edited } = await accepted(); + const verdicts = await provenanceVerdicts({ + provenance: { "ex.chat-1": entry, "ex.chat-2": entry, "ex.gone": entry }, + examples: [ + { id: "ex.chat-1", surface }, + { id: "ex.chat-2", surface: edited }, + ], + project: { kind: "reference", referenceId: "astryx" }, + }); + expect(verdicts["ex.chat-1"]).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "refinable" }); + expect(verdicts["ex.chat-2"]).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "edited" }); + // A record whose surface is gone has no surface to describe; the next save drops it. + expect(verdicts["ex.gone"]).toBeUndefined(); + }); + + it("a shadcn/ui project trusts only shadcn/ui provenance", async () => { + const { entry, surface } = await accepted(); + const verdicts = await provenanceVerdicts({ provenance: { a: entry }, examples: [{ id: "a", surface }], project: { kind: "reference", referenceId: "shadcn" } }); + expect(verdicts.a).toMatchObject({ madeWith: false, status: "inert", reason: "reference" }); + }); + + it("an imported project's provenance is unavailable; a repository project's too", async () => { + const { entry, surface } = await accepted(); + for (const [kind, reason] of [["imported", "imported"], ["agent", "repository"]] as const) { + const verdicts = await provenanceVerdicts({ provenance: { a: entry }, examples: [{ id: "a", surface }], project: { kind } }); + expect(verdicts.a).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "unavailable", reason }); + } + }); + + it("an import keeps the reason each record was reduced to inert for", async () => { + const { entry } = await accepted(); + const holder = { current: {} as Record }; + await importProvenance(memoryProvenanceStore(holder, "import-test"), { a: entry, b: { ...entry, version: 4 }, c: { ...entry, baseSha256: "1".repeat(64) } }); + expect(holder.current.a).toEqual(entry); + expect(holder.current.b).toEqual({ block: "auth-form", version: 4, status: "inert", reason: "version" }); + expect(holder.current.c).toEqual({ block: "auth-form", version: 1, status: "inert", reason: "base" }); + }); +}); diff --git a/e2e/composer-blocks-provenance.spec.ts b/e2e/composer-blocks-provenance.spec.ts new file mode 100644 index 0000000..ab2d238 --- /dev/null +++ b/e2e/composer-blocks-provenance.spec.ts @@ -0,0 +1,266 @@ +/** + * Phase 2, step 4 (docs/blocks/PHASE-2-RELEASE-PLAN.md, §7 H3 and §8) in the + * EXPERIMENTAL composer artifact: the read-only Blocks catalog, Preview's + * provenance line and host-owned wording, and provenance through save, + * export, import, duplicate and delete. Agent-free: /api/models is stubbed, + * every build is scripted, and no model is called. + */ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +type Json = Record; + +/** WCAG 2 A/AA over what this step adds, and nothing else on the page. */ +const scanOf = (page: Page, testId: string) => + new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).disableRules(["region"]).include(`[data-testid="${testId}"]`).analyze(); + +const SWITCH_ON = { enabled: true, references: ["shadcn", "astryx"], blocks: ["records-table", "auth-form", "guide-steps"] }; + +async function setUp(page: Page) { + await page.addInitScript(() => localStorage.setItem("composer.experimental.v1", JSON.stringify({ blocks: true }))); + await page.route("**/api/models", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ models: ["scripted"], blocks: SWITCH_ON }) })); + await page.goto("/"); +} + +async function newProject(page: Page, name: string) { + await page.getByTestId("nav-projects").click(); + await page.getByTestId("new-project-name").fill(name); + await page.getByTestId("new-source-shadcn").click(); + await page.getByTestId("new-project-create").click(); + await expect(page.getByTestId("build-prompt")).toBeVisible(); +} + +/** A scripted sign-in build on shadcn/ui expands auth-form; accept it and return its example id. */ +async function acceptAuthForm(page: Page): Promise { + await page.getByTestId("build-model").selectOption("scripted"); + await page.getByTestId("build-intent").selectOption("structured-input"); + await page.getByTestId("build-prompt").fill("a sign-in form with email and password"); + await page.getByTestId("build-run").click(); + await expect(page.getByTestId("build-gate-summary-1")).toContainText("Follows your design-system rules", { timeout: 30_000 }); + await expect(page.getByTestId("build-block-1")).toContainText("block auth-form v1"); + await page.getByTestId("build-accept-1").click(); + await expect(page.getByTestId("build-accepted-1")).toContainText(/ex\.chat-\d+/); + return (await page.getByTestId("build-accepted-1").innerText()).match(/ex\.chat-\d+/)![0]; +} + +/** Every provenance map in this browser, by storage key. */ +const storedProvenance = (page: Page) => + page.evaluate(() => { + const out: Record> = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i)!; + if (key.startsWith("composer.project.blocks.")) out[key] = JSON.parse(localStorage.getItem(key)!); + } + return out; + }); + +/** Wait until provenance for `id` is stored (it is written after the accept, off the critical path). */ +async function provenanceStoredFor(page: Page, id: string) { + await expect.poll(async () => Object.values(await storedProvenance(page)).some((map) => id in map)).toBe(true); +} + +/** Edit the accepted auth-form surface's "Email address" label in Surfaces, and save. */ +async function editSurface(page: Page, id: string) { + await page.getByTestId("nav-surfaces").click(); + await page.getByTestId(`edit-${id}`).click(); + const texts = page.locator('[data-testid^="node-text-"]'); + const n = await texts.count(); + let edited = false; + for (let i = 0; i < n && !edited; i++) { + if ((await texts.nth(i).inputValue()) === "Email address") { + await texts.nth(i).fill("Work email address"); + edited = true; + } + } + expect(edited).toBe(true); + await expect(page.getByTestId("save-scenario")).toBeEnabled(); + await page.getByTestId("save-scenario").click(); +} + +/** Export the open project from the hub and return the file's JSON. */ +async function exportProjectFile(page: Page): Promise { + await page.getByTestId("nav-projects").click(); + const [download] = await Promise.all([page.waitForEvent("download"), page.locator('[data-testid^="project-export-"]').first().click()]); + const path = await download.path(); + const { readFileSync } = await import("node:fs"); + return JSON.parse(readFileSync(path!, "utf8")) as Json; +} + +/** Import a project file through the hub. */ +async function importProjectFile(page: Page, file: Json, name: string) { + await page.getByTestId("nav-projects").click(); + await page.getByTestId("import-project-input").setInputFiles({ name: `${name}.composerproject.json`, mimeType: "application/json", buffer: Buffer.from(JSON.stringify(file)) }); + await expect(page.getByTestId("build-prompt")).toBeVisible(); +} + +test("the Catalog lists every bundled block, read-only: reference, version, status, intents, declared actions and streaming policy", async ({ page }) => { + await setUp(page); + await newProject(page, "Catalog"); + await page.getByTestId("nav-inventory").click(); + const catalog = page.getByTestId("blocks-catalog"); + await expect(catalog).toBeVisible(); + await expect(catalog).toContainText("Interface blocks"); + + const shadcn = page.getByTestId("blocks-catalog-ref-shadcn"); + await expect(shadcn).toContainText("shadcn/ui 3.2.0"); + await expect(shadcn).toContainText("this project"); + await expect(shadcn).toContainText(/withheld from this beta/); + const astryx = page.getByTestId("blocks-catalog-ref-astryx"); + await expect(astryx).toContainText("Astryx 0.1.4"); + await expect(astryx).not.toContainText("this project"); + + const auth = page.getByTestId("blocks-catalog-shadcn-auth-form"); + for (const text of ["auth-form", "Account access", "v1", "approved", "structured-input", "auth_submit", "auth_forgot", "auth_provider_apple", "disable actions until complete"]) { + await expect(auth).toContainText(text); + } + const guide = page.getByTestId("blocks-catalog-astryx-guide-steps"); + for (const text of ["guide-steps", "structured-editing", "guide_start", "guide_save", "static-safe"]) await expect(guide).toContainText(text); + await expect(page.getByTestId("blocks-catalog-astryx-records-table")).toContainText("records_refresh"); + + // Read-only: nothing in it can be pressed, typed into or chosen. + await expect(catalog.locator("button, input, select, textarea, a[href]")).toHaveCount(0); + await expect(catalog).toContainText(/host-owned/); + expect((await scanOf(page, "blocks-catalog")).violations).toEqual([]); +}); + +test("trusted: an accepted block surface is made with its block and refinable; its actions are host-owned and Composer sends nothing", async ({ page }) => { + await setUp(page); + await newProject(page, "Trusted"); + const id = await acceptAuthForm(page); + await provenanceStoredFor(page, id); + + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + const line = page.getByTestId("preview-provenance"); + await expect(line).toContainText("Made with block auth-form v1"); + await expect(line).toContainText(/refinable/i); + const note = page.getByTestId("preview-host-owned"); + await expect(note).toContainText("host-owned"); + await expect(note).toContainText(/not executed here/); + + // Every action the surface dispatches is recorded, and none leaves the page. + const requests: string[] = []; + page.on("request", (r) => requests.push(`${r.method()} ${r.url()}`)); + const buttons = page.locator("[data-project-canvas] button:not([disabled])"); + const count = await buttons.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i++) await buttons.nth(i).click(); + await expect(page.getByTestId("action-log")).toContainText("auth_submit"); + await page.waitForTimeout(500); + expect(requests).toEqual([]); + + expect((await scanOf(page, "preview-provenance")).violations).toEqual([]); + expect((await scanOf(page, "preview-host-owned")).violations).toEqual([]); +}); + +test("edited: saving a hand edit drops the provenance, and the surface previews as an ordinary one", async ({ page }) => { + await setUp(page); + await newProject(page, "Edited"); + const id = await acceptAuthForm(page); + await provenanceStoredFor(page, id); + + await editSurface(page, id); + await expect.poll(async () => Object.values(await storedProvenance(page)).some((map) => id in map)).toBe(false); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.locator("[data-project-canvas]")).toContainText("Work email address"); + await expect(page.getByTestId("preview-provenance")).toHaveCount(0); + await expect(page.getByTestId("preview-host-owned")).toHaveCount(0); +}); + +test("duplicated and deleted: a duplicate carries provenance that still verifies; deleting a project deletes its provenance", async ({ page }) => { + await setUp(page); + await newProject(page, "Original"); + const id = await acceptAuthForm(page); + await provenanceStoredFor(page, id); + + await page.getByTestId("nav-projects").click(); + const grid = page.getByTestId("projects-grid"); + const originalId = (await grid.locator(".af-card").filter({ hasText: "Original" }).first().getAttribute("data-testid"))!.replace("project-", ""); + await page.getByTestId(`project-duplicate-${originalId}`).click(); + await expect(grid).toContainText("Original copy"); + const copyId = (await grid.locator(".af-card").filter({ hasText: "Original copy" }).first().getAttribute("data-testid"))!.replace("project-", ""); + expect(Object.keys((await storedProvenance(page))[`composer.project.blocks.${copyId}`] ?? {})).toEqual([id]); + + await page.getByTestId(`project-build-${copyId}`).click(); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.getByTestId("preview-provenance")).toContainText("Made with block auth-form v1"); + await expect(page.getByTestId("preview-provenance")).toContainText(/refinable/i); + + await page.getByTestId("nav-projects").click(); + await page.getByTestId(`project-delete-${copyId}`).click(); + await page.getByTestId(`project-delete-confirm-${copyId}`).click(); + await expect(grid).not.toContainText("Original copy"); + const left = await storedProvenance(page); + expect(left[`composer.project.blocks.${copyId}`]).toBeUndefined(); + expect(Object.keys(left[`composer.project.blocks.${originalId}`] ?? {})).toEqual([id]); +}); + +test("imported: provenance survives export and import, still labels the surface, and is unavailable for refinement", async ({ page }) => { + await setUp(page); + await newProject(page, "Portable blocks"); + const id = await acceptAuthForm(page); + await provenanceStoredFor(page, id); + + const file = await exportProjectFile(page); + expect(Object.keys(file.blockProvenance ?? {})).toEqual([id]); + await importProjectFile(page, file, "portable-blocks"); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + const line = page.getByTestId("preview-provenance"); + await expect(line).toContainText("Made with block auth-form v1"); + await expect(line).toContainText(/unavailable/i); + await expect(line).toContainText("imported projects never refine from provenance"); + expect((await scanOf(page, "preview-provenance")).violations).toEqual([]); +}); + +test("forged and stale: an imported record that does not verify is inert, names the reason, and earns no label", async ({ page }) => { + await setUp(page); + await newProject(page, "Forgery source"); + const id = await acceptAuthForm(page); + await provenanceStoredFor(page, id); + const file = await exportProjectFile(page); + const record = file.blockProvenance[id] as Json; + + // A forged call under the recorded digest: it validates, but does not expand to this surface. + const forgedCall = structuredClone(file); + forgedCall.name = "Forged call"; + forgedCall.blockProvenance[id] = { ...record, call: { ...record.call, title: "Someone else's sign in" } }; + await importProjectFile(page, forgedCall, "forged-call"); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.getByTestId("preview-provenance")).toContainText(/inert/i); + await expect(page.getByTestId("preview-provenance")).toContainText("its call does not expand to this surface"); + await expect(page.getByTestId("preview-provenance")).not.toContainText("Made with"); + await expect(page.getByTestId("preview-host-owned")).toHaveCount(0); + + // A record for a block version this build does not have: reduced to inert on import, with its reason kept. + const stale = structuredClone(file); + stale.name = "Stale version"; + stale.blockProvenance[id] = { ...record, version: 2 }; + await importProjectFile(page, stale, "stale-version"); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.getByTestId("preview-provenance")).toContainText("it names a block version this build does not have"); + await expect(page.getByTestId("preview-provenance")).not.toContainText("Made with"); + expect((await scanOf(page, "preview-provenance")).violations).toEqual([]); +}); + +test("an Example session's save prunes its provenance too, so duplicating it carries nothing stale", async ({ page }) => { + await setUp(page); + await page.getByTestId("example-open-shadcn").click(); + await expect(page.getByTestId("example-banner")).toBeVisible(); + await page.getByTestId("nav-build").click(); + const id = await acceptAuthForm(page); + await editSurface(page, id); + // Let the accept's provenance write and the save's prune settle before duplicating. + await page.waitForTimeout(500); + await page.getByTestId("example-duplicate").click(); + await expect(page.getByTestId("project-context")).toContainText("My shadcn/ui project"); + await expect.poll(async () => Object.values(await storedProvenance(page)).filter((map) => id in map).length).toBe(0); + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.locator("[data-project-canvas]")).toContainText("Work email address"); + await expect(page.getByTestId("preview-provenance")).toHaveCount(0); +}); diff --git a/e2e/composer-prod-smoke.spec.ts b/e2e/composer-prod-smoke.spec.ts index 98b3082..487fb7a 100644 --- a/e2e/composer-prod-smoke.spec.ts +++ b/e2e/composer-prod-smoke.spec.ts @@ -675,6 +675,33 @@ test("the Catalog export is today's: the catalog and each surface's messages, an expect(text).toBe(JSON.stringify(bundle, null, 2)); }); +test("a default build ignores and drops block provenance: an imported file's provenance is never stored, and no Blocks catalog or provenance line appears", async ({ page }) => { + // A beta export carries blockProvenance; production drops it on import and + // shows no block UI (docs/blocks/PHASE-2-RELEASE-PLAN.md, §8). + await page.addInitScript(() => { + localStorage.setItem("composer.experimental.v1", JSON.stringify({ blocks: true })); + }); + await page.goto("/"); + await newProject(page, "shadcn", "Beta file"); + await page.getByTestId("nav-projects").click(); + const [download] = await Promise.all([page.waitForEvent("download"), page.locator('[data-testid^="project-export-"]').first().click()]); + const file = JSON.parse(readFileSync((await download.path())!, "utf8")) as Record; + expect(file).not.toHaveProperty("blockProvenance"); + const id = String(((file.contract?.examples ?? []) as Array<{ id: string }>)[0]?.id ?? "ex.chat-1"); + file.name = "Beta file with provenance"; + file.blockProvenance = { [id]: { v: 1, reference: { name: "shadcn/ui", dspack: "0.4", version: "3.2.0" }, baseSha256: "0".repeat(64), block: "auth-form", version: 1, intent: "structured-input", blockSchemaSha256: "a".repeat(64), encoding: "named", call: {}, expansionDigest: "b".repeat(64) } }; + await page.getByTestId("import-project-input").setInputFiles({ name: "beta.composerproject.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(file)) }); + await expect(page.getByTestId("project-context")).toContainText("Beta file with provenance"); + const keys = await page.evaluate(() => Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i)!).filter((k) => k.startsWith("composer.project.blocks."))); + expect(keys).toEqual([]); + await page.getByTestId("nav-inventory").click(); + await expect(page.getByTestId("catalog-summary")).toBeVisible(); + await expect(page.getByTestId("blocks-catalog")).toHaveCount(0); + await page.getByTestId("nav-preview").click(); + await expect(page.getByTestId("preview-provenance")).toHaveCount(0); + await expect(page.getByTestId("preview-host-owned")).toHaveCount(0); +}); + test("the default build ships no interface-block code (docs/BLOCKS.md: blocks stay out of default builds)", () => { // Scans the served artifact on disk, so a lazily loaded chunk cannot hide it. const chunks = fileURLToPath(new URL("../apps/composer/out/_next/static/chunks/", import.meta.url)); diff --git a/packages/composer-core/src/blocks/provenance-verdict.test.ts b/packages/composer-core/src/blocks/provenance-verdict.test.ts new file mode 100644 index 0000000..df820f4 --- /dev/null +++ b/packages/composer-core/src/blocks/provenance-verdict.test.ts @@ -0,0 +1,231 @@ +/** + * The provenance verdict (docs/blocks/PHASE-2-RELEASE-PLAN.md, §8; step 4): + * what the beta's Preview says about a surface's block provenance. A surface + * earns "made with block X vN" only by exact expansion identity: the record + * is trusted against a bundled sidecar, its call re-expands to the digest it + * recorded, and the surface is that expansion. Anything less is inert, with + * the specific reason, and no label. The verdict states what a surface is; + * it never carries an action or a grant. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import { loadProfile, type Profile } from "@aestheticfunction/dspack-emit"; +import { + actionManifest, + expandBlock, + expansionDigest, + loadBlocks, + parseBlocksSidecar, + parseProvenance, + PROVENANCE_REASON_TEXT, + provenanceVerdict, + type LoadedBlocks, + type ProvenanceVerdict, +} from "./index"; + +type Json = Record; +const repo = (rel: string) => fileURLToPath(new URL(`../../../../${rel}`, import.meta.url)); +const json = (rel: string) => JSON.parse(readFileSync(repo(rel), "utf8")) as Json; + +const rawSidecar = json("apps/composer/experimental/blocks/astryx.blocks.json"); +const contract = json("apps/composer/astryx-project/astryx.dspack.json"); +const profile = loadProfile(json("apps/composer/astryx-project/astryx.profile.json") as never) as Profile; +const parsed = parseBlocksSidecar(rawSidecar); +if (!parsed.ok) throw new Error(JSON.stringify(parsed.issues)); +const sidecar = parsed.sidecar; +const authForm = sidecar.blocks.find((b) => b.id === "auth-form")!; +const example = authForm.examples[0]!; +const expanded = expandBlock(authForm, example.call, { reference: sidecar.reference, intent: example.intent }); +if (!expanded.ok) throw new Error(expanded.message); +const surface = expanded.surface as unknown as Json; + +/** The same surface after a hand edit: no longer the expansion. */ +const edited = structuredClone(surface); +edited.root.children[0].text = "Edited by hand"; + +let loaded: LoadedBlocks; +let record: Json; +const REFERENCE_PROJECT = { kind: "reference" as const, reference: sidecar.reference }; + +beforeAll(async () => { + loaded = await loadBlocks(rawSidecar, { baseContract: contract, profile }); + record = { + v: 1, + reference: sidecar.reference, + baseSha256: sidecar.baseSha256, + block: "auth-form", + version: 1, + intent: example.intent, + blockSchemaSha256: "a".repeat(64), + encoding: "named", + call: example.call, + expansionDigest: await expansionDigest(surface), + }; +}); + +const verdict = (overrides: Partial[0]> = {}) => + provenanceVerdict({ record, surface, loaded: [loaded], project: REFERENCE_PROJECT, ...overrides }); + +describe("trusted provenance earns the label", () => { + it("a reference project's accepted block surface is made with its block, and refinable", async () => { + expect(await verdict()).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "refinable" }); + }); + + it("an Example session is a reference project too", async () => { + expect(await verdict({ project: { kind: "reference", reference: sidecar.reference } })).toMatchObject({ madeWith: true, status: "refinable" }); + }); +}); + +describe("only exact expansion identity earns the label", () => { + it("edited: a surface changed after acceptance is inert, with no label; saving drops the record", async () => { + expect(await verdict({ surface: edited })).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "edited" }); + }); + + it("forged digest: a record that claims the edited surface's digest still cannot label it, because its call does not expand to it", async () => { + const forged = { ...record, expansionDigest: await expansionDigest(edited) }; + expect(await verdict({ record: forged, surface: edited })).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "expansion" }); + }); + + it("forged call: another valid call under the recorded digest does not expand to it", async () => { + const forged = { ...record, call: { ...example.call, title: "Someone else's title" } }; + expect(await verdict({ record: forged })).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "expansion" }); + }); + + it("forged or stale bindings: each is inert, with the specific reason", async () => { + const cases: Array<[string, Json, string]> = [ + ["a reference this build does not bundle", { ...record, reference: { name: "Elsewhere", dspack: "0.4", version: "9.9.9" } }, "reference"], + ["another base contract", { ...record, baseSha256: "0".repeat(64) }, "base"], + ["a block this build does not bundle", { ...record, block: "auth-wizard" }, "block"], + ["a version this build does not bundle", { ...record, version: 2 }, "version"], + ["a call that fails B1", { ...record, call: { ...example.call, mode: "sign-ip" } }, "validation"], + ["an intent the block does not declare", { ...record, intent: "record-collection" }, "validation"], + ["the wrong encoding", { ...record, encoding: "positional" }, "validation"], + ]; + for (const [what, forged, reason] of cases) { + expect(await verdict({ record: forged }), what).toEqual({ block: forged.block, version: forged.version, madeWith: false, status: "inert", reason }); + } + }); + + it("a reference project only trusts provenance for its own reference", async () => { + const shadcn = { name: "shadcn/ui", dspack: "0.4", version: "3.2.0" }; + expect(await verdict({ project: { kind: "reference", reference: shadcn } })).toMatchObject({ madeWith: false, status: "inert", reason: "reference" }); + }); + + it("a sidecar that no longer binds to this build's base contract vouches for nothing", async () => { + const moved: LoadedBlocks = { ...loaded, binding: "mismatch", usable: [] }; + expect(await verdict({ loaded: [moved] })).toMatchObject({ madeWith: false, status: "inert", reason: "base" }); + }); + + it("a record already reduced to inert keeps its stated reason, or says it was stored inert; neither earns a label", async () => { + expect(await verdict({ record: { block: "auth-form", version: 2, status: "inert", reason: "version" } })).toEqual({ block: "auth-form", version: 2, madeWith: false, status: "inert", reason: "version" }); + expect(await verdict({ record: { block: "auth-form", version: 1, status: "inert" } })).toEqual({ block: "auth-form", version: 1, madeWith: false, status: "inert", reason: "stored" }); + expect(await verdict({ record: { block: "auth-form", version: 1, status: "inert", reason: "grants-everything" } })).toMatchObject({ madeWith: false, reason: "stored" }); + }); + + it("nothing to say about garbage: no verdict", async () => { + for (const junk of [null, "x", 7, [], {}, { block: "auth-form" }, { version: 1 }, { block: 1, version: 1 }]) { + expect(await verdict({ record: junk })).toBeNull(); + } + }); + + it("a missing surface is never the expansion", async () => { + expect(await verdict({ surface: undefined })).toMatchObject({ madeWith: false, status: "inert", reason: "edited" }); + }); +}); + +describe("imported, repository and unoffered provenance", () => { + it("imported: the surface is still made with the block, but an imported project never refines from it", async () => { + expect(await verdict({ project: { kind: "imported" } })).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "unavailable", reason: "imported" }); + }); + + it("a repository project never refines from it either", async () => { + expect(await verdict({ project: { kind: "agent" } })).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "unavailable", reason: "repository" }); + }); + + it("an imported project's forged provenance is inert, not merely unavailable", async () => { + expect(await verdict({ project: { kind: "imported" }, surface: edited })).toMatchObject({ madeWith: false, status: "inert", reason: "edited" }); + }); + + it("a block this build does not offer (draft, or disabled at load) labels the surface but cannot refine it", async () => { + const draftBlock: LoadedBlocks = { ...loaded, sidecar: { ...sidecar, blocks: sidecar.blocks.map((b) => (b.id === "auth-form" ? { ...b, status: "draft" as const } : b)) } }; + const draftSidecar: LoadedBlocks = { ...loaded, sidecar: { ...sidecar, status: "draft" } }; + const disabled: LoadedBlocks = { ...loaded, usable: loaded.usable.filter((b) => b.id !== "auth-form"), disabled: [{ block: "auth-form", issues: ["B0"] }] }; + for (const [what, l] of [["draft block", draftBlock], ["draft sidecar", draftSidecar], ["disabled at load", disabled]] as const) { + expect(await verdict({ loaded: [l] }), what).toEqual({ block: "auth-form", version: 1, madeWith: true, status: "inert", reason: "not-offered" }); + } + }); +}); + +describe("provenance never grants action authority", () => { + it("a verdict carries what the surface is, never an action, a declaration or a grant", async () => { + const all: ProvenanceVerdict[] = [ + (await verdict())!, + (await verdict({ surface: edited }))!, + (await verdict({ project: { kind: "imported" } }))!, + (await verdict({ record: { ...record, version: 2 } }))!, + ]; + for (const v of all) { + for (const key of Object.keys(v)) expect(["block", "version", "madeWith", "status", "reason"]).toContain(key); + expect(JSON.stringify(v)).not.toMatch(/auth_submit|declared|action|grant|authori[sz]/i); + } + }); + + it("the label does not bind actions: messages altered after the surface was labelled bind nothing in the manifest", async () => { + const { emitSurface } = await import("@aestheticfunction/dspack-emit"); + const messages = emitSurface(surface as never, contract as never, { profile }).messages as Json[]; + const altered = structuredClone(messages); + altered.find((m) => m.updateComponents)!.updateComponents.components[0].text = "Altered"; + expect(await verdict()).toMatchObject({ madeWith: true }); + const manifest = await actionManifest({ + surfaces: [{ name: "ex.chat-1", messages: altered }], + examples: [{ id: "ex.chat-1", surface }], + provenance: { "ex.chat-1": record }, + references: [{ loaded, contract, profile }], + }); + expect(manifest.surfaces[0]!.block).toBeNull(); + expect(manifest.surfaces[0]!.actions.every((a) => a.declared === false)).toBe(true); + }); +}); + +describe("the words for every reason", () => { + it("every inert and unavailable reason has plain text, in the AF voice", () => { + const reasons = ["reference", "base", "block", "version", "validation", "expansion", "edited", "not-offered", "stored", "imported", "repository"]; + expect(Object.keys(PROVENANCE_REASON_TEXT).sort()).toEqual([...reasons].sort()); + for (const reason of reasons) { + const text = PROVENANCE_REASON_TEXT[reason as keyof typeof PROVENANCE_REASON_TEXT]; + expect(text.length, reason).toBeGreaterThan(10); + expect(text, reason).not.toMatch(/—/); + } + }); +}); + +describe("parseProvenance keeps the reason it reduced a record for", () => { + it("each record reduced to inert on import says why", async () => { + const out = parseProvenance( + { + ok: record, + reference: { ...record, reference: { name: "Elsewhere", dspack: "0.4", version: "9.9.9" } }, + base: { ...record, baseSha256: "0".repeat(64) }, + block: { ...record, block: "auth-wizard" }, + version: { ...record, version: 7 }, + validation: { ...record, call: { ...example.call, mode: "sign-ip" } }, + }, + [sidecar], + ); + expect(out.ok).toEqual(record); + expect(out.reference).toEqual({ block: "auth-form", version: 1, status: "inert", reason: "reference" }); + expect(out.base).toEqual({ block: "auth-form", version: 1, status: "inert", reason: "base" }); + expect(out.block).toEqual({ block: "auth-wizard", version: 1, status: "inert", reason: "block" }); + expect(out.version).toEqual({ block: "auth-form", version: 7, status: "inert", reason: "version" }); + expect(out.validation).toEqual({ block: "auth-form", version: 1, status: "inert", reason: "validation" }); + }); + + it("an inert record keeps a known reason and drops any other text", () => { + const out = parseProvenance( + { a: { block: "auth-form", version: 2, status: "inert", reason: "version" }, b: { block: "auth-form", version: 1, status: "inert", reason: "