From c42b01804d036f1fb2ea07c01b938c4be7732019 Mon Sep 17 00:00:00 2001 From: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:45:55 +1100 Subject: [PATCH 1/2] Keep registry block metadata within its own entry Signed-off-by: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> --- .github/workflows/checks.yml | 1 + registry.json | 2 +- scripts/block-metadata.mjs | 35 +++++++++++++++++++++++++++++ scripts/block-metadata.test.mjs | 40 +++++++++++++++++++++++++++++++++ scripts/build-registry.mjs | 10 +++++---- 5 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 scripts/block-metadata.mjs create mode 100644 scripts/block-metadata.test.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 22bf37a..39def38 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -26,6 +26,7 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: node --test scripts/block-metadata.test.mjs # the documentation and the registry are generated: the types of what renders them depend on them - run: npm run docs - name: The registry is as committed diff --git a/registry.json b/registry.json index 41828e1..f28095f 100644 --- a/registry.json +++ b/registry.json @@ -213,7 +213,7 @@ "name": "agent-workspace", "type": "registry:block", "title": "Agent workspace", - "description": "A dashboard with sidebar, charts and data table", + "description": "A workspace where an agent works: sessions, the thread, files and background tasks", "dependencies": [ "lucide-react" ], diff --git a/scripts/block-metadata.mjs b/scripts/block-metadata.mjs new file mode 100644 index 0000000..c9eee25 --- /dev/null +++ b/scripts/block-metadata.mjs @@ -0,0 +1,35 @@ +import ts from "typescript" + +/** Read each literal block on its own: formatting and neighbouring entries must not change its metadata. */ +export function blockMetadata(source) { + const file = ts.createSourceFile("src/blocks/index.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + if (file.parseDiagnostics.length) throw new Error("Invalid TypeScript in src/blocks/index.ts") + const declaration = file.statements + .filter(ts.isVariableStatement) + .flatMap((statement) => [...statement.declarationList.declarations]) + .find((item) => ts.isIdentifier(item.name) && item.name.text === "blocks") + if (!declaration?.initializer || !ts.isArrayLiteralExpression(declaration.initializer)) { + throw new Error("Expected a literal blocks array in src/blocks/index.ts") + } + + const metadata = new Map() + for (const entry of declaration.initializer.elements) { + if (!ts.isCallExpression(entry) || !ts.isIdentifier(entry.expression) || entry.expression.text !== "block" || + entry.arguments.length !== 1 || !ts.isObjectLiteralExpression(entry.arguments[0])) { + throw new Error("Expected block({...}) entries in src/blocks/index.ts") + } + const properties = entry.arguments[0].properties + const string = (key) => { + const property = properties.find((item) => ts.isPropertyAssignment(item) && + (ts.isIdentifier(item.name) || ts.isStringLiteral(item.name)) && item.name.text === key) + if (!property || !ts.isStringLiteralLike(property.initializer)) { + throw new Error(`Expected a literal ${key} in block metadata at line ${file.getLineAndCharacterOfPosition(entry.getStart(file)).line + 1}`) + } + return property.initializer.text + } + const name = string("name") + if (metadata.has(name)) throw new Error(`Duplicate block metadata: ${name}`) + metadata.set(name, { title: string("title"), description: string("description") }) + } + return metadata +} diff --git a/scripts/block-metadata.test.mjs b/scripts/block-metadata.test.mjs new file mode 100644 index 0000000..7f87d24 --- /dev/null +++ b/scripts/block-metadata.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" +import { blockMetadata } from "./block-metadata.mjs" + +test("the existing workspace keeps its own multiline description", () => { + const source = readFileSync(new URL("../src/blocks/index.ts", import.meta.url), "utf8") + const metadata = blockMetadata(source) + assert.equal(metadata.get("agent-workspace").description, + "A workspace where an agent works: sessions, the thread, files and background tasks") + assert.equal(metadata.get("dashboard-01").description, "A dashboard with sidebar, charts and data table") +}) + +test("metadata tolerates whitespace, comments, quotes, escapes and nested unrelated properties", () => { + const source = `export const blocks = [block({ + name: 'first', title: /* description: "wrong" */ "First", + description:\n 'A \\'quoted\\' description', + other: { description: "nested", text: "})" }, + }), block({name: "second", title: "Second", description: "Own description"})]` + assert.deepEqual([...blockMetadata(source)], [ + ["first", { title: "First", description: "A 'quoted' description" }], + ["second", { title: "Second", description: "Own description" }], + ]) +}) + +test("a missing description cannot consume the next entry", () => { + assert.throws(() => blockMetadata(`const blocks = [ + block({name: "first", title: "First"}), + block({name: "second", title: "Second", description: "Not first"}), + ]`), /literal description/) +}) + +test("missing arrays, dynamic metadata and duplicate names fail with a useful error", () => { + assert.throws(() => blockMetadata("const other = []"), /literal blocks array/) + assert.throws(() => blockMetadata(`const blocks = [block({name: "x", title: "X", description: other})]`), /literal description/) + assert.throws(() => blockMetadata(`const blocks = [ + block({name: "x", title: "X", description: "One"}), + block({name: "x", title: "X", description: "Two"}), + ]`), /Duplicate block metadata: x/) +}) diff --git a/scripts/build-registry.mjs b/scripts/build-registry.mjs index 189b805..fa362b8 100644 --- a/scripts/build-registry.mjs +++ b/scripts/build-registry.mjs @@ -20,6 +20,7 @@ import { execFileSync } from "node:child_process" import { copyFileSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" +import { blockMetadata } from "./block-metadata.mjs" const root = join(dirname(fileURLToPath(import.meta.url)), "..") const out = join(root, "public/r") @@ -46,17 +47,18 @@ function needs(content) { } // The blocks: whole screens, one file each. Their titles and descriptions are in src/blocks/index.ts. -const blockIndex = read("src/blocks/index.ts") +const blockIndex = blockMetadata(read("src/blocks/index.ts")) const blocks = readdirSync(join(root, "src/blocks")) .filter((file) => file.endsWith(".tsx")) .map((file) => { const name = file.replace(/\.tsx$/, "") - const entry = blockIndex.slice(blockIndex.indexOf(`name: "${name}"`)) + const entry = blockIndex.get(name) + if (!entry) throw new Error(`Missing block metadata: ${name}`) return { name, type: "registry:block", - title: entry.match(/title: "([^"]*)"/)?.[1], - description: entry.match(/description: "([^"]*)"/)?.[1], + title: entry.title, + description: entry.description, ...needs(read(`src/blocks/${file}`)), files: [{ path: `src/blocks/${file}`, type: "registry:component" }], } From d09ddd5a5d7fa617282e52691ff73cbeb1c45215 Mon Sep 17 00:00:00 2001 From: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:02:19 +1100 Subject: [PATCH 2/2] fix: reject ambiguous block metadata overrides Signed-off-by: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> --- scripts/block-metadata.mjs | 30 +++++++++++++++++++++++++----- scripts/block-metadata.test.mjs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/scripts/block-metadata.mjs b/scripts/block-metadata.mjs index c9eee25..bac0f2c 100644 --- a/scripts/block-metadata.mjs +++ b/scripts/block-metadata.mjs @@ -18,12 +18,32 @@ export function blockMetadata(source) { entry.arguments.length !== 1 || !ts.isObjectLiteralExpression(entry.arguments[0])) { throw new Error("Expected block({...}) entries in src/blocks/index.ts") } - const properties = entry.arguments[0].properties + const line = file.getLineAndCharacterOfPosition(entry.getStart(file)).line + 1 + const keys = ["name", "title", "description"] + const keyOf = (property) => { + if (ts.isSpreadAssignment(property)) return undefined + const name = ts.isComputedPropertyName(property.name) ? property.name.expression : property.name + return ts.isIdentifier(property.name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name) ? name.text : undefined + } + const unrelated = (property) => ts.isSpreadAssignment(property) + ? ts.isObjectLiteralExpression(property.expression) && property.expression.properties.every(unrelated) + : keyOf(property) !== undefined && !keys.includes(keyOf(property)) + const properties = new Map() + for (const property of entry.arguments[0].properties) { + // Either can replace an earlier literal at runtime, including its name. Do not emit stale metadata. + const key = keyOf(property) + if (key === undefined) { + if (unrelated(property)) continue + throw new Error(`Expected static block metadata properties at line ${line}`) + } + if (!keys.includes(key)) continue + if (properties.has(key)) throw new Error(`Duplicate block metadata property: ${key} at line ${line}`) + properties.set(key, property) + } const string = (key) => { - const property = properties.find((item) => ts.isPropertyAssignment(item) && - (ts.isIdentifier(item.name) || ts.isStringLiteral(item.name)) && item.name.text === key) - if (!property || !ts.isStringLiteralLike(property.initializer)) { - throw new Error(`Expected a literal ${key} in block metadata at line ${file.getLineAndCharacterOfPosition(entry.getStart(file)).line + 1}`) + const property = properties.get(key) + if (!property || !ts.isPropertyAssignment(property) || !ts.isStringLiteralLike(property.initializer)) { + throw new Error(`Expected a literal ${key} in block metadata at line ${line}`) } return property.initializer.text } diff --git a/scripts/block-metadata.test.mjs b/scripts/block-metadata.test.mjs index 7f87d24..e071065 100644 --- a/scripts/block-metadata.test.mjs +++ b/scripts/block-metadata.test.mjs @@ -38,3 +38,36 @@ test("missing arrays, dynamic metadata and duplicate names fail with a useful er block({name: "x", title: "X", description: "Two"}), ]`), /Duplicate block metadata: x/) }) + +test("spreads and computed properties cannot silently replace literal metadata", () => { + for (const override of ["...extra", '[key]: "Other"', '...{ description: "Other" }']) { + assert.throws(() => blockMetadata(`const blocks = [block({ + name: "x", title: "X", description: "Original", ${override}, + })]`), /static block metadata properties at line 1/) + } +}) + +test("repeated metadata keys cannot make the registry differ from the runtime block", () => { + for (const key of ["name", "title", "description"]) { + assert.throws(() => blockMetadata(`const blocks = [block({ + name: "x", title: "X", description: "Original", "${key}": "Replacement", + })]`), new RegExp(`Duplicate block metadata property: ${key}`)) + } + assert.throws(() => blockMetadata(`const blocks = [block({ + name: "x", title: "X", description: "Original", ["description"]: "Replacement", + })]`), /Duplicate block metadata property: description at line 1/) +}) + +test("provably unrelated computed keys and literal spreads remain supported", () => { + assert.deepEqual([...blockMetadata(`const blocks = [block({ + name: "x", title: "X", description: "Original", + ["other"]: dynamic, ...{ another: dynamic, ...{ final: dynamic } }, + })]`)], [["x", { title: "X", description: "Original" }]]) +}) + +test("invalid TypeScript and nonliteral metadata members are rejected", () => { + assert.throws(() => blockMetadata("const blocks = [block({"), /Invalid TypeScript/) + for (const description of ['get description() { return "Other" }', "description"]) { + assert.throws(() => blockMetadata(`const blocks = [block({name: "x", title: "X", ${description}})]`), /literal description/) + } +})