From 9680c5cc66bf17a3f803cf2eff4ddd75d41bdbf5 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Mon, 7 Sep 2026 19:13:49 +0700 Subject: [PATCH 1/7] feat(author): prove the proposal workflow through packed exports --- README.md | 12 +- benchmarks/authoring/fixtures.json | 14 +- benchmarks/authoring/hashes.json | 10 +- .../utility/assets/aurora-dashboard.svg | 23 + .../sources/utility/hero-handoff.html | 18 + benchmarks/authoring/sources/utility/hero.css | 1 + scripts/authoring-runner.ts | 62 +- scripts/authoring-runtime-style-contract.ts | 39 + scripts/authoring-runtime-worker.cjs | 802 ++++++++++++++++++ scripts/authoring-runtime.config.example.json | 12 + scripts/authoring/score.ts | 8 +- scripts/smoke-packed-cli.mjs | 12 +- skills/block-runner/SKILL.md | 11 +- skills/block-runner/references/GUIDE.md | 77 +- src/author/index.ts | 7 +- src/author/plan.ts | 16 +- src/author/styles.ts | 3 +- src/authoring/generate.ts | 7 + test/author.native-style-adapters.test.ts | 48 ++ test/author.proposal.test.ts | 53 ++ test/authoring.receipts.test.ts | 50 +- test/authoring.runner.test.ts | 77 +- test/authoring.style-ledger.test.ts | 46 + test/skill.test.ts | 5 +- 24 files changed, 1329 insertions(+), 84 deletions(-) create mode 100644 benchmarks/authoring/sources/utility/assets/aurora-dashboard.svg create mode 100644 benchmarks/authoring/sources/utility/hero-handoff.html create mode 100644 benchmarks/authoring/sources/utility/hero.css create mode 100644 scripts/authoring-runtime-style-contract.ts create mode 100755 scripts/authoring-runtime-worker.cjs create mode 100644 scripts/authoring-runtime.config.example.json create mode 100644 test/authoring.style-ledger.test.ts diff --git a/README.md b/README.md index a55555a..c446f1a 100644 --- a/README.md +++ b/README.md @@ -509,12 +509,12 @@ boundary. `AuthoringPlan` remains the semantic input contract for existing consu deprecated semantic `compileAuthoringPlan()` adapt that contract; their returned `canonicalPlan` is a `GeneratedAuthoringPlan` that consumers review and write. -For HTML authoring, a harness first calls `collectSourceEvidence()` (or `author()` analysis) and -then sends `AuthorOptions.proposal`: an ordered native structure with the returned `sourceRef`s, -editor fields/locks, and any explicit content decisions. Block Runner binds exact source content, -assets, CSS coverage, and required warnings before returning the same canonical plan. Existing -complete `AuthorOptions.plan` callers remain supported. Component foundation containment remains -an explicit `author.styles.foundation: 'component'` caller policy. +For HTML authoring, the primary path first calls `collectSourceEvidence()` and then sends +`AuthorOptions.proposal`: an ordered native structure with the returned `sourceRef`s, editor +fields/locks, and reviewed source decisions. Block Runner owns exact source hashes/content coverage, +assets, native style adapters, CSS coverage, and mandatory warnings before returning the canonical +plan. Inspection/validation need no consent; only the final canonical write identity does. Existing +complete `AuthorOptions.plan` callers remain supported as an advanced compatibility route. The runnable [authoring lifecycle example](examples/authoring-plan.ts) uses the same shape as the CLI and skill examples from proposal through preview, confirmation identity, and generation. diff --git a/benchmarks/authoring/fixtures.json b/benchmarks/authoring/fixtures.json index 777cd0b..854f8d5 100644 --- a/benchmarks/authoring/fixtures.json +++ b/benchmarks/authoring/fixtures.json @@ -153,9 +153,19 @@ "producer": "utility-generator", "sourceStyle": "utility-tailwind", "source": { - "path": "sources/utility/hero.html", - "sha256": "e2498a57e8306a15f5409d1245ca016aa4aa20399486bf14a1705419054fb9a4" + "path": "sources/utility/hero-handoff.html", + "sha256": "f2f5e7794f73fa4684bc5e897769f8ed8eab98e81730a0a8dde3a5e50ae8961d" }, + "sourceDependencies": [ + { + "path": "sources/utility/hero.css", + "sha256": "b748973dec35c2b80d7d432eb7b41049999481165084e78c17b98161ecad778e" + }, + { + "path": "sources/utility/assets/aurora-dashboard.svg", + "sha256": "18ab15d443086fa3fc379734c0f0e86b891535ea222f6499e0b37170067e6cf9" + } + ], "prompt": "fixtures/hero-utility/prompt.md", "plan": "fixtures/hero-utility/expected-plan.json", "requiredDimensions": [ diff --git a/benchmarks/authoring/hashes.json b/benchmarks/authoring/hashes.json index 65cff19..dcf33ab 100644 --- a/benchmarks/authoring/hashes.json +++ b/benchmarks/authoring/hashes.json @@ -3,7 +3,7 @@ "generatedFrom": "repository content; regenerate before a release record if any listed input changes", "values": { "suiteHash": { - "value": "39fba6758e1a932eb36e6d50a239fb8ef2efd73433de80da87b2d365a988bb05", + "value": "8debeb3552997932370a5247feb37a80a860bc3be0ed13c2e3c99464513a705d", "inputs": "all corpus files except hashes.json and runs/, ordered by path; canonical JSON path/raw-byte-SHA-256 pairs" }, "fixtureDefinitionHash": { @@ -11,18 +11,18 @@ "inputs": "fixtures/** as canonical path/raw-byte-SHA-256 pairs" }, "sourceSetHash": { - "value": "edf36ad64ceacfe70c35cf46fa0736909966e2b0a9b137b8c3955492238a8bf0", + "value": "4ec90e3c800054e580904eb4dd168ec11b5a58d37e79f4ad16f9b5a4ea9fd665", "inputs": "sources/** as canonical path/raw-byte-SHA-256 pairs" }, - "fixtureIndexHash": "c560c31530b96f05ba42f5a29344e38cc0dd656b17c8db0b7219776431f4b671", + "fixtureIndexHash": "1b5e4eba63c1fa85568c40aa9591860a9af5d3b452b8ac0c97687e24d7e05693", "fixtureSchemaHash": "d5508b413d5b01421557b7cedb7bd19ce4e0c29566f0809df02e9fd410127a77", "contractHash": "c0e163ec94dc0b151a3623560531fa44fd9336c01137b9bc8ba33131088bcc0c", "candidateContractHash": "78da7e741e3290582c0828a6b1e06a3ee61190e79d5e9455841d310c18fdccc0", "provenanceGuideHash": "e207e0500de3a005b5df113baff578e26801c2aa5547f2cb8d914ea58e190bfa", "releaseMatrixHash": "29d0e8c0965de9ee106423744820f3e8980533696992b05d14141437c405abb6", "suiteManifestHash": "abe91aa331fd5b90db4df08fbc0b51ea83cfa1a4b3fff356a584bb6550989732", - "fixtureManifestHash": "2d3dac0f9f114c2901d6b5660a9d8115142f1f55d305eeea631b466ba6eb9dca", - "sourceDependencyHash": "d8e39545a4fc92817f916afbff48ad726c5d046d31ba4d794aa5bef01d15ad9e", + "fixtureManifestHash": "3f1a89cfcb21348f1b44c2e2140caf3d524a32e6a9bc26641027e228b37fea1f", + "sourceDependencyHash": "9b68b11cee485394d605aa0428cc9a21e28bad4df06f86b3aca3a5f8e4466bb6", "expectedPlanHash": "08b80782525056282b6c2dac70ae0350894fff4ed6058237e5e0e4d9a2cf0865", "receiptSchemaHash": "90b4cc60efabc87f3c60f7c28fae21e95ad35c030aa454c97c9e2b0941d373f5" }, diff --git a/benchmarks/authoring/sources/utility/assets/aurora-dashboard.svg b/benchmarks/authoring/sources/utility/assets/aurora-dashboard.svg new file mode 100644 index 0000000..f0390cb --- /dev/null +++ b/benchmarks/authoring/sources/utility/assets/aurora-dashboard.svg @@ -0,0 +1,23 @@ + + Aurora release dashboard + A dark dashboard with color tokens, a release receipt list, and an activation check marked complete. + + + + + + + + + + Release evidence + Authoring package · block runner 0.9 + + STYLE TOKENS + + + RELEASE RECEIPTS + Source contractPackage dry runZIP activation + + Activation: complete + diff --git a/benchmarks/authoring/sources/utility/hero-handoff.html b/benchmarks/authoring/sources/utility/hero-handoff.html new file mode 100644 index 0000000..3cf2cca --- /dev/null +++ b/benchmarks/authoring/sources/utility/hero-handoff.html @@ -0,0 +1,18 @@ + +
+
+
+

Block Runner 0.9

+

Build a WordPress block your team can keep editing.

+

Turn a finished interface into a registered block with clear controls, native markup, and a source trail reviewers can inspect.

+ +
+
+ Aurora dashboard with color tokens, release receipts, and a completed activation check +
Native controls stay with the block, not in a screenshot.
+
+
+
diff --git a/benchmarks/authoring/sources/utility/hero.css b/benchmarks/authoring/sources/utility/hero.css new file mode 100644 index 0000000..60782e9 --- /dev/null +++ b/benchmarks/authoring/sources/utility/hero.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.relative{position:relative}.isolate{isolation:isolate}.mx-auto{margin-left:auto;margin-right:auto}.mb-5{margin-bottom:1.25rem}.mt-3{margin-top:.75rem}.mt-6{margin-top:1.5rem}.mt-9{margin-top:2.25rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.min-h-\[680px\]{min-height:680px}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-7xl{max-width:80rem}.max-w-xl{max-width:36rem}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-12{gap:3rem}.gap-3{gap:.75rem}.overflow-hidden{overflow:hidden}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-white\/25{border-color:hsla(0,0%,100%,.25)}.bg-cyan-300{--tw-bg-opacity:1;background-color:rgb(103 232 249/var(--tw-bg-opacity,1))}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-8{line-height:2rem}.leading-\[1\.04\]{line-height:1.04}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-tight{letter-spacing:-.025em}.text-cyan-300{--tw-text-opacity:1;color:rgb(103 232 249/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-950{--tw-text-opacity:1;color:rgb(2 6 23/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-black\/40{--tw-shadow-color:rgba(0,0,0,.4);--tw-shadow:var(--tw-shadow-colored)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:bg-cyan-200:hover{--tw-bg-opacity:1;background-color:rgb(165 243 252/var(--tw-bg-opacity,1))}.hover\:bg-white\/10:hover{background-color:hsla(0,0%,100%,.1)}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-cyan-300:focus-visible{outline-color:#67e8f9}.focus-visible\:outline-white:focus-visible{outline-color:#fff}@media (min-width:640px){.sm\:flex-row{flex-direction:row}.sm\:px-8{padding-left:2rem;padding-right:2rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}}@media (min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:px-12{padding-left:3rem;padding-right:3rem}.lg\:text-7xl{font-size:4.5rem;line-height:1}} \ No newline at end of file diff --git a/scripts/authoring-runner.ts b/scripts/authoring-runner.ts index ff9ce19..b9407fb 100644 --- a/scripts/authoring-runner.ts +++ b/scripts/authoring-runner.ts @@ -13,6 +13,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { pathToFileURL } from 'node:url'; +import { author } from '../src/author/index.js'; import { AuthoringGenerationError, compileRegisteredBlock } from '../src/authoring/generate.js'; import { validateAuthoringPlan } from '../src/authoring/schema.js'; import { @@ -90,7 +91,7 @@ function candidateSourcePath(candidateDirectory: string, relative: string): stri return path.join(candidateDirectory, 'source', relative); } -export function materializeCandidate(fixture: AuthoringFixture, suiteDirectory: string, candidateDirectory: string, receiptDirectory: string, candidatePlan: string, planSnapshot?: Buffer): ReceiptArtifact { +export async function materializeCandidate(fixture: AuthoringFixture, suiteDirectory: string, candidateDirectory: string, receiptDirectory: string, candidatePlan: string, planSnapshot?: Buffer): Promise { if (!/^[a-z0-9-]+$/.test(fixture.id)) throw new Error('unsafe fixture id'); if (!fixture.source?.path || !existsSync(candidatePlan)) throw new Error(`${fixture.id} has missing source or candidate plan`); const sourceInput = suiteFile(suiteDirectory, fixture.source.path, fixture.source.sha256); @@ -102,11 +103,40 @@ export function materializeCandidate(fixture: AuthoringFixture, suiteDirectory: const value = dependency as { path: string; sha256?: unknown }; return suiteFile(suiteDirectory, value.path, value.sha256); }); - // Expected plans are scoring contracts, not model output and not source code. - // Only a supplied canonical candidate may enter the production compiler. + // Expected plans are scoring contracts, not model output and not source code. A candidate + // only reaches the compiler through the production author() source/coverage gate. const candidateBytes = planSnapshot ?? readFileSync(candidatePlan); const plan = validateAuthoringPlan(candidateBytes.toString('utf8')); - const compiled = compileRegisteredBlock(plan); + const stylesheetDependencies = dependencyInputs.filter((input) => path.extname(input.relative).toLowerCase() === '.css'); + const stylesheet = stylesheetDependencies.length + ? Buffer.concat(stylesheetDependencies.map((input) => input.bytes)).toString('utf8') + : undefined; + const report = await author(sourceInput.bytes.toString('utf8'), { + // This is the verified suite input path, deliberately not the temporary candidate snapshot. + sourcePath: sourceInput.source, + author: { + name: plan.target.name, + title: plan.target.title, + category: plan.target.category, + locking: plan.locking, + styles: { + mode: 'css', + ...(stylesheet === undefined ? {} : { css: stylesheet }), + ...(plan.styles.foundation === 'component' ? { foundation: 'component' } : {}), + }, + }, + plan, + }); + if (!report.ok || !report.package?.canonicalPlan || !report.package.manifest) { + const reason = report.items.map((item) => item.reason).filter(Boolean).join('; ') + || 'production author() did not produce a source-bound canonical package'; + if (/script behaviour|event-handler behaviour|unsupported-executable-behaviour/i.test(reason)) { + throw new AuthoringGenerationError(`unsupported-executable-behaviour: ${reason}`, fixture.source.path); + } + throw new Error(`production author() rejected candidate: ${reason}`); + } + const canonicalPlan = report.package.canonicalPlan; + const compiled = compileRegisteredBlock(canonicalPlan); if (existsSync(candidateDirectory)) throw new Error(`refusing to overwrite candidate: ${candidateDirectory}`); mkdirSync(candidateDirectory, { recursive: true }); for (const file of [...compiled.files, ...compiled.assets]) { @@ -114,11 +144,17 @@ export function materializeCandidate(fixture: AuthoringFixture, suiteDirectory: mkdirSync(path.dirname(destination), { recursive: true }); writeFileSync(destination, file.content, { flag: 'wx' }); } - // These are the confirmed style decisions, not a per-source-declaration coverage ledger. - // The worker must provide the latter before claiming the style dimension was measured. - write(path.join(candidateDirectory, 'style-decisions.json'), `${JSON.stringify(plan.styles, null, 2)}\n`); + write(path.join(candidateDirectory, 'style-decisions.json'), `${JSON.stringify(canonicalPlan.styles, null, 2)}\n`); write(path.join(candidateDirectory, 'compiler-manifest.json'), `${JSON.stringify(compiled.manifest, null, 2)}\n`); writeFileSync(path.join(candidateDirectory, 'authoring-plan.json'), candidateBytes, { flag: 'wx' }); + write(path.join(candidateDirectory, 'canonical-authoring-plan.json'), `${JSON.stringify(canonicalPlan, null, 2)}\n`); + write(path.join(candidateDirectory, 'source-coverage.json'), `${JSON.stringify({ + valid: true, + source: canonicalPlan.source, + inputStylesheetSha256: sha256(stylesheet ?? [...sourceInput.bytes.toString('utf8').matchAll(/]*>([\s\S]*?)<\/style\s*>/gi)].map((match) => match[1]).join('\n')), + coverage: canonicalPlan.coverage, + canonicalPlan, + }, null, 2)}\n`); for (const input of [sourceInput, ...dependencyInputs]) { const destination = candidateSourcePath(candidateDirectory, input.relative); mkdirSync(path.dirname(destination), { recursive: true }); @@ -317,7 +353,7 @@ function unsuccessfulReceipt( return receipt; } -export function executeFixture( +export async function executeFixture( fixture: AuthoringFixture, suiteDirectory: string, runDirectory: string, @@ -325,7 +361,7 @@ export function executeFixture( hashes: ReturnType, plansDirectory: string | undefined, workerTimeoutMs = 480_000, -): AuthoringReceipt { +): Promise { if (!/^[a-z0-9-]+$/.test(fixture.id)) throw new Error('unsafe fixture id'); if (!Number.isSafeInteger(workerTimeoutMs) || workerTimeoutMs < 1) throw new Error('worker timeout must be a positive integer'); const startedAt = new Date().toISOString(); @@ -349,7 +385,7 @@ export function executeFixture( let generatedSourceManifest: ReceiptArtifact; try { planSnapshot = readFileSync(candidatePlan); - generatedSourceManifest = materializeCandidate(fixture, suiteDirectory, candidateDirectory, receiptDirectory, candidatePlan, planSnapshot); + generatedSourceManifest = await materializeCandidate(fixture, suiteDirectory, candidateDirectory, receiptDirectory, candidatePlan, planSnapshot); } catch (error) { if (isMissingFileError(error)) { return unsuccessfulReceipt( @@ -476,7 +512,7 @@ export function executeFixture( } } -function main(): void { +async function main(): Promise { const suiteDirectory = path.resolve(valueFor('--suite') ?? 'benchmarks/authoring'); const suppliedPlans = valueFor('--plans'); const plansDirectory = suppliedPlans ? path.resolve(suppliedPlans) : undefined; @@ -500,7 +536,7 @@ function main(): void { mkdirSync(runDirectory); const receipts: AuthoringReceipt[] = []; for (const fixture of fixtures) { - const receipt = executeFixture(fixture, suiteDirectory, runDirectory, runner, hashes, plansDirectory); + const receipt = await executeFixture(fixture, suiteDirectory, runDirectory, runner, hashes, plansDirectory); const file = path.join(runDirectory, 'receipts', `${receipt.fixtureId}.json`); if (existsSync(file)) throw new Error(`refusing to overwrite fixture receipt: ${file}`); write(file, `${JSON.stringify(receipt, null, 2)}\n`); @@ -538,4 +574,4 @@ function main(): void { if (!run.summary.contractPass) process.exitCode = 1; } -if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) main(); +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) void main(); diff --git a/scripts/authoring-runtime-style-contract.ts b/scripts/authoring-runtime-style-contract.ts new file mode 100644 index 0000000..4e4f861 --- /dev/null +++ b/scripts/authoring-runtime-style-contract.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * Invoke the production selector scoper from the runtime worker without + * duplicating its selector grammar in the worker's CommonJS process. + */ +import { readFileSync } from 'node:fs'; +import { scopeLocalSelectorList } from '../src/author/styles.js'; + +type InputStyle = { + index: number; + transportSelector?: string; + source?: { selector?: string }; +}; + +type Input = { + root: string; + foundation?: 'component'; + styles: InputStyle[]; +}; + +const inputFile = process.argv[2]; +if (!inputFile) throw new Error('style-contract input file is required'); +const input = JSON.parse(readFileSync(inputFile, 'utf8')) as Input; +if (typeof input.root !== 'string' || !Array.isArray(input.styles) + || (input.foundation !== undefined && input.foundation !== 'component')) { + throw new Error('style-contract input is invalid'); +} + +const entries = input.styles.map((style) => { + const selector = style.transportSelector ?? style.source?.selector; + if (!Number.isInteger(style.index) || typeof selector !== 'string') { + throw new Error(`coverage style ${style.index} has no transport or source selector`); + } + const scoped = scopeLocalSelectorList(selector, input.root, { foundation: input.foundation }); + if (!scoped.ok) throw new Error(`coverage style ${style.index} cannot be scoped: ${scoped.reason}`); + return { index: style.index, sourceSelector: selector, renderedSelector: scoped.selector }; +}); + +process.stdout.write(`${JSON.stringify({ entries })}\n`); diff --git a/scripts/authoring-runtime-worker.cjs b/scripts/authoring-runtime-worker.cjs new file mode 100755 index 0000000..5998352 --- /dev/null +++ b/scripts/authoring-runtime-worker.cjs @@ -0,0 +1,802 @@ +#!/usr/bin/env node +/* + * Execute a frozen registered-block candidate against a configured disposable + * WordPress runtime. This worker makes no model calls and never edits the + * candidate source recorded by the runner. + * + * Set BLOCK_RUNNER_AUTHORING_RUNTIME_CONFIG to an absolute JSON config file. + * See scripts/authoring-runtime.config.example.json for the required shape. + */ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const cp = require('node:child_process'); +const { createRequire } = require('node:module'); +const http = require('node:http'); + +function emittedStyleValueMatches(property, emittedValue, coverageValue) { + if (emittedValue === coverageValue) return true; + return /^--[a-zA-Z_][a-zA-Z0-9_-]*$/.test(property) && coverageValue === '' && emittedValue.trim() === ''; +} + +// Focused contract for the production comparator; it deliberately bypasses runtime configuration. +if (process.argv.length === 3 && process.argv[2] === '--style-value-contract') { + const cases = [ + { property: '--tw-pan-x', emitted: ' ', coverage: '', expected: true }, + { property: '--tw-pan-x', emitted: '\t', coverage: '', expected: true }, + { property: 'color', emitted: ' ', coverage: '', expected: false }, + { property: '-- invalid', emitted: ' ', coverage: '', expected: false }, + { property: '--brand', emitted: ' red ', coverage: 'red', expected: false }, + { property: 'color', emitted: 'red', coverage: 'red', expected: true }, + ].map((entry) => ({ ...entry, actual: emittedStyleValueMatches(entry.property, entry.emitted, entry.coverage) })); + if (!cases.every((entry) => entry.actual === entry.expected)) process.exit(1); + process.stdout.write(`${JSON.stringify({ cases })}\n`); + process.exit(0); +} + +function failPreflight(message) { + process.stderr.write(`authoring runtime worker preflight failed: ${message}\n`); + process.exit(2); +} + +function readConfig() { + const configFile = process.env.BLOCK_RUNNER_AUTHORING_RUNTIME_CONFIG; + if (!configFile) failPreflight('BLOCK_RUNNER_AUTHORING_RUNTIME_CONFIG is required; point it at a JSON file based on scripts/authoring-runtime.config.example.json.'); + const resolved = path.resolve(configFile); + let config; + try { config = JSON.parse(fs.readFileSync(resolved, 'utf8')); } catch (error) { + failPreflight(`cannot read ${resolved}: ${error.message}`); + } + const string = (value, label) => { + if (typeof value !== 'string' || !value.trim()) failPreflight(`${label} must be a non-empty string.`); + return value; + }; + if (!config || typeof config !== 'object' || Array.isArray(config)) failPreflight('config must be a JSON object.'); + const wordpress = config.wordpress; + if (!wordpress || typeof wordpress !== 'object' || Array.isArray(wordpress)) failPreflight('config.wordpress must be an object.'); + const credentials = wordpress.credentials; + if (!credentials || typeof credentials !== 'object' || Array.isArray(credentials)) failPreflight('config.wordpress.credentials must be an object.'); + const result = { + repo: path.resolve(string(config.repo, 'config.repo')), + cli: path.resolve(string(config.cli, 'config.cli')), + sharedDependencies: path.resolve(string(config.sharedDependencies, 'config.sharedDependencies')), + docker: string(config.docker, 'config.docker'), + wordpress: { + container: string(wordpress.container, 'config.wordpress.container'), + url: string(wordpress.url, 'config.wordpress.url').replace(/\/$/, ''), + username: string(credentials.username, 'config.wordpress.credentials.username'), + password: string(credentials.password, 'config.wordpress.credentials.password'), + }, + smokeViewport: config.smokeViewport, + }; + for (const [label, file, expected] of [ + ['config.repo/package.json', path.join(result.repo, 'package.json'), 'file'], + ['config.cli', result.cli, 'file'], + ['config.sharedDependencies', result.sharedDependencies, 'directory'], + ]) { + if (!fs.existsSync(file) || (expected === 'file' ? !fs.statSync(file).isFile() : !fs.statSync(file).isDirectory())) { + failPreflight(`${label} does not exist as a ${expected}: ${file}`); + } + } + if (result.docker.includes(path.sep) && (!fs.existsSync(result.docker) || !fs.statSync(result.docker).isFile())) { + failPreflight(`config.docker does not exist as a file: ${result.docker}`); + } + if (result.smokeViewport !== undefined && (!result.smokeViewport || !Number.isInteger(result.smokeViewport.width) + || !Number.isInteger(result.smokeViewport.height) || result.smokeViewport.width < 1 || result.smokeViewport.height < 1)) { + failPreflight('config.smokeViewport must contain positive integer width and height when supplied.'); + } + return result; +} + +const config = readConfig(); +const req = createRequire(path.join(config.repo, 'package.json')); +let chromium, postcss; +try { + ({ chromium } = req('@playwright/test')); + postcss = req('postcss'); + req.resolve('axe-core/axe.min.js'); +} catch (error) { + failPreflight(`configured repo is missing required runtime dependencies: ${error.message}`); +} + +const args = {}; +for (let index = 2; index < process.argv.length; index += 2) { + const flag = process.argv[index]; + const value = process.argv[index + 1]; + if (!flag?.startsWith('--') || value === undefined) failPreflight('worker arguments must be flag/value pairs.'); + (args[flag] ??= []).push(value); +} +const get = (key) => args[key]?.[0]; +for (const key of ['--candidate-dir', '--fixture-id', '--candidate-plan', '--source', '--expected-plan', '--result']) { + if (!get(key)) failPreflight(`${key} is required.`); +} +if (get('--wordpress-version') && get('--wordpress-version') !== '7.1') failPreflight('this worker only accepts the WordPress 7.1 runner contract.'); +if (get('--browser') && get('--browser') !== 'chromium') failPreflight('this worker only accepts the Chromium runner contract.'); +if (get('--viewport') && get('--viewport') !== '1440x1024') failPreflight('this worker only accepts the 1440x1024 primary viewport contract.'); + +const candidate = path.resolve(get('--candidate-dir')); +const fixture = get('--fixture-id'); +const out = path.join(candidate, 'execution'); +const checks = {}; +const artifacts = {}; +const log = []; +const hash = (bytes) => `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`; +const json = (file) => JSON.parse(fs.readFileSync(file, 'utf8')); +const fixtureSpec = json(path.join(config.repo, 'benchmarks', 'authoring', 'fixtures.json')).fixtures.find((value) => value.id === fixture); +if (!fixtureSpec) failPreflight(`fixture ${fixture} is not in ${config.repo}/benchmarks/authoring/fixtures.json.`); +const declaredFidelityViewports = (() => { + const captures = fixtureSpec.assertions?.fidelity?.viewportCaptures; + if (!Array.isArray(captures) || captures.length === 0) failPreflight(`fixture ${fixture} must declare at least one fidelity viewport capture.`); + const seen = new Set(); + return captures.map((capture) => { + if (typeof capture !== 'string') failPreflight(`fixture ${fixture} has a non-string fidelity viewport capture.`); + const match = /^(\d+)x(\d+)$/.exec(capture); + if (!match || Number(match[1]) < 1 || Number(match[2]) < 1) failPreflight(`fixture ${fixture} has an invalid fidelity viewport capture: ${capture}.`); + if (seen.has(capture)) failPreflight(`fixture ${fixture} declares duplicate fidelity viewport capture: ${capture}.`); + seen.add(capture); + return { name: capture, width: Number(match[1]), height: Number(match[2]) }; + }); +})(); +for (const file of [candidate, get('--candidate-plan'), get('--source'), get('--expected-plan')]) { + if (!fs.existsSync(file)) failPreflight(`required candidate input does not exist: ${file}`); +} +fs.mkdirSync(out, { recursive: true }); + +function save(name, value, ext = 'json') { + const file = path.join(out, `${name}.${ext}`); + fs.writeFileSync(file, ext === 'json' ? `${JSON.stringify(value, null, 2)}\n` : value); + artifacts[name] = { path: path.relative(candidate, file) }; + return file; +} +function check(name, pass, detail, evidence) { checks[name] = { pass, detail, evidence }; } +function run(command, argv, cwd = out, timeout = 180000) { + const result = cp.spawnSync(command, argv, { cwd, encoding: 'utf8', timeout, maxBuffer: 30 * 1024 * 1024, env: process.env }); + log.push({ command, args: argv, cwd, status: result.status, stdout: result.stdout, stderr: result.stderr, error: result.error?.message }); + if (result.status !== 0) throw new Error(`${command} exited ${result.status}: ${(result.stderr || result.error?.message || '').slice(-1200)}`); + return result.stdout; +} +function wp(argv) { return run(config.docker, ['exec', config.wordpress.container, 'wp', ...argv]); } +function coverageEvidence() { + const file = path.join(candidate, 'source-coverage.json'); + if (!fs.existsSync(file)) throw new Error('runner-owned source-coverage.json is required before runtime style scoring.'); + const report = json(file); + if (!report || report.valid !== true || !report.source || !report.coverage || !Array.isArray(report.coverage.styles)) { + throw new Error('source-coverage.json must report valid:true plus canonical source and coverage.styles.'); + } + const sourceBytes = fs.readFileSync(get('--source')); + const expectedHash = typeof report.source.sha256 === 'string' ? report.source.sha256.replace(/^sha256:/, '') : ''; + if (!/^[a-f0-9]{64}$/i.test(expectedHash) || hash(sourceBytes) !== `sha256:${expectedHash}`) { + throw new Error('source-coverage.json source hash does not bind to the supplied source bytes.'); + } + const cssFiles = (args['--source-dependency'] || []).filter((file) => /\.css$/i.test(file)); + const css = cssFiles.length ? cssFiles.map((file) => fs.readFileSync(file, 'utf8')).join('') + : [...sourceBytes.toString('utf8').matchAll(/]*>([\s\S]*?)<\/style\s*>/gi)].map((match) => match[1]).join('\n'); + const rawStylesheetSha256 = hash(css); + const declaredInputStylesheetSha256 = report.inputStylesheetSha256; + const effectiveStylesheetSha256 = report.coverage.stylesheet?.sha256; + const normalizedHash = (value, label) => { + if (typeof value !== 'string' || !/^(?:sha256:)?[a-f0-9]{64}$/i.test(value)) throw new Error(`${label} must be a SHA-256 digest.`); + return `sha256:${value.replace(/^sha256:/i, '').toLowerCase()}`; + }; + if (declaredInputStylesheetSha256 !== undefined) { + if (rawStylesheetSha256 !== normalizedHash(declaredInputStylesheetSha256, 'source-coverage inputStylesheetSha256')) { + throw new Error('inputStylesheetSha256 does not bind to the raw supplied CSS or inline stylesheet bytes.'); + } + } else if (effectiveStylesheetSha256 && rawStylesheetSha256 !== normalizedHash(effectiveStylesheetSha256, 'source-coverage coverage.stylesheet.sha256')) { + // Receipts created before raw/effective stylesheet hashes were split used the effective field for raw input. + throw new Error('validated stylesheet hash does not bind to the supplied CSS bytes.'); + } + return { ...report, stylesheetHashes: { raw: rawStylesheetSha256, input: declaredInputStylesheetSha256 ?? null, effective: effectiveStylesheetSha256 ?? null, validation: declaredInputStylesheetSha256 === undefined ? 'legacy-effective-as-raw' : 'raw-input' } }; +} +function sourceDeclarations(files) { + const declarations = []; + for (const file of files) { + const text = fs.readFileSync(file, 'utf8'); + const styles = file.endsWith('.css') ? [text] : [...text.matchAll(/]*>([\s\S]*?)<\/style>/gi)].map((match) => match[1]); + for (const css of styles) { + postcss.parse(css, { from: file }).walkDecls((declaration) => declarations.push({ + selector: declaration.parent.selector || declaration.parent.name || 'inline', property: declaration.prop, value: declaration.value, + })); + } + } + return declarations; +} +function scopedStyleSelectors(report, styles) { + const root = `.wp-block-${report.canonicalPlan.target.name.replace('/', '-')}`; + const eligible = styles.map((style, index) => ({ style, index })).filter(({ style }) => ['literal', 'scoped-css'].includes(style?.outcome)); + if (eligible.length === 0) return new Map(); + const input = { root, foundation: report.canonicalPlan.styles?.foundation, styles: eligible.map(({ style, index }) => ({ index, transportSelector: style.transportSelector, source: style.source })) }; + const inputFile = path.join(out, 'style-scoper-input.json'); + fs.writeFileSync(inputFile, `${JSON.stringify(input, null, 2)}\n`); + const helper = path.join(config.repo, 'scripts', 'authoring-runtime-style-contract.ts'); + if (!fs.existsSync(helper)) throw new Error('production runtime style-contract helper is missing.'); + const result = JSON.parse(run(process.execPath, ['--import', 'tsx', helper, inputFile], config.repo)); + if (!Array.isArray(result.entries) || result.entries.length !== eligible.length) throw new Error('production runtime style-contract returned incomplete selector evidence.'); + const selectors = new Map(); + for (const entry of result.entries) { + if (!Number.isInteger(entry?.index) || typeof entry.renderedSelector !== 'string' || selectors.has(entry.index)) { + throw new Error('production runtime style-contract returned invalid selector evidence.'); + } + selectors.set(entry.index, entry.renderedSelector); + } + if (eligible.some(({ index }) => !selectors.has(index))) throw new Error('production runtime style-contract omitted a CSS coverage selector.'); + save('styleScoperContract', { input, entries: result.entries, implementation: 'src/author/styles.ts scopeLocalSelectorList via node --import tsx' }); + return selectors; +} +function sourceStyleLedger(report) { + const sourceFiles = [get('--source'), ...(args['--source-dependency'] || [])].filter((file) => /\.(?:css|html?)$/i.test(file)); + const declarations = sourceDeclarations(sourceFiles); + const styles = report.coverage.styles; + if (declarations.length > 0 && styles.length === 0) { + throw new Error('source contains stylesheet or inline declarations but validated canonical coverage.styles is empty.'); + } + const expectedSelectors = scopedStyleSelectors(report, styles); + const emitted = []; + for (const cssFile of ['style.scss', 'editor.scss']) { + const file = path.join(candidate, cssFile); + if (!fs.existsSync(file)) throw new Error(`generated stylesheet is missing: ${cssFile}`); + postcss.parse(fs.readFileSync(file, 'utf8'), { from: file }).walkDecls((declaration) => emitted.push({ + selector: declaration.parent.selector, property: declaration.prop, value: declaration.value, + scope: cssFile === 'editor.scss' ? 'editor' : 'shared', + atRules: (() => { const rules = []; for (let parent = declaration.parent.parent; parent; parent = parent.parent) if (parent.type === 'atrule') rules.unshift(`@${parent.name}${parent.params ? ' ' + parent.params : ''}`); return rules; })(), + })); + } + const owners = { native: 'block', preset: 'theme', literal: 'block', 'scoped-css': 'block', warned: 'unsupported', blocked: 'unsupported' }; + const entries = styles.map((style, index) => { + const outcome = style?.outcome; + const owner = owners[outcome] || 'unsupported'; + const expectedSelector = expectedSelectors.get(index); + const cssFulfilled = ['literal', 'scoped-css'].includes(outcome) + ? emitted.some((value) => value.property === style.property && emittedStyleValueMatches(style.property, value.value, style.value) + && value.scope === style.scope && JSON.stringify(value.atRules) === JSON.stringify(style.atRules || []) + && value.selector === expectedSelector) + : null; + const fulfilled = ['native', 'preset'].includes(outcome) ? true : cssFulfilled === true; + return { + selector: style?.source?.selector || 'source coverage', property: style?.property ?? 'unknown', value: style?.value ?? 'unknown', owner, + transportSelector: style?.transportSelector, expectedRenderedSelector: expectedSelector, + editorControl: owner === 'unsupported' ? 'unverified; not credited as preserved' : outcome === 'native' ? 'validated canonical native target' : outcome === 'preset' ? 'validated canonical theme preset' : 'validated emitted block CSS', + source: style?.source || { entry: report.source.entry, sha256: report.source.sha256 }, outcome, fulfilled, + detail: style?.reason || 'source-bound canonical coverage validated before runtime execution', + }; + }); + fs.writeFileSync(path.join(candidate, 'style-ledger.json'), `${JSON.stringify({ version: 1, entries }, null, 2)}\n`); + const unverified = entries.filter((entry) => entry.owner === 'unsupported' || !entry.fulfilled); + save('styleReport', { + sourceCoverage: { source: report.source, validated: report.valid, sourceDeclarationCount: declarations.length, coverageEntryCount: entries.length, stylesheetHashes: report.stylesheetHashes }, + entries, emitted, remainingUnverified: unverified, + limitation: 'Coverage fulfillment is bound to runner-validated canonical analysis; unsupported or unverified entries are recorded and are never credited as preserved.', + }); + return { entries, unverified }; +} +function authoredInteractionSelectors(coverage) { + const selectors = { hover: [], focus: [] }; + for (const style of coverage?.styles || []) { + const source = style?.source?.selector; + if (typeof source !== 'string') continue; + const states = { hover: /:hover\b/i.test(source), focus: /:focus(?:-visible|-within)?\b/i.test(source) }; + if (!states.hover && !states.focus) continue; + const local = source.replace(/:(?:hover|focus(?:-visible|-within)?|active)\b(?:\([^)]*\))?/gi, '').trim() || '*'; + if (states.hover) selectors.hover.push(local); + if (states.focus) selectors.focus.push(local); + } + return selectors; +} +async function interactionStates(root, applicability) { + const selector = 'a[href],button,input,select,textarea'; + const selectors = Array.isArray(applicability) ? null : authoredInteractionSelectors(applicability); + const read = (index) => root.evaluate((element, { selector: value, index: target }) => { + const node = [...element.querySelectorAll(value)][target]; + if (!node) return null; + const style = getComputedStyle(node); + return { + tag: node.tagName, text: (node.textContent || '').trim(), href: node.getAttribute('href'), rect: (() => { const box = node.getBoundingClientRect().toJSON(); return { ...box, x: box.x + scrollX, y: box.y + scrollY }; })(), + style: { color: style.color, backgroundColor: style.backgroundColor, outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, outlineOffset: style.outlineOffset, outlineColor: style.outlineColor, boxShadow: style.boxShadow, transform: style.transform, textDecorationLine: style.textDecorationLine }, + }; + }, { selector, index }); + const locator = root.locator(selector); + const page = root.page(); + const settle = async (target) => { + const milliseconds = await target.evaluate((node) => { + const style = getComputedStyle(node); + const ms = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000); + const durations = style.transitionDuration.split(',').map(ms), delays = style.transitionDelay.split(',').map(ms); + return Math.min(1500, Math.max(0, ...durations.map((duration, index) => duration + (delays[index % delays.length] || 0))) + 50); + }); + await page.waitForTimeout(milliseconds); + }; + const reset = async (target) => { await page.mouse.move(0, 0); await page.evaluate(() => document.activeElement?.blur()); await settle(target); }; + const count = await locator.count(); + const result = []; + for (let index = 0; index < count; index += 1) { + const target = locator.nth(index); + const states = selectors ? await target.evaluate((node, source) => { + const matches = (values) => values.some((value) => { try { return node.matches(value); } catch { return false; } }); + return { hover: matches(source.hover), focus: matches(source.focus) }; + }, selectors) : applicability[index] || { hover: false, focus: false }; + await reset(target); + const base = await read(index); + let hover = null; let focus = null; let hoverError = null; let focusError = null; + if (states.hover) try { await target.hover(); await settle(target); hover = await read(index); } catch (error) { hoverError = String(error); } + if (states.focus) try { await reset(target); await page.keyboard.press('Tab'); await target.focus(); await settle(target); focus = await read(index); } catch (error) { focusError = String(error); } + const changed = (before, after) => before && after && Object.keys(before.style).filter((key) => before.style[key] !== after.style[key]); + result.push({ index, states, base, hover, focus, hoverChanged: changed(base, hover) || [], focusChanged: changed(base, focus) || [], hoverError, focusError }); + await reset(target); + } + return result; +} +function interactionAgreement(source, actual) { + const results = source.map((entry, index) => { + const candidate = actual[index]; + const applicable = Boolean(entry.states?.hover || entry.states?.focus); + const identity = !!candidate && entry.base?.tag === candidate.base?.tag && entry.base?.text === candidate.base?.text && entry.base?.href === candidate.base?.href; + const hover = !entry.states?.hover || (!entry.hoverError && !candidate?.hoverError && entry.hoverChanged.every((key) => candidate?.hover?.style[key] === entry.hover?.style[key])); + const focus = !entry.states?.focus || (!entry.focusError && !candidate?.focusError && entry.focusChanged.every((key) => candidate?.focus?.style[key] === entry.focus?.style[key])); + const displacement = !entry.states?.hover || ['x', 'y'].every((axis) => Math.abs(((entry.hover?.rect[axis] || 0) - (entry.base?.rect[axis] || 0)) - ((candidate?.hover?.rect[axis] || 0) - (candidate?.base?.rect[axis] || 0))) <= 1); + return { index, applicable, identity, displacement, sourceHoverChanged: entry.hoverChanged, candidateHoverChanged: candidate?.hoverChanged || [], sourceFocusChanged: entry.focusChanged, candidateFocusChanged: candidate?.focusChanged || [], pass: !applicable || (identity && hover && focus && displacement) }; + }); + const applicable = results.some((result) => result.applicable); + return { applicable, pass: source.length === actual.length && results.every((result) => result.pass), results }; +} +function patternOverrideName(node) { + const source = String(node); const normalized = source.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'field'; + let value = 0x811c9dc5; + for (let index = 0; index < source.length; index += 1) { value ^= source.charCodeAt(index); value = Math.imul(value, 0x01000193); } + return `block-runner-${normalized.slice(0, 48)}-${(value >>> 0).toString(36).padStart(7, '0')}`; +} +function patternRequirement(name) { + return { + 'heading.content': { block: 'core/heading', attribute: 'content' }, + 'body.content': { block: 'core/paragraph', attribute: 'content' }, + background: { block: 'core/group', attribute: 'style' }, + 'button.text': { block: 'core/button', attribute: 'text' }, + 'button.url': { block: 'core/button', attribute: 'url' }, + 'button.linkTarget': { block: 'core/button', attribute: 'linkTarget' }, + 'button.rel': { block: 'core/button', attribute: 'rel' }, + }[name]; +} +async function exerciseNamedPattern(page, canonicalPlan, originalPost) { + const expected = fixtureSpec.assertions.pattern || {}; + if (expected.expected === 'not-used') return { ready: Boolean(canonicalPlan?.pattern?.ready), exercised: false, pass: !canonicalPlan?.pattern?.ready, reason: 'Fixture explicitly requires no pattern.' }; + if (expected.expected !== 'named-pattern-used-with-whitelisted-overrides') return { ready: Boolean(canonicalPlan?.pattern?.ready), exercised: false, pass: false, reason: `No runtime evaluator for ${JSON.stringify(expected.expected)}.` }; + const blocksByNode = new Map(); + const visit = (nodes) => nodes.forEach((node) => { if (node.id) blocksByNode.set(node.id, node.block); visit(node.children || []); }); + visit(canonicalPlan?.structure || []); + const declared = new Set((canonicalPlan?.pattern?.overrides || []).map((override) => override.field)); + const actual = (canonicalPlan?.fields || []).filter((field) => field.mode === 'override').map((field) => ({ id: field.id, node: field.node, block: blocksByNode.get(field.node), attribute: field.attribute, declared: declared.has(field.id) })); + const allowed = Array.isArray(expected.allowed) ? expected.allowed : []; + const required = allowed.map((name) => { + const requirement = patternRequirement(name); + const fields = requirement ? actual.filter((field) => field.block === requirement.block && field.attribute === requirement.attribute && field.declared).map((field) => field.id) : []; + return { name, requirement, fields, present: fields.length > 0 }; + }); + const unexpected = actual.filter((field) => field.declared && !allowed.some((name) => { const requirement = patternRequirement(name); return requirement?.block === field.block && requirement.attribute === field.attribute; })); + const contractPass = canonicalPlan?.pattern?.ready === true && required.every((item) => item.present) && unexpected.length === 0 && actual.every((field) => field.declared); + const background = required.find((item) => item.name === 'background'); + const evidence = { expected, canonical: { ready: canonicalPlan?.pattern?.ready, required, actual, unexpected }, originalContractGap: background && !background.present ? 'The fixture requests per-instance background, but core/pattern-overrides has no core/group style binding; background is exercised only as a canonical pattern update.' : null, overrideEditing: { method: 'Block-editor API: find each bound descendant’s enclosing core/block and update its content override map. This is native API evidence, not a UI-interaction claim.' }, registration: null, insertion: null, edits: [], persisted: null, canonicalUpdate: null, structural: null, pass: false }; + try { + const canonicalMarkup = await page.evaluate((name) => { + const root = wp.data.select('core/block-editor').getBlocks().find((block) => block.name === name); + return root ? wp.blocks.serialize([root]) : null; + }, canonicalPlan.target.name); + if (!canonicalMarkup) throw new Error('The compiled block was unavailable to serialize as synced-pattern content.'); + const title = `Block Runner runtime ${fixture}`; + const registration = await page.evaluate(async ({ title: patternTitle, content }) => { + try { const saved = await wp.apiFetch({ path: '/wp/v2/blocks', method: 'POST', data: { title: patternTitle, status: 'publish', content } }); return { id: Number(saved?.id), title: saved?.title?.raw, content: saved?.content?.raw }; } catch (error) { return { error: String(error) }; } + }, { title, content: canonicalMarkup }); + evidence.registration = registration; + if (!Number.isInteger(registration.id) || registration.id <= 0 || registration.title !== title || typeof registration.content !== 'string') throw new Error('WordPress did not register a named synced wp_block through its REST API.'); + const patternPost = Number(wp(['post', 'create', `--post_title=Pattern runtime ${fixture}`, '--post_status=publish', '--post_content=', '--porcelain']).trim()); + await page.goto(`${config.wordpress.url}/wp-admin/post.php?post=${patternPost}&action=edit`); await page.waitForFunction(() => window.wp?.data?.select('core/block-editor')?.getBlocks); + await page.evaluate((ref) => { + const dispatch = wp.data.dispatch('core/block-editor'); + dispatch.insertBlocks(wp.blocks.createBlock('core/block', { ref })); + dispatch.insertBlocks(wp.blocks.createBlock('core/block', { ref })); + }, registration.id); + await page.waitForFunction((ref) => wp.data.select('core/block-editor').getBlocks().filter((block) => block.name === 'core/block' && Number(block.attributes?.ref) === ref).length === 2, registration.id); + await page.waitForFunction((ref) => { + const select = wp.data.select('core/block-editor'); + const roots = select.getBlocks().filter((block) => block.name === 'core/block' && Number(block.attributes?.ref) === ref); + return roots.length === 2 && roots.every((root) => select.getBlocks(root.clientId).length > 0); + }, registration.id); + const insertion = await page.evaluate((ref) => { + const select = wp.data.select('core/block-editor'); + const roots = select.getBlocks().filter((block) => block.name === 'core/block' && Number(block.attributes?.ref) === ref); + return roots.map((root) => ({ clientId: root.clientId, ref: root.attributes.ref, children: select.getBlocks(root.clientId).map((block) => block.name) })); + }, registration.id); + evidence.insertion = insertion; + if (insertion.length !== 2 || insertion.some((instance) => !instance.clientId)) throw new Error('Named synced pattern insertion did not create exactly two core/block references.'); + const bound = await page.evaluate((ids) => { + const select = wp.data.select('core/block-editor'); const descend = (blocks) => blocks.flatMap((block) => [block, ...descend(select.getBlocks(block.clientId) || [])]); + return ids.map((id) => { const root = select.getBlock(id); return root ? descend(select.getBlocks(id) || []).map((block) => ({ clientId: block.clientId, block: block.name, name: block.attributes?.metadata?.name })) : []; }); + }, insertion.map((instance) => instance.clientId)); + const editable = actual.filter((field) => field.declared && field.node && ['content', 'text', 'url', 'linkTarget', 'rel'].includes(field.attribute)); + for (const [instanceIndex, instance] of insertion.entries()) for (const field of editable) { + const name = patternOverrideName(field.node); const target = bound[instanceIndex].find((item) => item.name === name && item.block === field.block); const value = field.attribute === 'url' ? `/pattern-runtime-${instanceIndex}-${field.id}` : field.attribute === 'linkTarget' ? instanceIndex === 0 ? '_blank' : '_self' : field.attribute === 'rel' ? instanceIndex === 0 ? 'noopener' : 'noreferrer' : `Pattern runtime ${instanceIndex} ${field.id}`; + if (!target) { evidence.edits.push({ instanceIndex, field, name, ok: false, reason: 'Bound native target is absent from inserted core/block.' }); continue; } + const update = await page.evaluate(({ descendantId, expectedRootId, metadataName, attribute, value: next }) => { + const select = wp.data.select('core/block-editor'); const dispatch = wp.data.dispatch('core/block-editor'); + const [rootId] = select.getBlockParentsByBlockName(descendantId, 'core/block', true); + if (!rootId || rootId !== expectedRootId) return { ok: false, rootId: rootId || null, reason: 'Bound descendant does not resolve to its expected core/block instance.' }; + const current = select.getBlockAttributes(rootId)?.content; + const overrides = current && typeof current === 'object' && !Array.isArray(current) ? current : {}; + const entry = overrides[metadataName] && typeof overrides[metadataName] === 'object' && !Array.isArray(overrides[metadataName]) ? overrides[metadataName] : {}; + const content = { ...overrides, [metadataName]: { ...entry, [attribute]: next } }; + dispatch.updateBlockAttributes(rootId, { content }); + return { ok: true, rootId, content }; + }, { descendantId: target.clientId, expectedRootId: instance.clientId, metadataName: name, attribute: field.attribute, value }); + const stored = update.ok && await page.waitForFunction(({ rootId, metadataName, attribute, value: next }) => JSON.stringify(wp.data.select('core/block-editor').getBlock(rootId)?.attributes?.content?.[metadataName]?.[attribute]) === JSON.stringify(next), { rootId: instance.clientId, metadataName: name, attribute: field.attribute, value }).then(() => true).catch(() => false); + evidence.edits.push({ instanceIndex, field, name, target, value, update, ok: stored }); + } + await page.evaluate(() => wp.data.dispatch('core/editor').savePost()); await page.waitForFunction(() => !wp.data.select('core/editor').isSavingPost() && !wp.data.select('core/editor').isEditedPostDirty()); + await page.reload(); await page.waitForFunction(() => window.wp?.data?.select('core/block-editor')?.getBlocks?.().length); + await page.waitForFunction((ref) => { + const select = wp.data.select('core/block-editor'); const roots = select.getBlocks().filter((block) => block.name === 'core/block' && Number(block.attributes?.ref) === ref); + return roots.length === 2 && roots.every((root) => select.getBlocks(root.clientId).length > 0); + }, registration.id); + const persisted = await page.evaluate((ref) => { const select = wp.data.select('core/block-editor'); return select.getBlocks().filter((block) => block.name === 'core/block' && Number(block.attributes?.ref) === ref).map((root) => ({ clientId: root.clientId, content: JSON.parse(JSON.stringify(root.attributes?.content || {})), children: select.getBlocks(root.clientId).map((block) => block.name) })); }, registration.id); + evidence.persisted = persisted; + const canonicalUpdate = await page.evaluate(async ({ ref, content }) => { + try { + const blocks = wp.blocks.parse(content); const visit = (nodes) => nodes.flatMap((block) => [block, ...visit(block.innerBlocks || [])]); const group = visit(blocks).find((block) => block.name === 'core/group'); + if (!group) return { ok: false, error: 'Compiled synced pattern has no core/group for its canonical background update.', content: '' }; + group.attributes.style = { ...(group.attributes.style || {}), color: { ...(group.attributes.style?.color || {}), background: '#123456' } }; + const updated = wp.blocks.serialize(blocks); const saved = await wp.apiFetch({ path: `/wp/v2/blocks/${ref}`, method: 'POST', data: { content: updated } }); + return { ok: saved?.content?.raw === updated, content: saved?.content?.raw ?? '', background: '#123456' }; + } catch (error) { return { ok: false, error: String(error), content: '' }; } + }, { ref: registration.id, content: canonicalMarkup }); + if (canonicalUpdate.ok) { + await page.reload(); await page.waitForFunction(() => window.wp?.data?.select('core/block-editor')?.getBlocks?.().length); + await page.waitForFunction((ref) => { const select = wp.data.select('core/block-editor'); const roots = select.getBlocks().filter((block) => block.name === 'core/block' && Number(block.attributes?.ref) === ref); return roots.length === 2 && roots.every((root) => select.getBlocks(root.clientId).length > 0); }, registration.id); + } + const afterUpdate = canonicalUpdate.ok ? await page.evaluate((ref) => { const select = wp.data.select('core/block-editor'); const roots = select.getBlocks().filter((block) => block.name === 'core/block' && Number(block.attributes?.ref) === ref); const visit = (nodes) => nodes.flatMap((block) => [block, ...visit(select.getBlocks(block.clientId) || [])]); return roots.map((root) => ({ content: JSON.parse(JSON.stringify(root.attributes?.content || {})), background: visit(select.getBlocks(root.clientId) || []).find((block) => block.name === 'core/group')?.attributes?.style?.color?.background, structure: visit(select.getBlocks(root.clientId) || []).map((block) => block.name) })); }, registration.id) : null; + evidence.canonicalUpdate = { ...canonicalUpdate, afterUpdate }; + const structural = await page.evaluate(({ ref, marker: attempted }) => { + const select = wp.data.select('core/block-editor'); const roots = select.getBlocks().filter((block) => block.name === 'core/block' && Number(block.attributes?.ref) === ref); if (roots.length !== 2) return { attempted: false, rejected: false, reason: 'Expected two core/block instances after reload.' }; + const before = roots.map((root) => select.getBlocks(root.clientId).map((block) => block.name)); wp.data.dispatch('core/block-editor').insertBlocks(wp.blocks.createBlock('core/paragraph', { content: attempted }), undefined, roots[0].clientId); const after = roots.map((root) => select.getBlocks(root.clientId).map((block) => block.name)); + return { attempted: 'insert core/paragraph into first core/block', before, after, rejected: JSON.stringify(before) === JSON.stringify(after) }; + }, { ref: registration.id, marker: 'Structural override must be rejected.' }); + evidence.structural = structural; + const editsPass = evidence.edits.length === editable.length * 2 && evidence.edits.every((item) => item.ok); + const persistedPass = editsPass && persisted.length === 2 && evidence.edits.every((item) => JSON.stringify(persisted[item.instanceIndex]?.content?.[item.name]?.[item.field.attribute]) === JSON.stringify(item.value)); + const distinctPass = editable.every((field) => { const values = evidence.edits.filter((item) => item.field.id === field.id).map((item) => item.value); return values.length === 2 && values[0] !== values[1]; }); + const canonicalPass = canonicalUpdate.ok && afterUpdate?.length === 2 && afterUpdate.every((instance, index) => instance.background === canonicalUpdate.background && JSON.stringify(instance.content) === JSON.stringify(persisted[index]?.content)); + const frontend = await page.goto(`${config.wordpress.url}/?p=${patternPost}`).then(() => page.locator('body').evaluate((body, entries) => ({ text: body.innerText, links: [...body.querySelectorAll('a')].map((link) => ({ text: link.textContent?.trim(), href: link.getAttribute('href'), target: link.getAttribute('target'), rel: link.getAttribute('rel') })), entries }), evidence.edits)).catch((error) => ({ error: String(error) })); + const frontendPass = !frontend.error && evidence.edits.every((item) => item.field.attribute === 'url' ? frontend.links.some((link) => link.href === item.value) : item.field.attribute === 'linkTarget' ? frontend.links.some((link) => link.target === item.value) : item.field.attribute === 'rel' ? frontend.links.some((link) => link.rel === item.value) : frontend.text.includes(item.value)); + evidence.frontend = { ...frontend, pass: frontendPass }; + evidence.pass = contractPass && editsPass && persistedPass && distinctPass && canonicalPass && structural.rejected && frontendPass; + } catch (error) { evidence.error = error.message; } finally { + await page.goto(`${config.wordpress.url}/wp-admin/post.php?post=${originalPost}&action=edit`).catch(() => {}); await page.waitForFunction(() => window.wp?.data?.select('core/block-editor')?.getBlocks).catch(() => {}); + } + return evidence; +} +function repeatedPlanGroup(nodes, parentPath = []) { + const candidates = []; + const visit = (siblings, path) => { + const names = siblings.map((node) => node.block || node.name); + for (const name of new Set(names)) { + const indices = names.map((candidate, index) => candidate === name ? index : -1).filter((index) => index >= 0); + if (indices.length < 2) continue; + const children = indices.reduce((total, index) => total + (siblings[index].children || []).length, 0); + // Reorder a repeated component, never coincidental flat text siblings in a section preamble. + candidates.push({ parentPath: path, block: name, indices, score: (children > 0 ? 100000 : 0) + indices.length * 100 + children * 10 + path.length }); + } + siblings.forEach((node, index) => visit(node.children || [], path.concat(index))); + }; + visit(nodes, parentPath); + const selected = candidates.sort((first, second) => second.score - first.score)[0]; + return selected && { parentPath: selected.parentPath, block: selected.block, indices: selected.indices }; +} +async function exerciseCollectionWorkflow(page, canonicalPlan, blockName) { + if (!['cards', 'repeater'].includes(fixtureSpec.family)) return { applicable: false, pass: true }; + if (canonicalPlan?.locking?.mode !== 'none') return { applicable: true, pass: false, reason: `Fixture requires structural collection operations but canonical locking is ${canonicalPlan?.locking?.mode || 'unspecified'}.` }; + const planned = repeatedPlanGroup(canonicalPlan?.structure || []); + if (!planned) return { applicable: true, pass: false, reason: 'Canonical plan contains no repeated sibling native blocks to exercise.' }; + const operation = await page.evaluate(({ name, plan, family }) => { + const select = wp.data.select('core/block-editor'); const dispatch = wp.data.dispatch('core/block-editor'); + const root = select.getBlocks().find((block) => block.name === name); if (!root) return { pass: false, reason: 'Compiled root block is absent.' }; + let parent = root; for (const index of plan.parentPath) parent = parent?.innerBlocks?.[index]; + if (!parent?.clientId) return { pass: false, reason: 'Plan repeated-sibling parent is absent from the native tree.', plan }; + const fingerprint = (block) => JSON.stringify({ name: block.name, attributes: block.attributes, innerBlocks: (block.innerBlocks || []).map(fingerprint) }); + const repeated = (parent.innerBlocks || []).filter((block) => block.name === plan.block); + if (repeated.length < 2) return { pass: false, reason: 'Live native tree does not contain the planned repeated sibling group.', plan, repeated: repeated.map(fingerprint) }; + const parentId = parent.clientId; const before = repeated.map(fingerprint); let add = { attempted: false, accepted: false, beforeCount: repeated.length, afterCount: repeated.length }; + if (family === 'repeater') { + add.attempted = true; const clone = wp.blocks.cloneBlock(repeated[0]); dispatch.insertBlocks(clone, undefined, parentId); + const afterAdd = (select.getBlock(parentId)?.innerBlocks || []).filter((block) => block.name === plan.block); add = { ...add, afterCount: afterAdd.length, accepted: afterAdd.length === repeated.length + 1 }; + } + const candidates = (select.getBlock(parentId)?.innerBlocks || []).filter((block) => block.name === plan.block); + const beforeReorder = candidates.map(fingerprint); const moving = candidates[candidates.length - 1]; + dispatch.moveBlocksToPosition([moving.clientId], parentId, parentId, (select.getBlock(parentId)?.innerBlocks || []).indexOf(candidates[0])); + const after = (select.getBlock(parentId)?.innerBlocks || []).filter((block) => block.name === plan.block).map(fingerprint); + return { pass: (family !== 'repeater' || add.accepted) && JSON.stringify(beforeReorder) !== JSON.stringify(after), plan, parentId, before, add, beforeReorder, after }; + }, { name: blockName, plan: planned, family: fixtureSpec.family }); + if (!operation.pass) return { applicable: true, ...operation, persisted: null }; + await page.evaluate(() => wp.data.dispatch('core/editor').savePost()); + await page.waitForFunction(() => !wp.data.select('core/editor').isSavingPost() && !wp.data.select('core/editor').isEditedPostDirty()); + await page.reload(); await page.waitForFunction(() => window.wp?.data?.select('core/block-editor')?.getBlocks?.().length); + const persisted = await page.evaluate(({ name, plan }) => { + const root = wp.data.select('core/block-editor').getBlocks().find((block) => block.name === name); let parent = root; for (const index of plan.parentPath) parent = parent?.innerBlocks?.[index]; + const fingerprint = (block) => JSON.stringify({ name: block.name, attributes: block.attributes, innerBlocks: (block.innerBlocks || []).map(fingerprint) }); + return parent ? (parent.innerBlocks || []).filter((block) => block.name === plan.block).map(fingerprint) : null; + }, { name: blockName, plan: planned }); + const countPass = fixtureSpec.family !== 'repeater' || persisted?.length === operation.add.afterCount; + return { applicable: true, ...operation, persisted, pass: countPass && JSON.stringify(persisted) === JSON.stringify(operation.after) }; +} +async function pseudoStyles(root, coverage) { + const selectors = [...new Set(coverage.styles.map((style) => style.source?.selector).filter((selector) => /::(?:before|after)$/.test(selector || '')))]; + return root.evaluate((element, selectors) => selectors.map((selector) => { + const match = selector.match(/^(.*)(::(?:before|after))$/); + const node = element.matches(match[1]) ? element : element.querySelector(match[1]); + if (!node) return { selector, missing: true }; + const style = getComputedStyle(node, match[2]); + return { selector, content: style.content, display: style.display, width: style.width, height: style.height, backgroundColor: style.backgroundColor }; + }), selectors); +} + +let referenceServer; +async function serveReference() { + const sourceRoot = fs.realpathSync(path.join(candidate, 'source')); + referenceServer = http.createServer((request, response) => { + try { + const relative = decodeURIComponent(new URL(request.url, 'http://localhost').pathname).replace(/^\/+/, ''); + const file = fs.realpathSync(path.resolve(sourceRoot, relative)); + if (!file.startsWith(sourceRoot + path.sep) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end(); return; } + const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }; + response.writeHead(200, { 'Content-Type': types[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); + fs.createReadStream(file).pipe(response); + } catch { response.writeHead(404); response.end(); } + }); + await new Promise((resolve, reject) => { referenceServer.once('error', reject); referenceServer.listen(0, '127.0.0.1', resolve); }); + const origin = `http://127.0.0.1:${referenceServer.address().port}`; + return (file) => origin + '/' + path.relative(sourceRoot, file).split(path.sep).map(encodeURIComponent).join('/'); +} +let browser; +let pageRef; +let pluginSlug; +let runtimeComplete = false; +async function main() { + const available = Number(run('df', ['-Pk', candidate]).trim().split('\n').at(-1).trim().split(/\s+/)[3]); + if (available < 5 * 1024 * 1024) throw new Error('Disk low-water guard: less than 5GiB available.'); + run(config.docker, ['exec', config.wordpress.container, 'wp', 'core', 'version']); + const plan = json(get('--candidate-plan')); + const manifest = json(path.join(candidate, 'compiler-manifest.json')); + const sourceCoverage = coverageEvidence(); + const input = path.join(out, 'package-source'); + fs.mkdirSync(input); + for (const item of manifest.files) { + const destination = path.join(input, item.path); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(path.join(candidate, item.path), destination); + } + for (const directory of ['assets', 'fonts']) if (fs.existsSync(path.join(candidate, directory))) fs.cpSync(path.join(candidate, directory), path.join(input, directory), { recursive: true }); + const plugin = path.join(out, 'plugin'); + const preview = JSON.parse(run(config.cli, ['plugin', 'preview', input, '--standalone', plugin, '--json'])); + save('pluginPreview', { fingerprint: preview.fingerprint, notes: preview.notes }); + run(config.cli, ['plugin', 'write', input, '--standalone', plugin, '--confirm', preview.fingerprint, '--json']); + fs.symlinkSync(config.sharedDependencies, path.join(plugin, 'node_modules'), 'dir'); + run('npm', ['run', 'zip'], plugin, 220000); + run('npm', ['run', 'test:zip'], plugin, 30000); + const pluginPackage = json(path.join(plugin, 'package.json')); + pluginSlug = pluginPackage.name; + const zip = path.join(plugin, `${pluginSlug}.zip`); + fs.copyFileSync(zip, path.join(out, 'plugin.zip')); + artifacts.pluginZip = { path: 'execution/plugin.zip' }; + const lockBytes = fs.readFileSync(path.join(plugin, 'package-lock.json')); + fs.writeFileSync(path.join(out, 'dependencyLock.json'), lockBytes); + artifacts.dependencyLock = { path: 'execution/dependencyLock.json' }; + const lock = JSON.parse(lockBytes); const packages = []; + for (const relative of Object.keys(lock.packages)) { + if (!relative) continue; + const installed = path.join(plugin, relative, 'package.json'); + if (fs.existsSync(installed)) { const observed = json(installed); packages.push({ name: observed.name, version: observed.version }); } + } + save('dependencyInventory', { lockSha256: hash(lockBytes), packages, reusedInstallation: 'configured shared dependencies' }); + check('build', true, 'Production plugin preview/write, build, ZIP and ZIP policy executed successfully.', 'buildLog'); + run(config.docker, ['cp', zip, `${config.wordpress.container}:/tmp/block-runner-${fixture}.zip`]); + wp(['plugin', 'install', `/tmp/block-runner-${fixture}.zip`, '--activate', '--force']); + const coreFiles = wp(['eval', ' $files=array_merge(glob(ABSPATH."*.php"),iterator_to_array(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(ABSPATH."wp-includes",FilesystemIterator::SKIP_DOTS))),iterator_to_array(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(ABSPATH."wp-admin",FilesystemIterator::SKIP_DOTS)))); $out=[]; foreach($files as $f){$f=(string)$f;if(is_file($f))$out[str_replace(ABSPATH,"",$f)]=hash_file("sha256",$f);}ksort($out);echo json_encode($out);']); + save('coreFiles', JSON.parse(coreFiles)); + save('wordpressInventory', { version: wp(['core', 'version']).trim(), coreHash: hash(coreFiles), plugins: JSON.parse(wp(['plugin', 'list', '--format=json'])) }); + const theme = JSON.parse(wp(['theme', 'get', 'twentytwentyfive', '--format=json'])); + const themeConfig = JSON.parse(wp(['eval', 'echo file_get_contents(get_theme_file_path("theme.json"));'])); + save('themeInventory', { slug: wp(['option', 'get', 'stylesheet']).trim(), version: theme.version, configuration: themeConfig }); + + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 }); + await context.tracing.start({ screenshots: true, snapshots: true, sources: true }); + save('browserInventory', { name: 'chromium', version: browser.version(), viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 }); + const page = await context.newPage(); pageRef = page; page.setDefaultTimeout(18000); + const errors = []; page.on('pageerror', (error) => errors.push(String(error))); + await page.goto(`${config.wordpress.url}/wp-login.php`); await page.waitForLoadState('networkidle'); + await page.locator('#user_login').fill(config.wordpress.username); await page.locator('#user_pass').fill(config.wordpress.password); + await Promise.all([page.waitForURL('**/wp-admin/**'), page.locator('#loginform').evaluate((form) => form.requestSubmit())]); + const post = Number(wp(['post', 'create', `--post_title=Runtime ${fixture}`, '--post_status=publish', '--post_content=', '--porcelain']).trim()); + await page.goto(`${config.wordpress.url}/wp-admin/post.php?post=${post}&action=edit`); + await page.waitForFunction(() => window.wp?.data?.select('core/block-editor')?.getBlocks); + await page.evaluate((name) => wp.data.dispatch('core/block-editor').insertBlocks(wp.blocks.createBlock(name)), plan.target.name); + await page.waitForFunction((name) => wp.data.select('core/block-editor').getBlocks().some((block) => block.name === name && block.innerBlocks.length), plan.target.name); + const tree = await page.evaluate(() => wp.data.select('core/block-editor').getBlocks()); + const positions = {}; function walk(nodes, indices = []) { nodes.forEach((node, index) => { positions[node.id] = indices.concat(index); walk(node.children || [], indices.concat(index)); }); } walk(plan.structure); + const mediaNodes = new Set(plan.fields.filter((field) => field.mode === 'editable' && field.attribute === 'id').map((field) => field.node)); + let runtimeMedia = null; + if (mediaNodes.size) { + const mediaFile = path.join(out, `runtime-media-${process.pid}.png`); + fs.writeFileSync(mediaFile, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL0NwAAAABJRU5ErkJggg==', 'base64')); + const containerFile = `/tmp/block-runner-runtime-media-${fixture}-${process.pid}.png`; + run(config.docker, ['cp', mediaFile, `${config.wordpress.container}:${containerFile}`]); + const id = Number(wp(['media', 'import', containerFile, '--porcelain']).trim()); + const url = wp(['eval', `echo wp_get_attachment_url(${id});`]).trim(); + if (!Number.isInteger(id) || id < 1 || !url) throw new Error('Could not create a valid disposable media attachment for editable core/image fields.'); + runtimeMedia = { id, url }; + } + const edits = plan.fields.filter((field) => field.mode === 'editable').map((field, index) => ({ id: field.id, path: positions[field.node], attribute: field.attribute, value: mediaNodes.has(field.node) && field.attribute === 'id' ? runtimeMedia.id : mediaNodes.has(field.node) && field.attribute === 'url' ? runtimeMedia.url : field.type === 'number' ? index + 2 : field.attribute === 'url' ? `/runtime-${index}` : typeof field.default === 'boolean' ? !field.default : `Runtime edit ${fixture} ${index}` })); + const editResults = await page.evaluate(({ name, edits: values }) => { const root = wp.data.select('core/block-editor').getBlocks().find((block) => block.name === name); return values.map((edit) => { let node = root; for (const index of edit.path || []) node = node?.innerBlocks[index]; if (!node || !edit.path) return { ...edit, error: 'native node missing' }; wp.data.dispatch('core/block-editor').updateBlockAttributes(node.clientId, { [edit.attribute]: edit.value }); return edit; }); }, { name: plan.target.name, edits }); + await page.evaluate(() => wp.data.dispatch('core/editor').savePost()); await page.reload(); await page.waitForFunction(() => window.wp?.data?.select('core/block-editor')?.getBlocks?.().length); + const persisted = await page.evaluate(({ name, edits: values }) => { const root = wp.data.select('core/block-editor').getBlocks().find((block) => block.name === name); return values.map((edit) => { let node = root; for (const index of edit.path || []) node = node?.innerBlocks[index]; return { ...edit, observed: node?.attributes[edit.attribute], pass: !!node && JSON.stringify(node.attributes[edit.attribute]) === JSON.stringify(edit.value), valid: node?.isValid }; }); }, { name: plan.target.name, edits }); + const collectionWorkflow = await exerciseCollectionWorkflow(page, sourceCoverage.canonicalPlan, plan.target.name); + save('editorReport', { post, tree, runtimeMedia, editResults, persisted, collectionWorkflow, method: 'native editor insertion and attribute updates; media id fields use a disposable WordPress attachment with its matching URL; save and reload; structural collection operations run only when plan locking permits them; not an owner editing-feel review', errors }); + check('editor', persisted.length === edits.length && persisted.every((entry) => entry.pass && entry.valid !== false) && collectionWorkflow.pass, 'Native editable fields updated, saved and compared after reload; applicable collection add/reorder workflow also persisted.', 'editorReport'); + // The named pattern is derived from the compiler's actual serialized block, not fixture markup. + await page.evaluate(async (original) => { wp.data.dispatch('core/block-editor').resetBlocks(original); await wp.data.dispatch('core/editor').savePost(); }, tree); + const patternEvidence = await exerciseNamedPattern(page, sourceCoverage.canonicalPlan, post); + await page.screenshot({ path: path.join(out, 'editor.png'), fullPage: true }); artifacts.editorScreenshot = { path: 'execution/editor.png' }; + await page.addScriptTag({ path: req.resolve('axe-core/axe.min.js') }); + const editorAxe = await page.evaluate(() => axe.run(document)); + const editorFrames = []; + for (const frame of page.frames().filter((frame) => frame !== page.mainFrame())) { + try { await frame.addScriptTag({ path: req.resolve('axe-core/axe.min.js') }); editorFrames.push(await frame.evaluate(() => axe.run(document))); } catch (error) { editorFrames.push({ error: String(error) }); } + } + save('editorAxe', editorAxe); + await page.evaluate(async (original) => { wp.data.dispatch('core/block-editor').resetBlocks(original); await wp.data.dispatch('core/editor').savePost(); }, tree); + await page.goto(`${config.wordpress.url}/?p=${post}`); await page.waitForLoadState('networkidle'); + const selector = `.wp-block-${plan.target.name.replace('/', '-')}`; const root = page.locator(selector).first(); + const frontend = await root.evaluate((element) => ({ text: element.textContent, html: element.outerHTML, width: element.getBoundingClientRect().width, overflow: element.scrollWidth > element.clientWidth + 1, images: [...element.querySelectorAll('img')].map((image) => ({ src: image.currentSrc, loaded: image.complete && image.naturalWidth > 0, alt: image.alt })), links: [...element.querySelectorAll('a')].map((link) => ({ text: link.textContent, href: link.getAttribute('href') })) })); + save('frontendReport', { post, url: page.url(), ...frontend, errors }); + check('frontend', !!frontend.text.trim() && !frontend.overflow && frontend.images.every((image) => image.loaded) && errors.length === 0, 'Saved candidate rendered; image loading, overflow and JavaScript errors observed.', 'frontendReport'); + await root.screenshot({ path: path.join(out, 'frontend.png') }); artifacts.frontendScreenshot = { path: 'execution/frontend.png' }; + await page.addScriptTag({ path: req.resolve('axe-core/axe.min.js') }); + const frontendAxe = await page.evaluate((value) => axe.run(document.querySelector(value)), selector); + const actions = await root.locator('a[href],button,input,select,textarea').count(); const focus = []; + await page.locator('body').click({ position: { x: 1, y: 1 } }); + for (let index = 0; index < 70; index += 1) { await page.keyboard.press('Tab'); const active = await page.evaluate((value) => { const element = document.activeElement; if (!document.querySelector(value)?.contains(element)) return null; const style = getComputedStyle(element); return { tag: element.tagName, text: element.textContent, href: element.getAttribute('href'), outline: style.outlineStyle, outlineWidth: style.outlineWidth, boxShadow: style.boxShadow, rect: element.getBoundingClientRect().toJSON() }; }, selector); if (active && !focus.some((entry) => JSON.stringify(entry) === JSON.stringify(active))) focus.push(active); if (focus.length >= actions && actions > 0) break; } + const semantics = await root.evaluate((element) => { const levels = [...element.querySelectorAll('h1,h2,h3,h4,h5,h6')].map((heading) => Number(heading.tagName[1])); const pseudo = [element, ...element.querySelectorAll('*')].flatMap((node, index) => ['::before', '::after'].map((which) => { const style = getComputedStyle(node, which); return { tag: node.tagName, index, pseudo: which, content: style.content, width: style.width, height: style.height, display: style.display, pointerEvents: style.pointerEvents }; })).filter((value) => !['none', 'normal'].includes(value.content)); const textualPseudo = pseudo.filter((value) => value.content.replace(/^['"]|['"]$/g, '').trim().length > 0); return { levels, hierarchy: levels.every((level, index) => index === 0 || level <= levels[index - 1] + 1), images: [...element.querySelectorAll('img')].map((image) => ({ alt: image.getAttribute('alt'), decorative: image.getAttribute('role') === 'presentation' || image.getAttribute('alt') === '' })), pseudo, textualPseudo }; }); + const focusVisible = focus.every((entry) => (entry.outline !== 'none' && parseFloat(entry.outlineWidth) > 0) || entry.boxShadow !== 'none'); + const requested = fixtureSpec.assertions.accessibility?.checks || []; + const frontendAriaSnapshot = await root.ariaSnapshot().catch((error) => ({ error: String(error) })); + + const semanticLandmarks = (element, geometry) => [...element.querySelectorAll('h1,h2,h3,p,a,img,figcaption,cite')] + // Native core/paragraph wraps a source ; compare the citation once rather than treating that wrapper as added prose. + .filter((node) => !(node.tagName === 'P' && node.children.length === 1 && node.firstElementChild?.tagName === 'CITE' && node.textContent.trim() === node.firstElementChild.textContent.trim())) + .map((node) => ({ tag: node.tagName, text: (node.textContent || '').trim(), ...(geometry ? { box: node.getBoundingClientRect().toJSON() } : { alt: node.getAttribute('alt') }) })); + const originalSource = fs.readFileSync(get('--source')); + const originalHash = hash(originalSource); + const nativeSource = //i.test(originalSource.toString('utf8')); + const reference = await context.newPage(); + let referenceRoot; + let referenceMetadata; + let referenceUrl; + let referenceFileUrl; + if (nativeSource) { + const sourceBase64 = originalSource.toString('base64'); + const referencePost = Number(wp(['eval', `$source=base64_decode('${sourceBase64}');$post=wp_insert_post(['post_title'=>'Block Runner runtime reference','post_status'=>'publish','post_content'=>$source],true);if(is_wp_error($post)){fwrite(STDERR,$post->get_error_message());exit(1);}echo $post;`]).trim()); + if (!Number.isInteger(referencePost) || referencePost < 1) throw new Error('Could not create a disposable WordPress reference post from the original source bytes.'); + const storedHash = `sha256:${wp(['eval', `echo hash('sha256',get_post_field('post_content',${referencePost}));`]).trim()}`; + if (storedHash !== originalHash) throw new Error('Disposable WordPress reference post content does not match the original source bytes.'); + referenceUrl = wp(['post', 'url', String(referencePost)]).trim(); + referenceMetadata = { mode: 'wordpress-post', postId: referencePost, originalHash, storedHash, permalink: referenceUrl, referenceRoot: '.wp-block-post-content' }; + await reference.goto(referenceUrl); await reference.waitForLoadState('networkidle'); + referenceRoot = reference.locator(referenceMetadata.referenceRoot).first(); + if (await referenceRoot.count() !== 1 || !await referenceRoot.isVisible()) throw new Error('Disposable WordPress reference post did not render one visible post-content root.'); + } else { + referenceFileUrl = await serveReference(); + referenceUrl = referenceFileUrl(get('--source')); + referenceMetadata = { mode: 'loopback-html', originalHash, permalink: referenceUrl, referenceRoot: 'body' }; + await reference.goto(referenceUrl); await reference.waitForLoadState('networkidle'); + referenceRoot = reference.locator(referenceMetadata.referenceRoot); + } + const referenceAvailability = await reference.evaluate((selector) => { + const root = document.querySelector(selector); + return { + styles: [...document.querySelectorAll('link[rel~="stylesheet"]')].map((link) => { let rules = false; let error = null; try { rules = !!link.sheet && link.sheet.cssRules.length >= 0; } catch (value) { error = String(value); } return { href: link.href, rules, error }; }), + images: [...(root?.querySelectorAll('img') || [])].map((image) => ({ src: image.currentSrc || image.src, loaded: image.complete && image.naturalWidth > 0, naturalWidth: image.naturalWidth })), + }; + }, referenceMetadata.referenceRoot); + const dependencyFiles = nativeSource ? [] : args['--source-dependency'] || []; + const expectedCss = dependencyFiles.filter((file) => /\.css$/i.test(file)).map((file) => referenceFileUrl(file)); + const expectedImages = dependencyFiles.filter((file) => /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(file)).map((file) => referenceFileUrl(file)); + const referenceAvailable = referenceAvailability.styles.every((style) => style.rules) && referenceAvailability.images.every((image) => image.loaded) && expectedCss.every((href) => referenceAvailability.styles.some((style) => style.href === href && style.rules)) && expectedImages.every((href) => referenceAvailability.images.some((image) => image.src === href && image.loaded)); + save('referenceAvailability', { ...referenceAvailability, expectedCss, expectedImages, metadata: referenceMetadata, pass: referenceAvailable }); + if (!referenceAvailable) throw new Error('Reference stylesheet or image unavailable: not a valid product measurement.'); + const sourceTags = await referenceRoot.evaluate(semanticLandmarks, false); + const sourceGeometry = await referenceRoot.evaluate(semanticLandmarks, true); + const actualTags = await root.evaluate(semanticLandmarks, false); + const actualGeometry = await root.evaluate(semanticLandmarks, true); + const sourceLayout = await referenceRoot.evaluate((element) => ({ width: element.getBoundingClientRect().width, overflow: element.scrollWidth > element.clientWidth + 1, headings: [...element.querySelectorAll('h1,h2,h3')].map((node) => ({ tag: node.tagName, text: node.textContent.trim(), width: node.getBoundingClientRect().width })) })); + const sourceInteractions = await interactionStates(referenceRoot, sourceCoverage.coverage); + const candidateInteractions = await interactionStates(root, sourceInteractions.map((entry) => entry.states)); + const interaction = interactionAgreement(sourceInteractions, candidateInteractions); + const sourcePseudos = await pseudoStyles(referenceRoot, sourceCoverage.coverage); + const candidatePseudos = await pseudoStyles(root, sourceCoverage.coverage); + const pseudoAgreement = !sourcePseudos.some((entry) => entry.missing) && JSON.stringify(sourcePseudos) === JSON.stringify(candidatePseudos); + save('pseudoReport', { source: sourcePseudos, candidate: candidatePseudos, pass: pseudoAgreement, method: 'Declared before/after content, display, size, and background computed styles.' }); + const sourceActions = sourceInteractions.length; + const keyboardApplicability = { sourceActions, candidateActions: actions, applicable: sourceActions > 0, countMatches: actions === sourceActions }; + const keyboardReachable = keyboardApplicability.countMatches && (!keyboardApplicability.applicable || focus.length >= actions); + const visibleKeyboardFocus = !keyboardApplicability.applicable || focusVisible; + const accessibilityPass = semantics.hierarchy && semantics.images.every((image) => image.decorative || !!image.alt?.trim()) && (!requested.includes('keyboard reachable action') || keyboardReachable) && (!requested.includes('visible keyboard focus') || visibleKeyboardFocus) && (!requested.includes('decorative pseudo-element excluded from accessibility tree') || semantics.textualPseudo.length === 0) && interaction.pass && pseudoAgreement && frontendAxe.violations.length === 0; + save('accessibilityReport', { editor: { rawAxe: editorAxe, frames: editorFrames, limitation: 'Raw automated editor/frame observations only; iframe coverage is best-effort and is not editor accessibility certification.' }, frontend: { rawAxe: frontendAxe, keyboard: { actions, focus, focusVisible, applicability: keyboardApplicability, reachable: keyboardReachable, visibleFocusPass: visibleKeyboardFocus }, semantics, accessibilitySnapshot: frontendAriaSnapshot }, interaction: { source: sourceInteractions, candidate: candidateInteractions, agreement: interaction, limitation: 'Observed hover/focus state changes for ordered matching controls; no pixel comparison or owner interaction review.' }, limitations: ['Empty generated pseudo content with geometry is recorded as decorative evidence. Textual generated pseudo content never automatically passes the decorative requirement; the accessibility snapshot is retained for review.', 'Keyboard action checks require the candidate action count to match the source. Zero source and candidate actions are valid and recorded as not applicable.', 'Automated browser checks only; this is not owner acceptance or manual accessibility certification.'] }); + check('accessibility', accessibilityPass, 'Observed frontend semantic/keyboard and source-matched hover/focus checks; raw editor and frontend Axe output is retained with explicit scope limits.', 'accessibilityReport'); + const distanceMatches = (a, b, c, d, key) => Math.abs((a.box[key] - b.box[key]) - (c.box[key] - d.box[key])) <= 12; + const measureFidelityViewport = async ({ viewport, candidateRoot, sourceRoot, candidateOverflow }) => { + const [viewportSourceTags, viewportSourceGeometry, viewportActualTags, viewportActualGeometry] = await Promise.all([ + sourceRoot.evaluate(semanticLandmarks, false), sourceRoot.evaluate(semanticLandmarks, true), + candidateRoot.evaluate(semanticLandmarks, false), candidateRoot.evaluate(semanticLandmarks, true), + ]); + const tagsMatch = JSON.stringify(viewportSourceTags) === JSON.stringify(viewportActualTags); + let pairs = 0; let agreements = 0; + if (tagsMatch) for (let first = 0; first < viewportSourceGeometry.length; first += 1) for (let second = first + 1; second < viewportSourceGeometry.length; second += 1) for (const axis of ['left', 'top']) { pairs += 1; if (distanceMatches(viewportSourceGeometry[first], viewportSourceGeometry[second], viewportActualGeometry[first], viewportActualGeometry[second], axis)) agreements += 1; } + const layoutAgreement = pairs ? agreements / pairs : 0; + return { viewport: { width: viewport.width, height: viewport.height }, tagsMatch, layoutAgreement, pairs, agreements, sourceTags: viewportSourceTags, actualTags: viewportActualTags, sourceGeometry: viewportSourceGeometry, actualGeometry: viewportActualGeometry, candidateOverflow, pass: tagsMatch && layoutAgreement >= 0.95 && !candidateOverflow }; + }; + const captureFidelityViewport = async ({ viewport, candidateRoot, sourceRoot, candidateOverflow }) => { + const key = viewport.name.replace(/[^a-z0-9]+/gi, '-'); + const candidateFile = path.join(out, `fidelity-${key}-candidate.png`); + const sourceFile = path.join(out, `fidelity-${key}-reference.png`); + await Promise.all([candidateRoot.screenshot({ path: candidateFile }), sourceRoot.screenshot({ path: sourceFile })]); + artifacts[`fidelity${key}CandidateScreenshot`] = { path: path.relative(candidate, candidateFile) }; + artifacts[`fidelity${key}ReferenceScreenshot`] = { path: path.relative(candidate, sourceFile) }; + return measureFidelityViewport({ viewport, candidateRoot, sourceRoot, candidateOverflow }); + }; + const fidelityViewports = []; + for (const viewport of declaredFidelityViewports) { + if (viewport.name === '1440x1024') { + fidelityViewports.push(await captureFidelityViewport({ viewport, candidateRoot: root, sourceRoot: referenceRoot, candidateOverflow: frontend.overflow })); + // Retain the established artifact names while the viewport-qualified captures prove the declared contract. + await referenceRoot.screenshot({ path: path.join(out, 'reference.png') }); artifacts.referenceScreenshot = { path: 'execution/reference.png' }; + continue; + } + const viewportContext = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1 }); + try { + const [candidatePage, sourcePage] = await Promise.all([viewportContext.newPage(), viewportContext.newPage()]); + await Promise.all([candidatePage.goto(page.url()), sourcePage.goto(referenceUrl)]); + await Promise.all([candidatePage.waitForLoadState('networkidle'), sourcePage.waitForLoadState('networkidle')]); + const viewportCandidateRoot = candidatePage.locator(selector).first(); + const viewportSourceRoot = sourcePage.locator(referenceMetadata.referenceRoot).first(); + await Promise.all([viewportCandidateRoot.waitFor({ state: 'visible' }), viewportSourceRoot.waitFor({ state: 'visible' })]); + const candidateOverflow = await viewportCandidateRoot.evaluate((element) => element.scrollWidth > element.clientWidth + 1); + fidelityViewports.push(await captureFidelityViewport({ viewport, candidateRoot: viewportCandidateRoot, sourceRoot: viewportSourceRoot, candidateOverflow })); + } finally { + await viewportContext.close(); + } + } + const primaryFidelity = fidelityViewports.find((entry) => entry.viewport.width === 1440 && entry.viewport.height === 1024); + const allViewportPass = fidelityViewports.length === declaredFidelityViewports.length && fidelityViewports.every((entry) => entry.pass); + save('fidelityReport', { comparison: 'Every fixture-declared viewport captures both loaded source and candidate roots. Each compares ordered semantic landmarks (including figcaption and cite, with a native paragraph-only cite wrapper normalized to one citation) and pairwise left/top distances within 12px at a fixed 95% agreement threshold.', threshold: 0.95, referenceAvailability: { ...referenceAvailability, expectedCss, expectedImages, pass: referenceAvailable }, viewports: fidelityViewports, allViewportPass, layoutAgreement: primaryFidelity?.layoutAgreement ?? null, pairs: primaryFidelity?.pairs ?? null, agreements: primaryFidelity?.agreements ?? null, sourceTags: primaryFidelity?.sourceTags ?? null, actualTags: primaryFidelity?.actualTags ?? null, sourceGeometry: primaryFidelity?.sourceGeometry ?? null, actualGeometry: primaryFidelity?.actualGeometry ?? null, sourceLayout, candidateOverflow: primaryFidelity?.candidateOverflow ?? null, limitation: 'DOM arrangement metric bound to loaded references at every declared viewport; it is not pixel similarity or owner visual acceptance.' }); + check('fidelity', referenceAvailable && allViewportPass, 'Every fixture-declared viewport captured loaded source and candidate roots and met the fixed DOM-layout fidelity threshold.', 'fidelityReport'); + const names = []; function namesWalk(nodes) { for (const node of nodes) { names.push(node.name); namesWalk(node.innerBlocks || []); } } namesWalk(tree); + const required = fixtureSpec.assertions.native?.mustInclude || []; save('nativeReport', { names, required }); check('native', required.every((name) => names.includes(name)) && !names.includes('core/html'), 'Observed registered native block tree against fixture requirements.', 'nativeReport'); + save('planReport', { expectedContract: json(get('--expected-plan')), candidate: plan }); check('plan', plan.fields.every((field) => positions[field.node]), 'Candidate typed compiler succeeded; every declared field targets a native node.', 'planReport'); + save('sourceReport', { manifest, sourceCoverage: { valid: sourceCoverage.valid, source: sourceCoverage.source } }); check('source', true, 'Production compiler source packaged without changing original candidate bytes.', 'sourceReport'); + const styles = sourceStyleLedger(sourceCoverage); + check('style', styles.unverified.length === 0, 'Validated source-bound canonical coverage was fulfilled; unsupported or unverified declarations are not credited as preserved.', 'styleReport'); + const requiredWarnings = fixtureSpec.assertions.warnings?.expectedCodes || []; const warningCodes = (plan.warnings || []).map((warning) => typeof warning === 'string' ? warning : warning.code); + save('warningReport', { warnings: plan.warnings, requiredWarnings, remainingUnverified: styles.unverified }); check('warnings', styles.unverified.length === 0 && requiredWarnings.every((code) => warningCodes.includes(code)), 'Warning codes and every remaining unverified source declaration were checked.', 'warningReport'); + save('patternReport', patternEvidence); check('pattern', patternEvidence.pass === true, patternEvidence.pass ? 'Named synced pattern inserted, edited through bound native fields, saved/reloaded, canonically updated, and structurally rejected.' : 'Named synced-pattern lifecycle did not prove every required runtime contract; inspect patternReport.', 'patternReport'); + if (config.smokeViewport) { + const smoke = await browser.newContext({ viewport: config.smokeViewport, deviceScaleFactor: 1 }); const smokePage = await smoke.newPage(); await smokePage.goto(page.url()); await smokePage.waitForLoadState('networkidle'); const smokeRoot = smokePage.locator(selector).first(); const observation = await smokeRoot.evaluate((element) => ({ width: element.getBoundingClientRect().width, overflow: element.scrollWidth > element.clientWidth + 1 })); await smokeRoot.screenshot({ path: path.join(out, 'frontend-smoke.png') }); artifacts.frontendSmokeScreenshot = { path: 'execution/frontend-smoke.png' }; save('smokeViewportReport', { viewport: config.smokeViewport, ...observation, limitation: 'Single narrow viewport overflow observation only; it is not a responsive fidelity or accessibility suite.' }); await smoke.close(); + } + await context.tracing.stop({ path: path.join(out, 'browser-trace.zip') }); artifacts.editorTrace = { path: 'execution/browser-trace.zip' }; artifacts.frontendTrace = { path: 'execution/browser-trace.zip' }; + runtimeComplete = true; +} + +main().catch(async (error) => { + save('executionError', { message: error.message, stack: error.stack }); check('execution', false, error.message, 'executionError'); + if (pageRef) { save('browserFailure', { url: pageRef.url(), text: await pageRef.locator('body').innerText().catch(() => ''), state: await pageRef.evaluate(() => ({ registered: Object.keys(window.wp?.blocks?.getBlockTypes?.() || {}).length, blocks: window.wp?.data?.select('core/block-editor')?.getBlocks?.() })).catch(() => null) }); await pageRef.screenshot({ path: path.join(out, 'failure.png') }).catch(() => {}); } +}).finally(async () => { + if (browser) await browser.close().catch(() => {}); + if (referenceServer) await new Promise((resolve) => referenceServer.close(resolve)); + if (pluginSlug) { try { wp(['plugin', 'deactivate', pluginSlug]); } catch {} } + save('buildLog', log); + fs.writeFileSync(get('--result'), `${JSON.stringify({ status: runtimeComplete ? 'scored' : 'blocked', checks, artifacts, environment: { worker: 'authoring-runtime-worker-v1', runtimeComplete, runtimeConfig: 'configured through BLOCK_RUNNER_AUTHORING_RUNTIME_CONFIG' } }, null, 2)}\n`); +}); diff --git a/scripts/authoring-runtime.config.example.json b/scripts/authoring-runtime.config.example.json new file mode 100644 index 0000000..e807d17 --- /dev/null +++ b/scripts/authoring-runtime.config.example.json @@ -0,0 +1,12 @@ +{ + "repo": "/absolute/path/to/block-runner", + "cli": "/absolute/path/to/packed-consumer/node_modules/.bin/block-runner", + "sharedDependencies": "/absolute/path/to/standalone-plugin/node_modules", + "docker": "/Applications/Docker.app/Contents/Resources/bin/docker", + "wordpress": { + "container": "wp-env-proof-wp-env--tests-cli-1", + "url": "http://localhost:8889", + "credentials": { "username": "admin", "password": "password" } + }, + "smokeViewport": { "width": 390, "height": 844 } +} diff --git a/scripts/authoring/score.ts b/scripts/authoring/score.ts index c087d3a..7492a44 100644 --- a/scripts/authoring/score.ts +++ b/scripts/authoring/score.ts @@ -647,6 +647,7 @@ function requiredWarningCode(fixture: AuthoringFixture): string | undefined { } const STYLE_LEDGER_OWNERS = new Set(['block', 'theme', 'pattern', 'asset', 'unsupported']); +const VALID_CUSTOM_PROPERTY = /^--[a-zA-Z_][a-zA-Z0-9_-]*$/; function styleLedgerFailures(file: string): string[] { let ledger: unknown; @@ -664,9 +665,14 @@ function styleLedgerFailures(file: string): string[] { failures.push(`style-ledger entry ${index} must be an object`); continue; } - for (const key of ['selector', 'property', 'value', 'editorControl']) { + for (const key of ['selector', 'property', 'editorControl']) { if (typeof entry[key] !== 'string' || !entry[key].trim()) failures.push(`style-ledger entry ${index} requires ${key}`); } + const value = entry.value; + const property = entry.property; + if (typeof value !== 'string' || (!value.trim() && !(value === '' && typeof property === 'string' && VALID_CUSTOM_PROPERTY.test(property)))) { + failures.push(`style-ledger entry ${index} requires value`); + } if (typeof entry.owner !== 'string' || !STYLE_LEDGER_OWNERS.has(entry.owner)) { failures.push(`style-ledger entry ${index} has an invalid owner`); } diff --git a/scripts/smoke-packed-cli.mjs b/scripts/smoke-packed-cli.mjs index e4f3984..ed4f2d1 100644 --- a/scripts/smoke-packed-cli.mjs +++ b/scripts/smoke-packed-cli.mjs @@ -27,6 +27,16 @@ try { const conversion = JSON.parse(run(process.execPath, [cli, 'convert', '

Node support smoke

', '--json'], consumer).stdout); if (!conversion.ok) throw new Error('Packed CLI conversion smoke did not succeed.'); + const guide = readFileSync(path.join(consumer, 'node_modules', 'block-runner', 'skills', 'block-runner', 'references', 'GUIDE.md'), 'utf8'); + const example = guide.match(/\s*```js\n([\s\S]*?)\n```\s*/); + if (!example) throw new Error('Packed guide is missing its marked runnable authoring proposal example.'); + const exampleFile = path.join(consumer, 'guide-authoring-proposal.mjs'); + writeFileSync(exampleFile, example[1] + '\n'); + const canonicalPlan = JSON.parse(run(process.execPath, [exampleFile], consumer).stdout); + if (canonicalPlan.version !== 1 || !canonicalPlan.source || !canonicalPlan.coverage) { + throw new Error('Packed guide authoring proposal example did not produce a canonical plan.'); + } + const typecheck = path.join(root, 'node_modules', '.bin', 'tsc'); writeFileSync(path.join(consumer, 'library-smoke.mts'), [ "import { AuthoringGenerationError, convert, type ConvertOptions } from 'block-runner';", @@ -40,7 +50,7 @@ try { run(process.execPath, ['--input-type=module', '--eval', [ "const api = await import('block-runner');", - "if (typeof api.convert !== 'function' || typeof api.AuthoringGenerationError !== 'function') throw new Error('missing public library exports');", + "if (typeof api.convert !== 'function' || typeof api.author !== 'function' || typeof api.collectSourceEvidence !== 'function' || typeof api.AuthoringGenerationError !== 'function') throw new Error('missing public library exports');", ].join('\n')], consumer); console.log('Packed engine-strict install, CLI, and typed library smoke passed on Node ' + process.versions.node + '.'); diff --git a/skills/block-runner/SKILL.md b/skills/block-runner/SKILL.md index 6987433..200d6ee 100644 --- a/skills/block-runner/SKILL.md +++ b/skills/block-runner/SKILL.md @@ -20,15 +20,18 @@ package. Four paths. Pick by the requested artifact: -- **You need a reusable, named registered block in code** → create a `GeneratedAuthoringPlan`, not - source code. It records the block identity, design structure, editability and locking, style - and asset outcomes, pattern overrides, final destination, and proof inputs. Run `author +- **You need a reusable, named registered block in code** → for authored HTML, first create an + `AuthorOptions.proposal`, not source code or a full plan. It records only semantic structure, + source references, editability, locks, and reviewed source decisions; Block Runner returns the + canonical `GeneratedAuthoringPlan` with hashes, coverage, assets, native style adapters, and + mandatory warnings. Run `author preview`, show the literal tree and every warning, ask for a clear confirmation, then run `author write` using that exact confirmation. Follow it through plugin packaging and a full proof. The deterministic generator, not the model, writes the executable source. For authored HTML, make two calls: analyse exact HTML/CSS for `sourceRef`s, then submit an `AuthorOptions.proposal` containing only structure, source references, editability, locks, and - explicit content decisions. Block Runner derives ledgers, assets, CSS ownership, and warnings. + explicit content decisions. Inspection/validation need no consent; confirmation covers only the + final canonical write identity. Complete `AuthorOptions.plan` is an advanced compatibility route. - **You are inventing the structure** → do not write HTML. Emit an intent tree (JSON describing which blocks and how they nest) and pipe it to diff --git a/skills/block-runner/references/GUIDE.md b/skills/block-runner/references/GUIDE.md index 88e81e2..bcf892c 100644 --- a/skills/block-runner/references/GUIDE.md +++ b/skills/block-runner/references/GUIDE.md @@ -41,9 +41,9 @@ unsure whether the styling matters, ask the user rather than silently flattening ## 2. Registered-block authoring — plan, preview, confirm, write, prove Use this path for one reusable `namespace/slug` block source package. The model interprets the -design and produces the versioned declarative **`GeneratedAuthoringPlan`**; the deterministic source -generator produces all executable source and serializes blocks. This is deliberately different -from converting a design into page `post_content`. +design into a semantic proposal; deterministic `author()` returns the canonical versioned +**`GeneratedAuthoringPlan`**, produces executable source, and serializes blocks. This is deliberately +different from converting a design into page `post_content`. The 0.9 authoring workflow is an unreleased candidate. Until its npm channel is published and independently verified, install a reviewed, pinned candidate tarball in @@ -52,22 +52,58 @@ nonexistent `@testing` tag: stable `latest` remains on 0.8.0. An installed skill rewrites runtime commands to the exact version that installed it, so its compiler and guide cannot drift. -### The model's job: make the authoring plan, never the implementation +### The model's job: make a semantic proposal, never the implementation + +For HTML, first call `collectSourceEvidence()` and send `AuthorOptions.proposal`: native structure +with its `sourceRef`s, fields, editability, locking, and reviewed source decisions. Make those +choices explicit, but do not manufacture ledgers, asset hashes, native CSS adapters, source hashes, +or mandatory warnings. `author()` owns them and returns the canonical `GeneratedAuthoringPlan`. +Inspection and validation need no consent; only the final canonical write identity needs it. + +The proposal is declarative JSON only. Do **not** emit or ask the user to paste React, JSX, TSX, +PHP, `block.json`, generated CSS, `registerBlockType`, `register_block_type`, or `` +delimiters. The generator owns executable source and block serialization; the model owns the +reviewable semantic decisions. + +### Primary HTML workflow: proposal → canonical plan + +This self-contained example uses only public packed imports. It intentionally supplies no manual +hashes, coverage ledger, assets, or native CSS: those are deterministic core responsibilities. + + +```js +import { author, collectSourceEvidence } from 'block-runner'; + +const html = '

Build faster

Native editable blocks, reviewed first.

'; +const evidence = collectSourceEvidence(html); +const ref = (tag) => evidence.structure.find((entry) => entry.tag === tag)?.sourceRef; +const report = await author(html, { + author: { name: 'acme/small-hero', title: 'Small hero' }, + proposal: { + structure: [{ id: 'hero', block: 'core/group', sourceRef: ref('section'), children: [ + { id: 'title', block: 'core/heading', sourceRef: ref('h2') }, + { id: 'copy', block: 'core/paragraph', sourceRef: ref('p') }, + ] }], + fields: [{ id: 'title', label: 'Title', mode: 'editable', node: 'title', attribute: 'content' }], + locking: { mode: 'contentOnly' }, + }, +}); +if (!report.ok || !report.package?.canonicalPlan) throw new Error('authoring proposal was rejected'); +process.stdout.write(`${JSON.stringify(report.package.canonicalPlan, null, 2)}\n`); +``` + -The plan must state the target identity and **final** destination, native structure, field modes -(`fixed`, `editable`, or `override`), locking, style and asset dispositions, pattern-override -fields, planned outputs, and every warning. Make material choices explicit: which content can be -edited, which structure is locked, what maps to native/theme support, what requires scoped CSS, -which assets are available, and which fields a pattern may override. A missing destination is a -question for the user, not permission to use a temporary directory. +Run it from a project with the packed package installed, then preview, obtain confirmation, and +write the exact canonical identity: -The plan is declarative JSON only. Do **not** emit or ask the user to paste React, JSX, TSX, -PHP, a complete `block.json`, generated CSS, `registerBlockType` or -`register_block_type` calls, or `` delimiters. Do not put executable source in a -plan's file content. The generator owns executable source and block serialization; the model -owns the reviewable design decisions. +```bash +node author-proposal.mjs > small-hero.plan.json +npx --no-install block-runner author preview small-hero.plan.json --output-dir +# Show the complete preview; obtain its full confirmation hash and explicit approval. +npx --no-install block-runner author write small-hero.plan.json --confirm '' --output-dir '' +``` -### `GeneratedAuthoringPlan` v1 shape +### Advanced: complete `GeneratedAuthoringPlan` v1 shape The CLI accepts exactly this versioned JSON shape. Object keys may be in any order; arrays retain their order and every listed value participates in the confirmation hash. `files` names @@ -162,12 +198,9 @@ An empty `files` list lets the compiler enumerate its complete source set in the preview; it does not mean no output. A native SVG adds an owned `asset-urls.mjs` source file, which is included in confirmation and the manifest. -For existing HTML/CSS input, first analyse the exact input to obtain deterministic `sourceRef`s. -Then submit `AuthorOptions.proposal` with ordered native structure, source references, fields, -locks, and explicit add/replace/omit decisions. Block Runner owns source hashes, content transport, -assets, CSS coverage, and mandatory warnings, then returns the usual canonical plan for preview, -confirmation, and writing. Complete `AuthorOptions.plan` remains supported for existing callers, -but do not make a model copy ledgers, asset hashes, destinations, CSS rules, or warnings. +For existing HTML/CSS input, the proposal workflow above is primary. Complete `AuthorOptions.plan` +remains supported as an advanced compatibility route, but do not make a model copy ledgers, asset +hashes, destinations, CSS rules, or warnings. Tailwind detection is advisory. Supplied compiled CSS can be handled as ordinary CSS; Tailwind source/runtime output needs an explicit, pinned build graph (including custom variants, plugins, diff --git a/src/author/index.ts b/src/author/index.ts index 3a2af8e..d4a857a 100644 --- a/src/author/index.ts +++ b/src/author/index.ts @@ -1680,7 +1680,12 @@ function reconcileVerifiedNativeTargets( && candidate.property === entry.property && candidate.value === entry.value && candidate.atRules.join('\u0000') === entry.atRules.join('\u0000') && candidate.source?.selector === entry.source?.selector && candidate.source?.offset === entry.source?.offset); - if (matching.length === 1 && matching[0]!.nativeTargets?.length) entry.nativeTargets = matching[0]!.nativeTargets!.map((target) => ({ ...target })); + if (matching.length === 1 && matching[0]!.nativeTargets?.length) { + entry.nativeTargets = matching[0]!.nativeTargets!.map((target) => ({ ...target })); + if (entry.nativeTargets.every((target) => target.role === 'image' || target.role === 'caption')) { + entry.transportSelector = entry.nativeTargets[0]!.selector; + } + } } } diff --git a/src/author/plan.ts b/src/author/plan.ts index 02a3e8d..61704a1 100644 --- a/src/author/plan.ts +++ b/src/author/plan.ts @@ -204,6 +204,7 @@ export function compileAnalyzedDesign(input: { ? reconcileProposalStyleOwnership( structure, input.rules, input.styleLedger, input.source, input.sourceRefToNode, input.stylesheetFacts?.rules ?? input.rules, input.cascadeSensitiveDeclarations ?? new Set(), input.sourcePath, + definition.styles?.foundation, `.wp-block-${name.replace('/', '-')}`, ) : { rules: input.rules, styleLedger: input.styleLedger }; const responsive = liftExactResponsiveStyles({ @@ -326,6 +327,8 @@ function reconcileProposalStyleOwnership( sourceRules: readonly CssRule[], cascadeSensitiveDeclarations: ReadonlySet, sourcePath?: string, + foundation?: 'component', + scopeRoot?: string, ): { rules: readonly CssRule[]; styleLedger: readonly AuthoredStyleLedgerEntry[] } { const dom = new JSDOM(source, { includeNodeLocations: true }); try { @@ -377,7 +380,7 @@ function reconcileProposalStyleOwnership( return output; }, []); const residual = removePromoted(rules); - return adaptNativeSourceStyles(structure, residual, nextLedger, sourceNode, bindings, sourcePath, (ref) => dom.nodeLocation(sourceNode.get(ref)!) ?? undefined); + return adaptNativeSourceStyles(structure, residual, nextLedger, sourceNode, bindings, sourcePath, (ref) => dom.nodeLocation(sourceNode.get(ref)!) ?? undefined, foundation, scopeRoot); } finally { dom.window.close(); } } @@ -390,14 +393,16 @@ const BUTTON_WRAPPER_RESET = new Map([ ]); const BUTTON_TARGET_PROPERTIES = new Set(`color background background-color background-image border border-color border-width border-style border-radius box-shadow padding padding-top padding-right padding-bottom padding-left margin margin-top margin-right margin-bottom margin-left display width height min-width max-width min-height max-height - font font-family font-size font-weight font-style line-height letter-spacing text-align text-decoration text-transform white-space opacity transform translate rotate scale filter + font font-family font-size font-weight font-style line-height letter-spacing text-align text-decoration text-transform white-space justify-content opacity transform translate rotate scale filter transition transition-property transition-duration transition-delay transition-timing-function outline outline-color outline-width outline-style outline-offset cursor`.split(/\s+/)); +const VALID_CUSTOM_PROPERTY = /^--[a-zA-Z_][a-zA-Z0-9_-]*$/; /** Insert narrowly-qualified native rules beside their exact source rule, preserving nesting/order. */ function adaptNativeSourceStyles( structure: AuthoringStructureNode[], rules: readonly CssRule[], ledger: AuthoredStyleLedgerEntry[], sourceNodes: ReadonlyMap, bindings: ReadonlyMap, sourcePath: string | undefined, locationFor: (ref: string) => { startOffset: number; startLine: number; startCol: number } | undefined, + foundation?: 'component', scopeRoot?: string, ): { rules: CssRule[]; styleLedger: AuthoredStyleLedgerEntry[] } { const nodes = flattenNodes(structure); // Conversion retains source classes for residual CSS. For a directly-bound img, however, @@ -440,6 +445,8 @@ function adaptNativeSourceStyles( const targetFor = (selector: string): NativeTarget[] | undefined => { const subjects = nativeSelectorSubjects(selector); if (!subjects) { + const contained = scopeRoot ? scopeLocalSelectorList(selector, scopeRoot, { foundation }) : undefined; + if (contained?.ok && contained.foundation) return undefined; const bound = [...bindings.entries()].map(([sourceRef, id]) => ({ sourceRef, node: nodes.find((candidate) => candidate.id === id), element: sourceNodes.get(sourceRef) })) .filter((candidate): candidate is { sourceRef: string; node: AuthoringStructureNode; element: Element } => !!candidate.node && !!candidate.element); if (bound.some((candidate) => ['core/button', 'core/image', 'core/group', 'core/columns'].includes(candidate.node.block))) { @@ -454,7 +461,7 @@ function adaptNativeSourceStyles( .sort((left, right) => (locationFor(left.sourceRef)?.startOffset ?? Number.MAX_SAFE_INTEGER) - (locationFor(right.sourceRef)?.startOffset ?? Number.MAX_SAFE_INTEGER) || left.sourceRef.localeCompare(right.sourceRef)); if (matching.length) throw new NativeTargetBindingError(matching.map((candidate) => ({ sourceRef: candidate.sourceRef, node: candidate.node.id!, role: nativeAdapterRole(candidate.node) })), `${selector} is not a supported single-subject native selector`); - throw new Error(`unresolved-native-style-mapping: ${selector} is not a supported single-subject native selector`); + return undefined; } return undefined; } @@ -552,7 +559,7 @@ function adaptNativeSourceStyles( throw unresolved('core/columns cannot own an authored grid'); } if (targets.some((target) => nodes.find((node) => node.id === target.node)?.block === 'core/button') - && rule.declarations.some((declaration) => !BUTTON_TARGET_PROPERTIES.has(declaration.property))) { + && rule.declarations.some((declaration) => !BUTTON_TARGET_PROPERTIES.has(declaration.property) && !VALID_CUSTOM_PROPERTY.test(declaration.property))) { throw unresolved('property is outside the supported core/button adapter'); } for (const target of targets.filter((candidate) => candidate.role === 'image')) { @@ -592,6 +599,7 @@ function adaptNativeSourceStyles( } const extras: CssRule[] = targets.flatMap((target, targetIndex) => { const targetDeclarations = target.role === 'grid-container' ? rule.declarations.filter((declaration) => gridProperties.has(declaration.property)) : rule.declarations; + if (!targetDeclarations.length) return []; const targetRule: CssRule = { ...rule, id: `${rule.id}.native-target.${targetIndex}`, selector: target.target, declarations: targetDeclarations, generated: 'native-adapter-target' }; const resets = target.reset ? rule.declarations.filter((declaration) => BUTTON_WRAPPER_RESET.has(declaration.property)).map((declaration) => ({ ...declaration, id: `${declaration.id}.native-reset.${targetIndex}`, value: BUTTON_WRAPPER_RESET.get(declaration.property)! })) : []; return resets.length ? [targetRule, { ...rule, id: `${rule.id}.native-reset.${targetIndex}`, selector: target.reset!, declarations: resets, generated: 'native-adapter-wrapper-reset' } as CssRule] : [targetRule]; diff --git a/src/author/styles.ts b/src/author/styles.ts index 7bb6ee4..ba02827 100644 --- a/src/author/styles.ts +++ b/src/author/styles.ts @@ -630,7 +630,8 @@ export function nativeSelectorSubjects(selectorList: string): NativeSelectorSubj const owner = relationship?.owner ?? selector; const relation = relationship?.relation; const terminal = relationship?.terminal; - if (!owner || /[\s>+~\[\]#*]/.test(owner) || /::|:(?:not|is|where|has)\s*\(/i.test(owner)) return undefined; + // The token scanner distinguishes escaped class characters from selector syntax. + if (!owner) return undefined; let index = 0; const classes: Array<{ raw: string; decoded: string }> = []; let state: NativeSelectorSubject['state']; diff --git a/src/authoring/generate.ts b/src/authoring/generate.ts index 8e04c69..11e80c6 100644 --- a/src/authoring/generate.ts +++ b/src/authoring/generate.ts @@ -19,6 +19,7 @@ import WORDPRESS_BLOCK_SCHEMA_7_1 from './vendor/wordpress-block.schema.7.1.json import { patternOverrideName, supportedPatternOverrideAttributes } from './overrides.js'; import { authoringRegistryIdentity, validateEditableField, validateNativeComposition, type AuthoringRegistryIdentity } from './capabilities.js'; import { collectConfirmedAssets, fontOwnershipDecision, type GeneratedAssetFile } from './assets.js'; +import { nativeSelectorSubjects } from '../author/styles.js'; import { renderConfirmedStyleRules, renderFontLicenseNotice, @@ -645,6 +646,12 @@ function validateNativeAdapterProvenance(plan: AuthoringPlan): void { throw new AuthoringGenerationError('forged-native-adapter: generated selector/provenance is not present in the canonical rules', 'coverage.styles.nativeTargets'); } claimed.add(matchIndex); + const sourceSubjects = entry.source?.selector ? nativeSelectorSubjects(entry.source.selector) : undefined; + const sourceStates = sourceSubjects && new Set(sourceSubjects.map((subject) => subject.state ?? '')); + const targetState = (/:(focus-visible|hover|focus|active)$/.exec(target.selector)?.[1] ?? '') as '' | 'hover' | 'focus' | 'focus-visible' | 'active'; + if (!sourceStates?.has(targetState)) { + throw new AuthoringGenerationError('forged-native-adapter: native target interaction state is not present in its source selector', 'coverage.styles.nativeTargets'); + } const marker = `block-runner-native-${target.node.replace(/[^a-zA-Z0-9_-]/g, '-')}`; const state = '(?::(?:hover|focus|focus-visible|active))?'; if ((target.role === 'button-link' || target.role === 'button-wrapper-reset') && (node.block !== 'core/button' diff --git a/test/author.native-style-adapters.test.ts b/test/author.native-style-adapters.test.ts index cc61654..366151a 100644 --- a/test/author.native-style-adapters.test.ts +++ b/test/author.native-style-adapters.test.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { author, collectSourceEvidence } from '../src/index.js'; import { compileRegisteredBlock } from '../src/authoring/generate.js'; +import type { AuthoringCssRule } from '../src/authoring/schema.js'; import { nativeSelectorSubjects } from '../src/author/styles.js'; function refs(html: string) { @@ -72,6 +73,49 @@ describe('native source-style adapters', () => { expect(plan.structure[0]!.attributes?.layout).toMatchObject({ type: 'grid' }); }); + it('keeps component-contained foundation resets and transports button custom properties', async () => { + const html = '
'; + const report = await author(html, { + author: { name: 'example/component-foundation', styles: { mode: 'css', foundation: 'component', css: [ + '*,::before,::after { --tw-border-spacing-x: 0; box-sizing: border-box; }', + '.shared { color: red; }', + '.action { --tw-bg-opacity: 1; background-color: rgb(34 211 238 / var(--tw-bg-opacity)); display: inline-flex; justify-content: center; }', + ].join('\n') } }, + proposal: { structure: [{ id: 'root', block: 'core/group', sourceRef: refs(html)('section'), children: [{ id: 'buttons', block: 'core/buttons', sourceRef: refs(html)('div'), children: [{ id: 'action', block: 'core/button', sourceRef: refs(html)('a') }] }] }] }, + }); + expect(report.ok, JSON.stringify(report.items)).toBe(true); + const plan = report.package!.canonicalPlan!; + expect(plan.styles.rules).toEqual(expect.arrayContaining([expect.objectContaining({ selector: '*,::before,::after' })])); + expect(plan.coverage!.styles).toEqual(expect.arrayContaining([expect.objectContaining({ property: '--tw-bg-opacity', nativeTargets: [expect.objectContaining({ role: 'button-link' })] })])); + expect(plan.coverage!.styles).toEqual(expect.arrayContaining([expect.objectContaining({ property: 'justify-content', value: 'center', nativeTargets: [expect.objectContaining({ role: 'button-link' })] })])); + }); + + it('rejects submitted native adapter interaction-state tampering while retaining source states', async () => { + const html = '
Photo
'; + const proposal = { structure: [{ id: 'image', block: 'core/image', sourceRef: refs(html)('figure') }] }; + const options = { author: { name: 'example/state-provenance', styles: { mode: 'css' as const, css: '.photo { width: 100%; } .photo:hover { opacity: .8; }' } }, proposal }; + const baseline = await author(html, options); + expect(baseline.ok, JSON.stringify(baseline.items)).toBe(true); + const plan = baseline.package!.canonicalPlan!; + const submit = async (from: string, to: string) => { + const forged = structuredClone(plan); + for (const entry of forged.coverage!.styles) { + if (entry.transportSelector === from) entry.transportSelector = to; + for (const target of entry.nativeTargets ?? []) if (target.selector === from) target.selector = to; + } + const rewrite = (rules: AuthoringCssRule[]): AuthoringCssRule[] => rules.map((rule) => rule.kind === 'conditional' + ? { ...rule, rules: rewrite(rule.rules) } + : rule.selector === from ? { ...rule, selector: to } : rule); + forged.styles.rules = rewrite(forged.styles.rules ?? []); + return author(html, { author: options.author, plan: forged }); + }; + const baselineTarget = plan.coverage!.styles.flatMap((entry) => entry.nativeTargets ?? []).find((target) => !/:(?:hover|focus|focus-visible|active)$/.test(target.selector))!; + const hoverTarget = plan.coverage!.styles.flatMap((entry) => entry.nativeTargets ?? []).find((target) => target.selector.endsWith(':hover'))!; + await expect(submit(baselineTarget.selector, `${baselineTarget.selector}:hover`)).resolves.toMatchObject({ ok: false }); + await expect(submit(hoverTarget.selector, hoverTarget.selector.replace(/:hover$/, ':focus'))).resolves.toMatchObject({ ok: false }); + await expect(author(html, { author: options.author, plan })).resolves.toMatchObject({ ok: true }); + }); + it('rejects grid declarations without unconditional source display:grid', async () => { const html = '

Grid

'; const report = await author(html, { @@ -83,6 +127,10 @@ describe('native source-style adapters', () => { }); it('reads complete image class atoms and bounded figure child compounds', () => { + expect(nativeSelectorSubjects('.min-h-\\[680px\\]')).toMatchObject([{ classes: [{ decoded: 'min-h-[680px]' }] }]); + expect(nativeSelectorSubjects('.tracking-\\[0\\.18em\\]')).toMatchObject([{ classes: [{ decoded: 'tracking-[0.18em]' }] }]); + expect(nativeSelectorSubjects('.card[data-active]')).toBeUndefined(); + expect(nativeSelectorSubjects('.card > a')).toBeUndefined(); expect(nativeSelectorSubjects('.media-img')).toMatchObject([{ staticSelector: '.media-img', classes: [{ decoded: 'media-img' }] }]); expect(nativeSelectorSubjects('figure.frame > img.media-img:hover')).toMatchObject([{ staticSelector: 'figure.frame', relation: 'image', terminalSelector: 'img.media-img', state: 'hover', diff --git a/test/author.proposal.test.ts b/test/author.proposal.test.ts index 7fe0c3f..340f851 100644 --- a/test/author.proposal.test.ts +++ b/test/author.proposal.test.ts @@ -245,6 +245,59 @@ describe('author proposal boundary', () => { validateSourceContent(html, compileRegisteredBlock(plan).template); }); + it('binds the complete self-contained utility hero handoff through proposal and canonical-plan routes', async () => { + const { html, sourcePath } = await source('utility/hero-handoff.html'); + const css = await readFile(path.join(benchmarkRoot, 'utility', 'hero.css'), 'utf8'); + const ref = refs(html); + const proposal = { structure: [{ id: 'hero', block: 'core/group', sourceRef: ref('section'), children: [ + { id: 'hero-grid', block: 'core/group', sourceRef: ref('div', 0), children: [ + { id: 'copy', block: 'core/group', sourceRef: ref('div', 1), children: [ + { id: 'eyebrow', block: 'core/paragraph', sourceRef: ref('p', 0) }, + { id: 'title', block: 'core/heading', sourceRef: ref('h1') }, + { id: 'lede', block: 'core/paragraph', sourceRef: ref('p', 1) }, + { id: 'buttons', block: 'core/buttons', sourceRef: ref('div', 2), children: [ + { id: 'download', block: 'core/button', sourceRef: ref('a', 0) }, + { id: 'guide', block: 'core/button', sourceRef: ref('a', 1) }, + ] }, + ] }, + { id: 'image', block: 'core/image', sourceRef: ref('figure') }, + ] }, + ] }], fields: [{ id: 'title-content', label: 'Title', mode: 'editable' as const, node: 'title', attribute: 'content' }], locking: { mode: 'contentOnly' as const } }; + const options = { + sourcePath, + assetRoot: path.dirname(sourcePath), + author: { name: 'block-runner/hero', styles: { mode: 'css' as const, css, foundation: 'component' as const } }, + proposal, + }; + const first = await author(html, options); + expect(first.ok, JSON.stringify(first.items)).toBe(true); + const plan = first.package!.canonicalPlan!; + const second = await author(html, options); + expect(second.ok, JSON.stringify(second.items)).toBe(true); + expect(plan).toEqual(second.package!.canonicalPlan); + const nodes = nodesById(plan.structure); + expect(nodes.get('eyebrow')!.attributes).toMatchObject({ content: 'Block Runner 0.9' }); + expect(nodes.get('title')!.attributes).toMatchObject({ content: 'Build a WordPress block your team can keep editing.' }); + expect(nodes.get('lede')!.attributes).toMatchObject({ content: 'Turn a finished interface into a registered block with clear controls, native markup, and a source trail reviewers can inspect.' }); + expect(nodes.get('download')!.attributes).toMatchObject({ text: 'Download the testing release', url: '/download' }); + expect(nodes.get('guide')!.attributes).toMatchObject({ text: 'Read the authoring guide', url: '/docs/authoring' }); + expect(nodes.get('image')!.attributes).toMatchObject({ alt: 'Aurora dashboard with color tokens, release receipts, and a completed activation check', caption: 'Native controls stay with the block, not in a screenshot.' }); + expect(plan.sourceDecisions).toBeUndefined(); + const roundtrip = await author(html, { + sourcePath, + assetRoot: path.dirname(sourcePath), + author: options.author, + plan, + }); + expect(roundtrip.ok, JSON.stringify(roundtrip.items)).toBe(true); + expect(roundtrip.package!.canonicalPlan).toEqual(plan); + const tampered = structuredClone(plan); + tampered.coverage!.styles.find((entry) => entry.transportSelector && entry.nativeTargets?.length)!.transportSelector = '.unrelated-target'; + const rejected = await author(html, { sourcePath, assetRoot: path.dirname(sourcePath), author: options.author, plan: tampered }); + expect(rejected.ok).toBe(false); + expect(rejected.package).toBeUndefined(); + }); + it('binds the semantic cards hierarchy without dropping content', async () => { const { html, sourcePath } = await source('semantic/cards.html'); const ref = refs(html); diff --git a/test/authoring.receipts.test.ts b/test/authoring.receipts.test.ts index 13786ae..c5bc4bc 100644 --- a/test/authoring.receipts.test.ts +++ b/test/authoring.receipts.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import Ajv2020 from 'ajv/dist/2020.js'; +import { author } from '../src/author/index.js'; import { executeFixture } from '../scripts/authoring-runner.js'; import { AUTHORING_DIMENSIONS, AUTHORING_RUNTIME_ARTIFACTS, authoringHashes, hashFile, @@ -14,7 +15,7 @@ import { } from '../scripts/authoring/score.js'; const suiteDirectory = path.resolve('benchmarks/authoring'); -const plan = () => ({ +const unboundPlan = () => ({ version: 1, generatorVersion: '0.9.0', target: { name: 'acme/receipt-unit-test', title: 'Receipt unit test' }, structure: [{ id: 'copy', block: 'core/paragraph', attributes: { content: 'Candidate' } }], fields: [], locking: { mode: 'none' }, styles: { strategy: 'native', outcomes: [] }, @@ -27,9 +28,32 @@ async function setup(negative = false) { const run = path.join(root, 'run'); await mkdir(plans); const suite = loadAuthoringSuite(suiteDirectory); - const fixture = suite.fixtures.find((candidate) => negative ? candidate.expectedStatus === 'unsupported' : candidate.expectedStatus === 'scored')!; + const fixture = suite.fixtures.find((candidate) => negative + ? candidate.expectedStatus === 'unsupported' + : candidate.id === 'cards-semantic')!; const hashes = authoringHashes(suite); - const candidate = plan(); + const sourceReference = fixture.source?.path; + if (!sourceReference) throw new Error(`fixture ${fixture.id} has no source path`); + const sourcePath = path.resolve(suiteDirectory, sourceReference); + const source = await readFile(sourcePath, 'utf8'); + const sourceDependencies = Array.isArray(fixture.sourceDependencies) + ? fixture.sourceDependencies.filter((dependency): dependency is { path: string } => typeof dependency === 'object' + && dependency !== null && !Array.isArray(dependency) && typeof (dependency as { path?: unknown }).path === 'string') + : []; + const stylesheet = await Promise.all(sourceDependencies + .filter((dependency) => dependency.path.endsWith('.css')) + .map((dependency) => readFile(path.resolve(suiteDirectory, dependency.path), 'utf8'))); + const analysis = negative + ? undefined + : await author(source, { + sourcePath, + author: { + name: 'acme/receipt-unit-test', + ...(stylesheet.length ? { styles: { mode: 'css' as const, css: stylesheet.join('') } } : {}), + }, + }); + if (!negative) expect(analysis?.ok, JSON.stringify(analysis?.items)).toBe(true); + const candidate = analysis?.package!.canonicalPlan ?? unboundPlan(); const input = path.join(plans, `${fixture.id}.json`); await writeFile(input, JSON.stringify(candidate)); const execute = (worker?: string, timeout?: number) => executeFixture(fixture, suiteDirectory, run, worker, hashes, plans, timeout); @@ -69,7 +93,7 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('records an absent runtime as blocked with no invented observations', async () => { const f = await setup(); - const receipt = f.execute(); + const receipt = await f.execute(); expect(receipt.status).toBe('blocked'); expect(receipt.environment).toEqual({ wordpress: null, theme: null, browser: null }); for (const key of Object.keys(AUTHORING_RUNTIME_ARTIFACTS)) expect(receipt.provenance?.[key]).toBeNull(); @@ -80,10 +104,10 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('gives the worker candidate-local source and plan snapshots', async () => { const f = await setup(); const worker = await f.worker("fs.writeFileSync('worker-observation.json', JSON.stringify({ source: fs.readFileSync(arg('--source'), 'utf8'), plan: fs.readFileSync(arg('--candidate-plan'), 'utf8'), dependencyArgs: args.filter((value) => value === '--source-dependency').length })); fs.writeFileSync(arg('--result'), JSON.stringify({status:'blocked',artifacts:{observation:{path:'worker-observation.json'}}}));"); - const receipt = f.execute(worker); + const receipt = await f.execute(worker); expect(receipt.status).toBe('blocked'); const observation = JSON.parse(await readFile(path.join(f.run, 'receipts', receipt.artifacts!.observation!.path), 'utf8')); - expect(observation.source).toContain('hero'); + expect(observation.source).toContain('plans-heading'); expect(JSON.parse(observation.plan)).toEqual(f.candidate); expect(observation.dependencyArgs).toBe(0); }); @@ -91,7 +115,7 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('retains malformed plan input as an engine error without generating source', async () => { const f = await setup(); await writeFile(f.input, '{"broken":'); - const receipt = f.execute(); + const receipt = await f.execute(); expect(receipt).toMatchObject({ status: 'engine_error', error: { kind: 'engine' } }); expect(existsSync(path.join(f.run, 'candidates', f.fixture.id))).toBe(false); expect(await readFile(path.join(f.run, 'receipts', receipt.artifacts!.candidateInput!.path), 'utf8')).toBe('{"broken":'); @@ -100,9 +124,11 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('counts only a real compiler refusal as the unsupported interaction outcome', async () => { const f = await setup(true); - Object.assign(f.candidate.structure[0]!.attributes, { onClick: 'requesting executable behavior' }); + const attributes = f.candidate.structure[0]?.attributes; + if (!attributes) throw new Error('unsupported fixture root attributes are missing'); + Object.assign(attributes, { onClick: 'requesting executable behavior' }); await writeFile(f.input, JSON.stringify(f.candidate)); - const receipt = f.execute(); + const receipt = await f.execute(); expect(receipt).toMatchObject({ status: 'unsupported', failClosed: { warningCode: 'BR_UNSUPPORTED_INTERACTION', noInteractiveRuntime: true } }); expect(existsSync(path.join(f.run, 'candidates', f.fixture.id))).toBe(false); expect(validateAuthoringReceipt(f.fixture, receipt, suiteDirectory, path.join(f.run, 'receipts'))).toEqual([]); @@ -121,7 +147,7 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', 'reserved-artifact': "fs.writeFileSync('evidence.txt','test'); fs.writeFileSync(arg('--result'),JSON.stringify({status:'blocked',artifacts:{generatedSourceManifest:{path:'evidence.txt'}}}));", 'changed-source': "fs.appendFileSync('edit.js','\\n// changed source'); fs.writeFileSync(arg('--result'),JSON.stringify({status:'blocked'}));", }[kind]!; - const receipt = f.execute(await f.worker(body), kind === 'timeout' ? 100 : 10000); + const receipt = await f.execute(await f.worker(body), kind === 'timeout' ? 100 : 10000); expect(receipt).toMatchObject({ status: 'engine_error', error: { kind: 'engine' } }); expect(receipt.artifacts).toHaveProperty('workerStdout'); expect(receipt.artifacts).toHaveProperty('workerStderr'); @@ -136,7 +162,7 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('does not manufacture a runtime from a worker that merely echoes requested settings', async () => { const f = await setup(); - const receipt = f.execute(await f.worker("fs.writeFileSync('style-ledger.json',JSON.stringify({version:1,entries:[]})); fs.writeFileSync(arg('--result'), JSON.stringify({status:'scored', environment:{wordpress:'7.1',browser:'chromium'}}));")); + const receipt = await f.execute(await f.worker("fs.writeFileSync('style-ledger.json',JSON.stringify({version:1,entries:[]})); fs.writeFileSync(arg('--result'), JSON.stringify({status:'scored', environment:{wordpress:'7.1',browser:'chromium'}}));")); const failures = validateAuthoringReceipt(f.fixture, receipt, suiteDirectory, path.join(f.run, 'receipts')); expect(receipt.environment).toEqual({ wordpress: null, theme: null, browser: null }); expect(failures).toContain('runtime observation requires artifact wordpressInventory'); @@ -145,7 +171,7 @@ describe('authoring execution receipt boundaries (no model or WordPress calls)', it('validates inventory shape and hashes independently from worker pass claims', async () => { const f = await setup(); - const receipt: AuthoringReceipt = f.execute(); + const receipt: AuthoringReceipt = await f.execute(); const receiptDirectory = path.join(f.run, 'receipts'); const add = async (name: string, value: unknown) => { const file = path.join(receiptDirectory, `${name}.json`); diff --git a/test/authoring.runner.test.ts b/test/authoring.runner.test.ts index cb8b574..177eac8 100644 --- a/test/authoring.runner.test.ts +++ b/test/authoring.runner.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; +import { author } from '../src/author/index.js'; import { materializeCandidate } from '../scripts/authoring-runner.js'; import { hashFile } from '../scripts/authoring/score.js'; import type { AuthoringFixture } from '../scripts/authoring/score.js'; @@ -22,29 +23,78 @@ describe('registered authoring candidate materialization', () => { expect(Buffer.from([0xff]).toString('utf8')).toBe(Buffer.from([0xfe]).toString('utf8')); expect(hashFile(first)).not.toBe(hashFile(second)); }); - it('runs the real compiler and does not substitute the expected scoring plan', async () => { + it('runs source-bound authoring before the real compiler and does not substitute the expected scoring plan', async () => { const root = await mkdtemp(path.join(tmpdir(), 'block-runner-real-candidate-')); - const fixture = { id: 'one', source: { path: 'source.html' }, plan: 'expected.json' } as AuthoringFixture; - await writeFile(path.join(root, 'source.html'), '

Source design

'); + const fixture = { + id: 'one', family: 'unit', source: { path: 'source.html' }, sourceDependencies: [{ path: 'source.css' }], plan: 'expected.json', + } as AuthoringFixture; + const source = 'The actual candidate'; + const css = '.notice { color: red; }'; + await writeFile(path.join(root, 'source.html'), source); + await writeFile(path.join(root, 'source.css'), css); await writeFile(path.join(root, 'expected.json'), '{"not":"a candidate"}'); - const plan = JSON.parse(await readFile('test/fixtures/authoring/pattern-overrides.plan.json', 'utf8')); - plan.structure[0].children[0].attributes.content = 'The actual candidate'; + const analysis = await author(source, { + sourcePath: path.join(root, 'source.html'), + author: { name: 'acme/notice', styles: { mode: 'css', css } }, + }); + expect(analysis.evidence?.coverage).toBeDefined(); + const plan = { + version: 1, + generatorVersion: '0.9.0', + target: { name: 'acme/notice', title: 'Notice', wordpress: '7.1' }, + source: analysis.source, + coverage: analysis.evidence!.coverage, + structure: [{ id: 'root', block: 'core/group', attributes: { className: 'notice' }, children: [ + { id: 'copy', block: 'core/paragraph', attributes: { content: 'The actual candidate' } }, + ] }], + fields: [], locking: { mode: 'none' }, + styles: { strategy: 'scoped-css', outcomes: [], rules: [{ kind: 'style', selector: '.notice', declarations: [{ property: 'color', value: 'red' }] }] }, + pattern: { ready: false, overrides: [] }, assets: [], files: [], warnings: [], + }; const candidatePlan = path.join(root, 'candidate.json'); await writeFile(candidatePlan, JSON.stringify(plan)); const candidate = path.join(root, 'generated'); const receipts = path.join(root, 'receipts'); - const manifest = materializeCandidate(fixture, root, candidate, receipts, candidatePlan); + const manifest = await materializeCandidate(fixture, root, candidate, receipts, candidatePlan); expect(await readFile(path.join(candidate, 'edit.js'), 'utf8')).toContain('The actual candidate'); expect(await readFile(path.join(candidate, 'save.js'), 'utf8')).toContain('useInnerBlocksProps.save( blockProps )'); expect(existsSync(path.join(candidate, 'src', 'edit.tsx'))).toBe(false); expect(existsSync(path.join(candidate, 'style-decisions.json'))).toBe(true); - expect(existsSync(path.join(candidate, 'style-ledger.json'))).toBe(false); + expect(JSON.parse(await readFile(path.join(candidate, 'source-coverage.json'), 'utf8'))).toMatchObject({ + valid: true, + source: { sha256: analysis.source!.sha256 }, + coverage: { styles: [expect.objectContaining({ property: 'color', value: 'red', outcome: 'scoped-css' })] }, + }); expect(await readFile(path.join(candidate, 'authoring-plan.json'), 'utf8')).toBe(JSON.stringify(plan)); expect(existsSync(path.join(receipts, manifest.path))).toBe(true); - expect(() => materializeCandidate(fixture, root, path.join(root, 'bad'), receipts, path.join(root, 'expected.json'))).toThrow('invalid authoring plan'); + await expect(materializeCandidate(fixture, root, path.join(root, 'bad'), receipts, path.join(root, 'expected.json'))).rejects.toThrow('invalid authoring plan'); expect(existsSync(path.join(root, 'bad'))).toBe(false); }); + it('refuses a candidate that omits source styling coverage before compiler output exists', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'block-runner-unbound-candidate-')); + const fixture = { + id: 'unbound', family: 'unit', source: { path: 'source.html' }, sourceDependencies: [{ path: 'source.css' }], + } as AuthoringFixture; + const source = '

Source design

'; + const css = '.notice { color: red; }'; + await writeFile(path.join(root, 'source.html'), source); + await writeFile(path.join(root, 'source.css'), css); + const analysis = await author(source, { + sourcePath: path.join(root, 'source.html'), + author: { name: 'acme/notice', styles: { mode: 'css', css } }, + }); + expect(analysis.ok, JSON.stringify(analysis.items)).toBe(true); + const plan = structuredClone(analysis.package!.canonicalPlan!); + plan.coverage!.styles = []; + const candidatePlan = path.join(root, 'candidate.json'); + await writeFile(candidatePlan, JSON.stringify(plan)); + + await expect(materializeCandidate(fixture, root, path.join(root, 'generated'), path.join(root, 'receipts'), candidatePlan)) + .rejects.toThrow(/complete source declaration and asset coverage/i); + expect(existsSync(path.join(root, 'generated'))).toBe(false); + }); + it('snapshots declared source dependencies inside the candidate for the worker', async () => { const root = await mkdtemp(path.join(tmpdir(), 'block-runner-source-snapshot-')); const fixture = { @@ -55,16 +105,21 @@ describe('registered authoring candidate materialization', () => { plan: 'expected.json', } as AuthoringFixture; await mkdir(path.join(root, 'sources'), { recursive: true }); - await writeFile(path.join(root, 'sources/design.html'), '
Design
'); + await writeFile(path.join(root, 'sources/design.html'), '
Design
'); await writeFile(path.join(root, 'sources/design.css'), '.design { color: red; }'); - const plan = JSON.parse(await readFile('test/fixtures/authoring/pattern-overrides.plan.json', 'utf8')); + const analysis = await author('
Design
', { + sourcePath: path.join(root, 'sources/design.html'), + author: { name: 'acme/dependency', styles: { mode: 'css', css: '.design { color: red; }' } }, + }); + expect(analysis.ok, JSON.stringify(analysis.items)).toBe(true); + const plan = analysis.package!.canonicalPlan!; await writeFile(path.join(root, 'expected.json'), '{}'); const candidatePlan = path.join(root, 'candidate.json'); await writeFile(candidatePlan, JSON.stringify(plan)); const candidate = path.join(root, 'generated'); const receipts = path.join(root, 'receipts'); - const manifest = materializeCandidate(fixture, root, candidate, receipts, candidatePlan); + const manifest = await materializeCandidate(fixture, root, candidate, receipts, candidatePlan); expect(await readFile(path.join(candidate, 'source', 'sources/design.html'), 'utf8')).toContain('Design'); expect(await readFile(path.join(candidate, 'source', 'sources/design.css'), 'utf8')).toContain('color: red'); diff --git a/test/authoring.style-ledger.test.ts b/test/authoring.style-ledger.test.ts new file mode 100644 index 0000000..44b3171 --- /dev/null +++ b/test/authoring.style-ledger.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { fixtureContractFailures, type AuthoringFixture } from '../scripts/authoring/score.js'; + +function fixture(artifactRoot: string): AuthoringFixture { + return { + id: 'style-ledger-validation', + family: 'styles', + source: { producer: 'unit-test', style: 'semantic-html' }, + candidate: { artifactRoot, requiredFiles: ['style-ledger.json'] }, + }; +} + +async function failures(property: string, value: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'block-runner-style-ledger-')); + const artifactRoot = path.join(root, 'candidate'); + await mkdir(artifactRoot); + await writeFile(path.join(artifactRoot, 'style-ledger.json'), JSON.stringify({ + version: 1, + entries: [{ + selector: '.example', + property, + value, + editorControl: 'scoped-css', + owner: 'block', + source: { path: 'source.html', line: 1, column: 1 }, + }], + })); + return fixtureContractFailures(fixture(artifactRoot), root); +} + +describe('authoring style-ledger receipt validation', () => { + it('accepts an empty value only for a valid custom property', async () => { + expect(await failures('--tw-placeholder', '')).toEqual([]); + }); + + it.each([ + ['ordinary property', 'color'], + ['invalid custom property', '--'], + ['whitespace custom property', '--tw-placeholder '], + ])('rejects an empty value for a %s', async (_label, property) => { + expect(await failures(property, '')).toContain('style-ledger entry 0 requires value'); + }); +}); diff --git a/test/skill.test.ts b/test/skill.test.ts index 915df07..6821e82 100644 --- a/test/skill.test.ts +++ b/test/skill.test.ts @@ -26,7 +26,10 @@ describe('canonical agent skill', () => { expect(skill).toContain('license: GPL-2.0-or-later'); expect(skill).toContain('compatibility: Requires Node.js ^20.19.0 || ^22.13.0 || >=24.0.0'); expect(skill).toContain('references/GUIDE.md'); - expect(await readCanonicalSkillGuide()).toContain('# Block Runner — agent guide'); + const guide = await readCanonicalSkillGuide(); + expect(guide).toContain('# Block Runner — agent guide'); + expect(guide).toContain('Primary HTML workflow: proposal → canonical plan'); + expect(guide).toContain('authoring-proposal-example:start'); }); it('keeps a balanced activation regression set', async () => { From 1b0d06bde785fb5ee8527ecade1acc926c24612a Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 8 Sep 2026 07:00:39 +0700 Subject: [PATCH 2/7] Document the complete authoring proposal workflow --- scripts/smoke-packed-cli.mjs | 14 ++++- skills/block-runner/SKILL.md | 3 +- skills/block-runner/references/GUIDE.md | 80 ++++++++++++++++++++----- test/author.proposal.test.ts | 27 ++++++++- 4 files changed, 106 insertions(+), 18 deletions(-) diff --git a/scripts/smoke-packed-cli.mjs b/scripts/smoke-packed-cli.mjs index ed4f2d1..dcde17a 100644 --- a/scripts/smoke-packed-cli.mjs +++ b/scripts/smoke-packed-cli.mjs @@ -33,8 +33,14 @@ try { const exampleFile = path.join(consumer, 'guide-authoring-proposal.mjs'); writeFileSync(exampleFile, example[1] + '\n'); const canonicalPlan = JSON.parse(run(process.execPath, [exampleFile], consumer).stdout); - if (canonicalPlan.version !== 1 || !canonicalPlan.source || !canonicalPlan.coverage) { - throw new Error('Packed guide authoring proposal example did not produce a canonical plan.'); + const nodes = flatten(canonicalPlan.structure ?? []); + const grid = nodes.find((node) => node.id === 'grid'); + const image = nodes.find((node) => node.id === 'image'); + if (canonicalPlan.version !== 1 || !canonicalPlan.source || !canonicalPlan.coverage + || !canonicalPlan.assets?.some((asset) => asset.source === 'https://cdn.example.test/editor.png' && asset.status === 'external') + || grid?.attributes?.layout?.type !== 'grid' + || image?.block !== 'core/image' || image.attributes?.caption !== 'Controls remain editable.') { + throw new Error('Packed guide authoring proposal example did not derive its canonical source, coverage, asset, and native bindings.'); } const typecheck = path.join(root, 'node_modules', '.bin', 'tsc'); @@ -65,6 +71,10 @@ function parsePackJson(stdout) { return JSON.parse(stdout.slice(start)); } +function flatten(nodes) { + return nodes.flatMap((node) => [node, ...flatten(node.children ?? [])]); +} + function trash(target) { // The smoke directories are unique temporary paths, but retain them if this host lacks trash. spawnSync('trash', [target], { encoding: 'utf8' }); diff --git a/skills/block-runner/SKILL.md b/skills/block-runner/SKILL.md index 200d6ee..69f8d56 100644 --- a/skills/block-runner/SKILL.md +++ b/skills/block-runner/SKILL.md @@ -20,7 +20,8 @@ package. Four paths. Pick by the requested artifact: -- **You need a reusable, named registered block in code** → for authored HTML, first create an +- **You need a reusable, named registered block in code** → for authored HTML, first read the + complete proposal-only contract in `references/GUIDE.md` §2, then create an `AuthorOptions.proposal`, not source code or a full plan. It records only semantic structure, source references, editability, locks, and reviewed source decisions; Block Runner returns the canonical `GeneratedAuthoringPlan` with hashes, coverage, assets, native style adapters, and diff --git a/skills/block-runner/references/GUIDE.md b/skills/block-runner/references/GUIDE.md index bcf892c..f818de4 100644 --- a/skills/block-runner/references/GUIDE.md +++ b/skills/block-runner/references/GUIDE.md @@ -65,26 +65,78 @@ PHP, `block.json`, generated CSS, `registerBlockType`, `register_block_type`, or delimiters. The generator owns executable source and block serialization; the model owns the reviewable semantic decisions. -### Primary HTML workflow: proposal → canonical plan - -This self-contained example uses only public packed imports. It intentionally supplies no manual -hashes, coverage ledger, assets, or native CSS: those are deterministic core responsibilities. +### Primary HTML workflow: complete proposal → canonical plan + +Read this proposal contract before the advanced complete-plan format below. A proposal has only +these root keys: `structure` (required), and optional `fields`, `locking`, `allowedBlocks`, +`pattern`, and `sourceDecisions`. Do not put `version`, `generatorVersion`, `target`, `source`, +`coverage`, `styles`, `assets`, `files`, or `warnings` in it: `author()` derives and owns those +canonical-plan records. + +Each `structure` node has required stable `id` and `block`, and optional `sourceRef`, +`attributes`, `lock`, and recursive `children`. A `sourceRef` is the exact opaque +`:-` value returned by `collectSourceEvidence()` for this exact input; +never construct, shorten, reuse, or edit one. Bind source-content units to their matching native +block: headings to `core/heading`, paragraphs to `core/paragraph`, list items to +`core/list-item`, figures (or unwrapped images) to `core/image`, and standalone links to +`core/button`. Containers such as `section` and `div`, plus required wrappers such as +`core/buttons`, may use their source reference when they represent source structure. Synthetic +wrappers have an `id` and `block` but no `sourceRef`; use them only where native nesting requires +one, for example `core/buttons` around source-bound `core/button` children. Do not bind the same +source unit twice or bind overlapping content nodes. + +`fields` are `{ id, label, mode, node?, attribute?, type?, default?, description? }`; `mode` is +exactly `fixed`, `editable`, or `override`. Point editable fields at the native node and attribute +they expose. Supported editing pairs are heading/paragraph/list-item `content`; image `id`, +`url`, `title`, `alt`, `caption`; and button `text`, `url`, `linkTarget`, `rel`. +`locking` is `{ mode: "all" | "contentOnly" | "insert" | "none", move?, remove?, +insert? }`; use a node's optional `{ move?, remove? }` `lock` for an individual node. `allowedBlocks` +is an optional direct-child insertion allowlist. `pattern` is optional +`{ ready, overrides: [{ field, label?, description? }] }` and refers to field IDs. + +`sourceDecisions` are reviewed dispositions, never executable instructions: +`{ action: "add" | "replace" | "omit", sourceRef, node?, attribute?, value?, reason }`. +Every source-content unit must be bound or explicitly omitted with a reason. A replacement names +the exact bound `node` and `attribute`; an add describes proposal-owned material; an omission is +for a real source unit. Do not use a decision to hide an unconsumed source value. + +Supported native source mappings include `figure > img + figcaption` owned together by one +`core/image` (`author()` derives and retains the image URL, valid source width and height, alt text, and caption), and `core/buttons > core/button` for +CTA links. An authored CSS grid is retained on its source-bound `core/group` when the native grid +mapping is supported. It is not a promise to convert arbitrary CSS grids into `core/columns`. +Node `label`, plus every complete-plan-only key listed above, belongs only to the advanced +`GeneratedAuthoringPlan` route and must not appear in a proposal. + +This small public example uses only packed public imports. It derives source evidence, source +coverage, and the external image asset from the supplied HTML; it supplies no manual ledger, +asset, hash, adapter, or warning. ```js import { author, collectSourceEvidence } from 'block-runner'; -const html = '

Build faster

Native editable blocks, reviewed first.

'; +const html = ` +

Release note

Ship native editing

Review the source trail before writing.

Editor controls
Controls remain editable.
`; const evidence = collectSourceEvidence(html); -const ref = (tag) => evidence.structure.find((entry) => entry.tag === tag)?.sourceRef; +const ref = (tag, occurrence = 0) => evidence.structure.filter((entry) => entry.tag === tag)[occurrence]?.sourceRef; const report = await author(html, { - author: { name: 'acme/small-hero', title: 'Small hero' }, + author: { name: 'acme/feature-note', title: 'Feature note', styles: { mode: 'css' } }, proposal: { - structure: [{ id: 'hero', block: 'core/group', sourceRef: ref('section'), children: [ - { id: 'title', block: 'core/heading', sourceRef: ref('h2') }, - { id: 'copy', block: 'core/paragraph', sourceRef: ref('p') }, + structure: [{ id: 'feature', block: 'core/group', sourceRef: ref('section'), children: [ + { id: 'grid', block: 'core/group', sourceRef: ref('div', 0), children: [ + { id: 'copy', block: 'core/group', sourceRef: ref('div', 1), children: [ + { id: 'eyebrow', block: 'core/paragraph', sourceRef: ref('p', 0) }, + { id: 'title', block: 'core/heading', sourceRef: ref('h2') }, + { id: 'body', block: 'core/paragraph', sourceRef: ref('p', 1) }, + { id: 'actions', block: 'core/buttons', sourceRef: ref('div', 2), children: [ + { id: 'start', block: 'core/button', sourceRef: ref('a', 0) }, + { id: 'docs', block: 'core/button', sourceRef: ref('a', 1) }, + ] }, + ] }, + { id: 'image', block: 'core/image', sourceRef: ref('figure') }, + ] }, ] }], - fields: [{ id: 'title', label: 'Title', mode: 'editable', node: 'title', attribute: 'content' }], + fields: [{ id: 'title-content', label: 'Title', mode: 'editable', node: 'title', attribute: 'content' }], locking: { mode: 'contentOnly' }, }, }); @@ -97,10 +149,10 @@ Run it from a project with the packed package installed, then preview, obtain co write the exact canonical identity: ```bash -node author-proposal.mjs > small-hero.plan.json -npx --no-install block-runner author preview small-hero.plan.json --output-dir +node author-proposal.mjs > feature-note.plan.json +npx --no-install block-runner author preview feature-note.plan.json --output-dir # Show the complete preview; obtain its full confirmation hash and explicit approval. -npx --no-install block-runner author write small-hero.plan.json --confirm '' --output-dir '' +npx --no-install block-runner author write feature-note.plan.json --confirm '' --output-dir '' ``` ### Advanced: complete `GeneratedAuthoringPlan` v1 shape diff --git a/test/author.proposal.test.ts b/test/author.proposal.test.ts index 340f851..77ec57f 100644 --- a/test/author.proposal.test.ts +++ b/test/author.proposal.test.ts @@ -262,7 +262,15 @@ describe('author proposal boundary', () => { ] }, { id: 'image', block: 'core/image', sourceRef: ref('figure') }, ] }, - ] }], fields: [{ id: 'title-content', label: 'Title', mode: 'editable' as const, node: 'title', attribute: 'content' }], locking: { mode: 'contentOnly' as const } }; + ] }], fields: [ + { id: 'eyebrow-content', label: 'Eyebrow', mode: 'editable' as const, node: 'eyebrow', attribute: 'content' }, + { id: 'title-content', label: 'Title', mode: 'editable' as const, node: 'title', attribute: 'content' }, + { id: 'lede-content', label: 'Body', mode: 'editable' as const, node: 'lede', attribute: 'content' }, + { id: 'download-text', label: 'Download CTA', mode: 'editable' as const, node: 'download', attribute: 'text' }, + { id: 'guide-text', label: 'Guide CTA', mode: 'editable' as const, node: 'guide', attribute: 'text' }, + { id: 'image-url', label: 'Product image URL', mode: 'editable' as const, node: 'image', attribute: 'url' }, + { id: 'image-alt', label: 'Product image alt text', mode: 'editable' as const, node: 'image', attribute: 'alt' }, + ], locking: { mode: 'contentOnly' as const } }; const options = { sourcePath, assetRoot: path.dirname(sourcePath), @@ -282,7 +290,23 @@ describe('author proposal boundary', () => { expect(nodes.get('download')!.attributes).toMatchObject({ text: 'Download the testing release', url: '/download' }); expect(nodes.get('guide')!.attributes).toMatchObject({ text: 'Read the authoring guide', url: '/docs/authoring' }); expect(nodes.get('image')!.attributes).toMatchObject({ alt: 'Aurora dashboard with color tokens, release receipts, and a completed activation check', caption: 'Native controls stay with the block, not in a screenshot.' }); + expect(plan.fields).toEqual([ + expect.objectContaining({ id: 'eyebrow-content', mode: 'editable', node: 'eyebrow', attribute: 'content' }), + expect.objectContaining({ id: 'title-content', mode: 'editable', node: 'title', attribute: 'content' }), + expect.objectContaining({ id: 'lede-content', mode: 'editable', node: 'lede', attribute: 'content' }), + expect.objectContaining({ id: 'download-text', mode: 'editable', node: 'download', attribute: 'text' }), + expect.objectContaining({ id: 'guide-text', mode: 'editable', node: 'guide', attribute: 'text' }), + expect.objectContaining({ id: 'image-url', mode: 'editable', node: 'image', attribute: 'url' }), + expect.objectContaining({ id: 'image-alt', mode: 'editable', node: 'image', attribute: 'alt' }), + ]); expect(plan.sourceDecisions).toBeUndefined(); + expect(plan.source).toMatchObject({ entry: sourcePath }); + expect(plan.assets).toEqual(expect.arrayContaining([expect.objectContaining({ + source: path.join(path.dirname(sourcePath), 'assets/aurora-dashboard.svg'), + uses: [expect.objectContaining({ node: 'image', attribute: 'url' })], + })])); + expect(plan.structure[0]!.children![0]!.children!.map((node) => node.id)).toEqual(['copy', 'image']); + expect(plan.locking).toEqual({ mode: 'contentOnly' }); const roundtrip = await author(html, { sourcePath, assetRoot: path.dirname(sourcePath), @@ -291,6 +315,7 @@ describe('author proposal boundary', () => { }); expect(roundtrip.ok, JSON.stringify(roundtrip.items)).toBe(true); expect(roundtrip.package!.canonicalPlan).toEqual(plan); + validateSourceContent(html, compileRegisteredBlock(plan).template); const tampered = structuredClone(plan); tampered.coverage!.styles.find((entry) => entry.transportSelector && entry.nativeTargets?.length)!.transportSelector = '.unrelated-target'; const rejected = await author(html, { sourcePath, assetRoot: path.dirname(sourcePath), author: options.author, plan: tampered }); From 03f995fae40fc48a43c41d682b15e4b5fd1c940a Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 8 Sep 2026 07:59:10 +0700 Subject: [PATCH 3/7] test(skill): align authoring workflow heading --- test/skill.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/skill.test.ts b/test/skill.test.ts index 6821e82..8fab788 100644 --- a/test/skill.test.ts +++ b/test/skill.test.ts @@ -28,7 +28,7 @@ describe('canonical agent skill', () => { expect(skill).toContain('references/GUIDE.md'); const guide = await readCanonicalSkillGuide(); expect(guide).toContain('# Block Runner — agent guide'); - expect(guide).toContain('Primary HTML workflow: proposal → canonical plan'); + expect(guide).toContain('Primary HTML workflow: complete proposal → canonical plan'); expect(guide).toContain('authoring-proposal-example:start'); }); From f9a3e73a32548d28b572dd19340074559f3d2d45 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 8 Sep 2026 08:17:57 +0700 Subject: [PATCH 4/7] fix(author): normalize equivalent image dimensions (#73) --- src/author/proposal.ts | 10 +++++++- test/author.proposal.test.ts | 47 +++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/author/proposal.ts b/src/author/proposal.ts index 16b48b0..5096ab0 100644 --- a/src/author/proposal.ts +++ b/src/author/proposal.ts @@ -120,12 +120,20 @@ function bindContent(node: AuthoringProposalNode, element: Element, attributes: const apply = (attribute: string, value: JsonValue): void => { const change = decision(attribute); if (change?.action === 'omit') return; - if (attributes[attribute] !== undefined && JSON.stringify(attributes[attribute]) !== JSON.stringify(value) && !change) throw new Error(`proposal ${node.id}.${attribute} changes source content without an explicit source decision`); + if (attributes[attribute] !== undefined && !sourceAttributeMatchesProposal(node.block, attribute, attributes[attribute], value) && !change) throw new Error(`proposal ${node.id}.${attribute} changes source content without an explicit source decision`); attributes[attribute] = change?.action === 'replace' || change?.action === 'add' ? change.value! : value; }; for (const [attribute, value] of sourceAttributes(element, node.block)) apply(attribute, value); } +/** Only native image dimensions accept their numeric proposal form; canonical plans retain source strings. */ +function sourceAttributeMatchesProposal(block: string, attribute: string, proposed: JsonValue, source: JsonValue): boolean { + if (JSON.stringify(proposed) === JSON.stringify(source)) return true; + return block === 'core/image' && (attribute === 'width' || attribute === 'height') + && typeof proposed === 'number' && Number.isSafeInteger(proposed) && proposed > 0 + && source === String(proposed); +} + function safeHtml(element: Element): string { const safe = richTextSafe(element); if (!safe.safe) throw new Error(`source content is not RichText-safe: ${safe.reason}`); return cleanRichText(element).html; } function ownsContent(block: string): boolean { return ['core/image', 'core/button', 'core/heading', 'core/paragraph', 'core/list-item'].includes(block); } function sourceLocation(element: Element | undefined, dom: JSDOM, path?: string): AuthoringCoverageLocation | undefined { const loc = element && dom.nodeLocation(element); return loc ? { path, htmlLine: loc.startLine, htmlColumn: loc.startCol, offset: loc.startOffset } : undefined; } diff --git a/test/author.proposal.test.ts b/test/author.proposal.test.ts index 77ec57f..bc18182 100644 --- a/test/author.proposal.test.ts +++ b/test/author.proposal.test.ts @@ -15,7 +15,7 @@ import { writeGeneratedRegisteredBlock, } from '../src/index.js'; import { validateSourceContent } from '../src/author/content.js'; -import type { AuthoringStructureNode } from '../src/authoring/schema.js'; +import type { AuthoringStructureNode, JsonValue } from '../src/authoring/schema.js'; const benchmarkRoot = path.resolve('benchmarks/authoring/sources'); async function source(relative: string): Promise<{ html: string; sourcePath: string }> { @@ -136,6 +136,51 @@ describe('author proposal boundary', () => { expect(report.package!.canonicalPlan!.sourceDecisions![0]).toMatchObject({ action: 'replace', original: 'Original copy', value: 'Revised copy' }); }); + it.each([ + ['direct image', 'Hero', 'img'], + ['figure image', '
Hero
', 'figure'], + ])('binds %s dimensions from equivalent numeric proposal values as source strings', async (_name, html, tag) => { + const reference = refs(html)(tag); + const report = await author(html, { author: { name: 'example/proposal' }, proposal: { + structure: [{ id: 'image', block: 'core/image', sourceRef: reference, attributes: { width: 640, height: 480 } }], + } }); + expect(report.ok, JSON.stringify(report.items)).toBe(true); + expect(report.package!.canonicalPlan!.structure[0]!.attributes).toMatchObject({ width: '640', height: '480' }); + expect(report.package!.canonicalPlan!.sourceDecisions).toBeUndefined(); + const omitted = await author(html, { author: { name: 'example/proposal' }, proposal: { + structure: [{ id: 'image', block: 'core/image', sourceRef: reference, attributes: { width: 640 } }], + sourceDecisions: [{ action: 'omit', sourceRef: reference, node: 'image', attribute: 'height', reason: 'Height is intentionally omitted.' }], + } }); + expect(omitted.ok, JSON.stringify(omitted.items)).toBe(true); + expect(omitted.package!.canonicalPlan!.structure[0]!.attributes).toMatchObject({ width: '640' }); + expect(omitted.package!.canonicalPlan!.structure[0]!.attributes).not.toHaveProperty('height'); + expect(omitted.package!.canonicalPlan!.sourceDecisions).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'omit', attribute: 'height', original: '480' }), + ])); + }); + + it('requires decisions for changed or invalid image dimension proposal values', async () => { + const html = 'Hero'; + const reference = refs(html)('img'); + const proposal = (width: JsonValue, height: JsonValue) => ({ structure: [{ id: 'image', block: 'core/image', sourceRef: reference, attributes: { width, height } }] }); + const changed = await author(html, { author: { name: 'example/proposal' }, proposal: proposal(641, 480) }); + expect(changed.ok).toBe(false); + expect(changed.items.map((item) => item.reason).join('\n')).toMatch(/changes source content without an explicit source decision/i); + for (const value of [640.5, 0, -640, Infinity, true, {}, Number.MAX_SAFE_INTEGER + 1, '640px']) { + const rejected = await author(html, { author: { name: 'example/proposal' }, proposal: proposal(value, 480) }); + expect(rejected.ok, `${String(value)}: ${JSON.stringify(rejected.items)}`).toBe(false); + } + const reviewed = await author(html, { author: { name: 'example/proposal' }, proposal: { + ...proposal('641', 480), + sourceDecisions: [{ action: 'replace', sourceRef: reference, node: 'image', attribute: 'width', value: '641', reason: 'Approved image crop.' }], + } }); + expect(reviewed.ok, JSON.stringify(reviewed.items)).toBe(true); + expect(reviewed.package!.canonicalPlan!.structure[0]!.attributes).toMatchObject({ width: '641', height: '480' }); + expect(reviewed.package!.canonicalPlan!.sourceDecisions).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'replace', attribute: 'width', original: '640', value: '641' }), + ])); + }); + it('accounts for identical source units by reference and audits href aliases', async () => { const html = '

Same

Same

Discard

Read'; const evidence = collectSourceEvidence(html).structure; From 5abf40b7b7670385970313d635a51a483c495aaf Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 8 Sep 2026 08:34:32 +0700 Subject: [PATCH 5/7] fix(author): make proposal diagnostics actionable (#74) --- src/author/index.ts | 62 ++++++++++++++++++++--- src/author/plan.ts | 7 ++- src/author/proposal.ts | 54 +++++++++++++++++++- test/author.diagnostics.test.ts | 45 +++++++++++++++- test/author.native-style-adapters.test.ts | 46 +++++++++++++---- 5 files changed, 194 insertions(+), 20 deletions(-) diff --git a/src/author/index.ts b/src/author/index.ts index d4a857a..b3907af 100644 --- a/src/author/index.ts +++ b/src/author/index.ts @@ -360,7 +360,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis command: 'author', source, summary: { blocks: 0, valid: 0, invalid: 0, warnings: graphItems.length + fontItems.length }, - items: [...fontItems, ...graphItems], + items: [...stageSourceAnalysisItems(fontItems), ...stageSourceAnalysisItems(graphItems)], styleLedger: safetyLedger, assets: assets.length > 0 ? assets : undefined, evidence: { ...evidence, dependencies: [...evidence.dependencies, { kind: 'tailwind-build', reference: 'pinned compiler/build graph' }], coverage: sourceCoverage(safetyLedger, assets, [...preparedAssets.values()], styleInput, editorStyleInput, definition, options, fontWarnings) }, @@ -375,7 +375,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis command: 'author', source, summary: { blocks: 0, valid: 0, invalid: 0, warnings: graphItems.length + fontItems.length }, - items: [...fontItems, ...graphItems], + items: [...stageSourceAnalysisItems(fontItems), ...stageSourceAnalysisItems(graphItems)], styleLedger: safetyLedger, assets: assets.length > 0 ? assets : undefined, evidence: { ...evidence, coverage: sourceCoverage(safetyLedger, assets, [...preparedAssets.values()], styleInput, editorStyleInput, definition, options, fontWarnings) }, @@ -402,7 +402,11 @@ export async function author(input: string, options: AuthorOptions = {}): Promis ...nativeSource.conversion.summary, warnings: nativeSource.conversion.summary.warnings + graphItems.length + fontItems.length, }, - items: [...nativeSource.conversion.items, ...fontItems, ...graphItems], + items: [ + ...stageSourceAnalysisItems(nativeSource.conversion.items), + ...stageSourceAnalysisItems(fontItems), + ...stageSourceAnalysisItems(graphItems), + ], styleLedger: preflightStyleLedger, assets: assets.length > 0 ? assets : undefined, evidence: { ...evidence, coverage: sourceCoverage(preflightStyleLedger, assets, [...preparedAssets.values()], styleInput, editorStyleInput, definition, options, fontWarnings) }, @@ -484,7 +488,27 @@ export async function author(input: string, options: AuthorOptions = {}): Promis selectorDependencies: selectorTransport.dependencies, }); } catch (error) { - return authorFailure(error, source, evidence); + const failureItems = stageFinalProposalItems(authoringFailureItems('input', error)); + return { + ...conversion, + ok: false, + command: 'author', + source, + summary: { + ...conversion.summary, + warnings: conversion.summary.warnings + fontItems.length + graphItems.length + assetItems.length + failureItems.length, + }, + items: [ + ...stageSourceAnalysisItems(conversion.items), + ...stageSourceAnalysisItems(fontItems), + ...stageSourceAnalysisItems(graphItems), + ...stageSourceAnalysisItems(assetItems), + ...failureItems, + ], + assets: assets.length > 0 ? assets : undefined, + styleLedger, + evidence, + }; } } let compiled: ReturnType | undefined; @@ -602,7 +626,13 @@ export async function author(input: string, options: AuthorOptions = {}): Promis ...conversion.summary, warnings: conversion.summary.warnings + fontItems.length + graphItems.length + assetItems.length + generationItems.length, }, - items: [...conversion.items, ...fontItems, ...graphItems, ...assetItems, ...generationItems], + items: [ + ...stageSourceAnalysisItems(conversion.items), + ...stageSourceAnalysisItems(fontItems), + ...stageSourceAnalysisItems(graphItems), + ...stageSourceAnalysisItems(assetItems), + ...stageFinalProposalItems(generationItems), + ], assets: assets.length > 0 ? assets : undefined, styleLedger: compiled ? [...styleLedger, ...compiled.editorStyleLedger] : styleLedger, package: packageSource, @@ -735,7 +765,7 @@ function authorFailure( source?: BlockRunnerReport['source'], evidence?: AuthorSourceEvidence, ): BlockRunnerReport { - const items = authoringFailureItems('input', failure); + const items = stageSourceAnalysisItems(authoringFailureItems('input', failure)); return { ok: false, command: 'author', @@ -746,6 +776,26 @@ function authorFailure( }; } +type AuthorItemStage = { stage: 'intermediate'; phase: 'source-analysis' } | { stage: 'final-proposal' }; + +function stageAuthorItems(items: readonly ReportItem[], stage: AuthorItemStage): ReportItem[] { + return items.map((item) => ({ + ...item, + details: { + ...(item.details && typeof item.details === 'object' && !Array.isArray(item.details) ? item.details : {}), + ...stage, + }, + })); +} + +function stageSourceAnalysisItems(items: readonly ReportItem[]): ReportItem[] { + return stageAuthorItems(items, { stage: 'intermediate', phase: 'source-analysis' }); +} + +function stageFinalProposalItems(items: readonly ReportItem[]): ReportItem[] { + return stageAuthorItems(items, { stage: 'final-proposal' }); +} + /** * Read source facts without applying the rules engine. Locations refer to the original HTML and * unknown elements are intentionally recorded rather than classified as invalid markup. diff --git a/src/author/plan.ts b/src/author/plan.ts index 61704a1..952e6c3 100644 --- a/src/author/plan.ts +++ b/src/author/plan.ts @@ -539,7 +539,12 @@ function adaptNativeSourceStyles( }); } if (!targets?.length) return [rule]; - const gridDeclarations = rule.declarations.filter((declaration) => gridProperties.has(declaration.property)); + // `display:flex` is ordinary residual layout, not evidence that a Core container owns a + // grid. Only display:grid participates in the native-grid ownership/rejection path. + const hasNonGridDisplay = rule.declarations.some((declaration) => declaration.property === 'display' && declaration.value.trim() !== 'grid'); + const gridDeclarations = hasNonGridDisplay ? [] : rule.declarations.filter((declaration) => declaration.property !== 'display' + ? gridProperties.has(declaration.property) + : declaration.value.trim() === 'grid'); const unresolved = (reason: string, target = targets[0], declaration = rule.declarations[0]) => { const sourceRef = target?.sourceRef; const location = sourceRef ? locationFor(sourceRef) : undefined; diff --git a/src/author/proposal.ts b/src/author/proposal.ts index 5096ab0..a545c57 100644 --- a/src/author/proposal.ts +++ b/src/author/proposal.ts @@ -74,7 +74,7 @@ export function bindAuthoringProposal(input: { element = index.get(node.sourceRef); if (!element) throw staleSourceRefDiagnostic('proposal sourceRef', node.sourceRef, node.id, input); if (isSourceUnit(element) && !isCompatibleSourceBinding(element, node.block)) { - throw new Error(`${describeElement(element, dom, input.sourcePath)}: proposal sourceRef ${node.sourceRef} cannot bind ${element.tagName.toLowerCase()} content to ${node.block}`); + throw incompatibleSourceBindingDiagnostic(node, element, dom, input); } if (ownsContent(node.block)) { const overlapping = [...usedElements.entries()].find(([, candidate]) => candidate === element || candidate.contains(element!) || element!.contains(candidate)); @@ -91,6 +91,7 @@ export function bindAuthoringProposal(input: { return { id: node.id, block: node.block, ...(Object.keys(attributes).length ? { attributes } : {}), ...(node.lock ? { lock: node.lock } : {}), ...(node.children?.length ? { children: node.children.map(bind) } : {}) }; }; const structure = input.proposal.structure.map(bind); + validateNativeProposalRelationships(input.proposal.structure, index, dom, input); const nodeIds = new Set(flatten(input.proposal.structure).map((node) => node.id)); validateDecisions(decisions, index, sourceRefToNode, nodeIds, input, dom); return { @@ -115,6 +116,57 @@ export function bindAuthoringProposal(input: { } finally { dom.window.close(); } } +/** Validate native parentage while source and proposal references are still directly available. */ +function validateNativeProposalRelationships( + nodes: readonly AuthoringProposalNode[], + index: ReadonlyMap, + dom: JSDOM, + input: Parameters[0], + parent?: AuthoringProposalNode, +): void { + for (const node of nodes) { + if (node.block === 'core/button' && parent?.block !== 'core/buttons') { + const element = node.sourceRef ? index.get(node.sourceRef) : undefined; + throw authorDiagnostic( + 'invalid-proposal-relationship', + `proposal node ${node.id} cannot bind core/button outside a core/buttons parent`, + sourceLocation(element, dom, input.sourcePath), + { + sourceRef: node.sourceRef, + node: node.id, + selectedParent: parent ? { node: parent.id, block: parent.block } : null, + requiredRelationship: { parentBlock: 'core/buttons', relationship: 'direct-child' }, + action: 'place-core-button-under-core-buttons', + }, + ); + } + validateNativeProposalRelationships(node.children ?? [], index, dom, input, node); + } +} + +function incompatibleSourceBindingDiagnostic( + node: AuthoringProposalNode, + element: Element, + dom: JSDOM, + input: Parameters[0], +): ReturnType { + const sourceTag = element.tagName.toLowerCase(); + const expectedBlock = sourceTag === 'figure' || sourceTag === 'img' ? 'core/image' : undefined; + return authorDiagnostic( + 'incompatible-proposal-source-binding', + `${describeElement(element, dom, input.sourcePath)}: proposal sourceRef ${node.sourceRef} cannot bind ${sourceTag} content to ${node.block}`, + sourceLocation(element, dom, input.sourcePath), + { + sourceRef: node.sourceRef, + node: node.id, + block: node.block, + ...(expectedBlock ? { requiredBlock: expectedBlock } : {}), + classification: 'incompatible-source-block-binding', + action: expectedBlock === 'core/image' ? 'replace-with-core-image' : 'select-a-compatible-native-block', + }, + ); +} + function bindContent(node: AuthoringProposalNode, element: Element, attributes: Record, decisions: Map, ref: string): void { const decision = (attribute: string) => decisions.get(`${ref}:${node.id}:${attribute}`) ?? decisions.get(`${ref}::${attribute}`); const apply = (attribute: string, value: JsonValue): void => { diff --git a/test/author.diagnostics.test.ts b/test/author.diagnostics.test.ts index 18cc4bb..42cce21 100644 --- a/test/author.diagnostics.test.ts +++ b/test/author.diagnostics.test.ts @@ -150,7 +150,50 @@ describe('author diagnostics', () => { proposal: { structure: [{ id: 'group', block: 'core/group', sourceRef: sourceRef(html, 'div') }, { id: 'button', block: 'core/button', sourceRef: sourceRef(html, 'a') }] }, }); expect(report.items).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'unresolved-native-style-mapping', details: expect.objectContaining({ classification: 'unsupported-native-style-mapping', unsupportedReason: expect.any(String) }) }), + expect.objectContaining({ + code: 'invalid-proposal-relationship', + details: expect.objectContaining({ requiredRelationship: { parentBlock: 'core/buttons', relationship: 'direct-child' }, action: 'place-core-button-under-core-buttons', stage: 'final-proposal' }), + }), ])); }); + + it('keeps source analysis visible when a final proposal relationship is rejected', async () => { + const html = 'Go
Photo
'; + const proposal = { structure: [{ id: 'cta', block: 'core/button', sourceRef: sourceRef(html, 'a') }] }; + const options = { author: { name: 'example/diagnostic-stages' }, proposal }; + const [first, second] = await Promise.all([author(html, options), author(html, options)]); + expect(first.ok).toBe(false); + expect(first.package).toBeUndefined(); + expect(first.items).toEqual(second.items); + expect(first.items.find((item) => /Custom HTML fallback/i.test(item.reason))) + .toMatchObject({ details: { stage: 'intermediate', phase: 'source-analysis' } }); + expect(first.items.find((item) => item.code === 'invalid-proposal-relationship')).toMatchObject({ + source: { offset: 0 }, + details: { + sourceRef: sourceRef(html, 'a'), + node: 'cta', + selectedParent: null, + requiredRelationship: { parentBlock: 'core/buttons', relationship: 'direct-child' }, + action: 'place-core-button-under-core-buttons', + stage: 'final-proposal', + }, + }); + }); + + it('names the core/image correction for a figure bound to an incompatible block', async () => { + const html = '
Photo
'; + const report = await author(html, { + author: { name: 'example/diagnostic-figure' }, + proposal: { structure: [{ id: 'layout', block: 'core/columns', sourceRef: sourceRef(html, 'figure') }] }, + }); + expect(report.ok).toBe(false); + expect(report.package).toBeUndefined(); + expect(report.items.find((item) => item.code === 'incompatible-proposal-source-binding')).toMatchObject({ + source: { offset: 0 }, + details: { + sourceRef: sourceRef(html, 'figure'), node: 'layout', block: 'core/columns', requiredBlock: 'core/image', + action: 'replace-with-core-image', stage: 'final-proposal', + }, + }); + }); }); diff --git a/test/author.native-style-adapters.test.ts b/test/author.native-style-adapters.test.ts index 366151a..34f0662 100644 --- a/test/author.native-style-adapters.test.ts +++ b/test/author.native-style-adapters.test.ts @@ -126,6 +126,28 @@ describe('native source-style adapters', () => { expect(report.items).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'unresolved-native-style-mapping' })])); }); + it('does not treat flex as authored-grid evidence while retaining the core/columns grid rejection', async () => { + const html = '

One

Two

'; + const proposal = { structure: [{ id: 'columns', block: 'core/columns', sourceRef: refs(html)('section'), children: [ + { id: 'first', block: 'core/column', children: [{ id: 'one', block: 'core/paragraph', sourceRef: refs(html)('p') }] }, + { id: 'second', block: 'core/column', children: [{ id: 'two', block: 'core/paragraph', sourceRef: refs(html)('p', 1) }] }, + ] }] }; + const flex = await author(html, { + author: { name: 'example/columns-flex', styles: { mode: 'css', css: '.layout { display: flex; gap: 1rem; }' } }, proposal, + }); + expect(flex.ok, JSON.stringify(flex.items)).toBe(true); + expect(flex.package!.canonicalPlan!.coverage!.styles.find((entry) => entry.property === 'display')?.nativeTargets).toBeUndefined(); + + const grid = await author(html, { + author: { name: 'example/columns-grid', styles: { mode: 'css', css: '.layout { display: grid; gap: 1rem; }' } }, proposal, + }); + expect(grid.ok).toBe(false); + expect(grid.package).toBeUndefined(); + expect(grid.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'unresolved-native-style-mapping', reason: expect.stringMatching(/core\/columns cannot own an authored grid/i) }), + ])); + }); + it('reads complete image class atoms and bounded figure child compounds', () => { expect(nativeSelectorSubjects('.min-h-\\[680px\\]')).toMatchObject([{ classes: [{ decoded: 'min-h-[680px]' }] }]); expect(nativeSelectorSubjects('.tracking-\\[0\\.18em\\]')).toMatchObject([{ classes: [{ decoded: 'tracking-[0.18em]' }] }]); @@ -182,10 +204,14 @@ describe('native source-style adapters', () => { author: { name: 'example/unwrapped-button', styles: { mode: 'css', css: 'div .move:hover { color: red; }' } }, proposal: { structure: [{ id: 'section', block: 'core/group', sourceRef: refs(html)('div') }, { id: 'button', block: 'core/button', sourceRef: refs(html)('a') }] }, }); - const item = report.items.find((candidate) => candidate.code === 'unresolved-native-style-mapping')!; + const item = report.items.find((candidate) => candidate.code === 'invalid-proposal-relationship')!; expect(report.package).toBeUndefined(); expect(item.source).toMatchObject({ path: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', offset: 21, htmlLine: 1, htmlColumn: 22 }); - expect(item.details).toMatchObject({ htmlSource: { path: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', offset: 21 }, cssSource: { selector: 'div .move:hover' } }); + expect(item.details).toMatchObject({ + sourceRef: refs(html)('a'), node: 'button', selectedParent: null, + requiredRelationship: { parentBlock: 'core/buttons', relationship: 'direct-child' }, + action: 'place-core-button-under-core-buttons', stage: 'final-proposal', + }); }); it('retains every matched unwrapped anchor for an unsupported relationship', async () => { @@ -195,16 +221,14 @@ describe('native source-style adapters', () => { author: { name: 'example/unwrapped-buttons', styles: { mode: 'css', css: 'div .move:hover { color: red; }' } }, proposal: { structure: [{ id: 'section', block: 'core/group', sourceRef: refs(html)('div') }, { id: 'button-a', block: 'core/button', sourceRef: refs(html)('a') }, { id: 'button-b', block: 'core/button', sourceRef: refs(html)('a', 1) }] }, }); - const item = report.items.find((candidate) => candidate.code === 'unresolved-native-style-mapping')!; + const item = report.items.find((candidate) => candidate.code === 'invalid-proposal-relationship')!; expect(report.package).toBeUndefined(); - expect(item.source).toBeUndefined(); - expect(item.details).toMatchObject({ cssSource: { selector: 'div .move:hover' } }); - const details = item.details as { htmlSources?: unknown }; - expect(details.htmlSources).toEqual([ - { sourceRef: refs(html)('a'), path: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', offset: 21, line: 1, column: 22 }, - { sourceRef: refs(html)('a', 1), path: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', offset: 56, line: 1, column: 57 }, - ]); - expect(item.details).not.toHaveProperty('htmlSource'); + expect(item.source).toMatchObject({ path: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', offset: 21, htmlLine: 1, htmlColumn: 22 }); + expect(item.details).toMatchObject({ + sourceRef: refs(html)('a'), node: 'button-a', selectedParent: null, + requiredRelationship: { parentBlock: 'core/buttons', relationship: 'direct-child' }, + action: 'place-core-button-under-core-buttons', stage: 'final-proposal', + }); }); it('rejects adapter targets whose marker is absent from serialized native markup', async () => { From 9181f22f4c2337211518dcdf4092704cb13b4a3c Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 8 Sep 2026 09:40:48 +0700 Subject: [PATCH 6/7] Preserve full-width source canvases --- src/authoring/generate.ts | 49 +++++++++++++++++++++++++++++++-- test/authoring.generate.test.ts | 19 +++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/authoring/generate.ts b/src/authoring/generate.ts index 11e80c6..96d107b 100644 --- a/src/authoring/generate.ts +++ b/src/authoring/generate.ts @@ -33,7 +33,7 @@ import { * The owned source-template contract. Changing it changes every generated package and must be an * intentional, reviewed release decision. */ -export const REGISTERED_BLOCK_TEMPLATE_VERSION = '0.9-static-v9' as const; +export const REGISTERED_BLOCK_TEMPLATE_VERSION = '0.9-static-v10' as const; /** * The declarative-style renderer is part of the owned template contract. It never accepts a * stylesheet fragment from the plan: its inputs are validated outcomes and structured rules. @@ -216,7 +216,7 @@ export function compileRegisteredBlock(input: AuthoringPlan): GeneratedRegistere sourceFile('edit.js', 'javascript', emitEditJs(template, allowedBlocks, plan.locking.mode, assets), operations.get('edit.js')!), sourceFile('save.js', 'javascript', emitSaveJs(), operations.get('save.js')!), sourceFile('style.scss', 'scss', fontStyles.css - + emitScss(plan.styles.outcomes, rootClass) + + emitScss(plan.styles.outcomes, rootClass, hasViewportRoot(plan)) + stylesheetSuffix(plan.styles.rules, rootClass, plan), operations.get('style.scss')!), // Shared styles are loaded by WordPress in both contexts. The editor stylesheet is a stable, // owned template seam for explicitly confirmed editor-only affordances. @@ -467,12 +467,19 @@ export default function save() { * produce no CSS. Token values are derived as WordPress preset references; scoped CSS outcomes * can only use an allowlisted declaration and a value that cannot introduce CSS structure. */ -export function emitScss(outcomes: readonly AuthoringStyleOutcome[], rootClass: string): string { +export function emitScss(outcomes: readonly AuthoringStyleOutcome[], rootClass: string, unconstrainedRoot = false): string { assertOwnedRootClass(rootClass); const declarations = outcomes.flatMap((outcome, index) => renderStyleOutcome(outcome, `styles.outcomes[${index}]`)); const lines = [ `/* Generated by registered-block style emitter v${REGISTERED_BLOCK_STYLE_EMITTER_VERSION}. */`, `${rootClass} {`, + ...(unconstrainedRoot ? [ + // WordPress constrains unknown custom blocks to the theme content column. This only applies + // to a source outer canvas with a centered, max-width inner group; its own retained rules + // still own the component's reading measure. + ' width: 100%;', + ' max-width: none !important;', + ] : []), ...declarations.map((declaration) => ` ${declaration}`), '}', '', @@ -480,6 +487,42 @@ export function emitScss(outcomes: readonly AuthoringStyleOutcome[], rootClass: return lines.join('\n'); } +/** + * A source canvas commonly has one outer group and an immediately nested group that centers a + * max-width layout. WordPress would otherwise constrain the generated wrapper before that source + * layout can run. Keep this inference deliberately structural: an ordinary constrained group + * never breaks out of its theme content column. + */ +function hasViewportRoot(plan: AuthoringPlan): boolean { + if (plan.structure.length !== 1 || plan.structure[0]?.block !== 'core/group') return false; + return (plan.structure[0].children ?? []).some((child) => child.block === 'core/group' + && centeredMaxWidthGroup(child, plan.styles.rules ?? [])); +} + +function centeredMaxWidthGroup( + node: AuthoringStructureNode, + rules: NonNullable, +): boolean { + const classes = new Set(typeof node.attributes?.className === 'string' + ? node.attributes.className.split(/\s+/).filter(Boolean) + : []); + if (!classes.size) return false; + let maxWidth = false; + let leftAuto = false; + let rightAuto = false; + for (const rule of rules) { + if (rule.kind !== 'style') continue; + const subjects = nativeSelectorSubjects(rule.selector); + if (!subjects?.some((subject) => subject.classes.some(({ decoded }) => classes.has(decoded)))) continue; + for (const declaration of rule.declarations) { + if (declaration.property === 'max-width') maxWidth = true; + if (declaration.property === 'margin-left' && declaration.value === 'auto') leftAuto = true; + if (declaration.property === 'margin-right' && declaration.value === 'auto') rightAuto = true; + } + } + return maxWidth && leftAuto && rightAuto; +} + /** Typed PHP emitter. It registers the package directory, letting WordPress load block.json metadata. */ export function emitPhp(): string { return ` { const style = sourceFile(first.files, 'style.scss').content; expect(style).toContain(`style emitter v${REGISTERED_BLOCK_STYLE_EMITTER_VERSION}`); + expect(style).not.toContain('max-width: none !important;'); expect(style).toContain('border: 1px solid #111;'); expect(style).toContain('color: var(--wp--preset--color--primary);'); expect(sourceFile(first.files, 'editor.scss').content).not.toContain('border: 1px solid #111;'); @@ -140,6 +141,24 @@ describe('registered-block source compiler', () => { } }); + it('preserves a source outer canvas around a centered max-width inner group', () => { + const input = plan(); + input.structure = [{ + id: 'canvas', block: 'core/group', children: [{ + id: 'layout', block: 'core/group', attributes: { className: 'mx-auto max-w-screen' }, + }], + }]; + input.styles.rules = [ + { kind: 'style', selector: '.mx-auto', declarations: [ + { property: 'margin-left', value: 'auto' }, { property: 'margin-right', value: 'auto' }, + ] }, + { kind: 'style', selector: '.max-w-screen', declarations: [{ property: 'max-width', value: '80rem' }] }, + ]; + + const style = sourceFile(compileRegisteredBlock(input).files, 'style.scss').content; + expect(style).toContain('.wp-block-acme-callout {\n width: 100%;\n max-width: none !important;'); + }); + it('uses the full pinned WordPress schema, including support-value constraints', () => { expect(() => validateBlockMetadata({ apiVersion: 3, From 0095101ed6bcbef3829a15555ab5297c3724b327 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 8 Sep 2026 09:53:20 +0700 Subject: [PATCH 7/7] Preserve authored flow spacing --- src/authoring/generate.ts | 44 ++++++++++++++++++++++++++++++--- test/authoring.generate.test.ts | 6 ++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/authoring/generate.ts b/src/authoring/generate.ts index 96d107b..0defccd 100644 --- a/src/authoring/generate.ts +++ b/src/authoring/generate.ts @@ -33,7 +33,7 @@ import { * The owned source-template contract. Changing it changes every generated package and must be an * intentional, reviewed release decision. */ -export const REGISTERED_BLOCK_TEMPLATE_VERSION = '0.9-static-v10' as const; +export const REGISTERED_BLOCK_TEMPLATE_VERSION = '0.9-static-v11' as const; /** * The declarative-style renderer is part of the owned template contract. It never accepts a * stylesheet fragment from the plan: its inputs are validated outcomes and structured rules. @@ -206,6 +206,8 @@ export function compileRegisteredBlock(input: AuthoringPlan): GeneratedRegistere const rootClass = blockRootClass(plan.target.name); const assets = collectConfirmedAssets(plan); const fontStyles = renderFontStyles(plan, assets); + const viewportRoot = hasViewportRoot(plan); + const sharedStyles = stylesheetSuffix(plan.styles.rules, rootClass, plan); const template = compileConfirmedTemplate(plan); const allowedBlocks = plan.allowedBlocks ?? unique(template.map(([name]) => name)).sort(); @@ -216,8 +218,9 @@ export function compileRegisteredBlock(input: AuthoringPlan): GeneratedRegistere sourceFile('edit.js', 'javascript', emitEditJs(template, allowedBlocks, plan.locking.mode, assets), operations.get('edit.js')!), sourceFile('save.js', 'javascript', emitSaveJs(), operations.get('save.js')!), sourceFile('style.scss', 'scss', fontStyles.css - + emitScss(plan.styles.outcomes, rootClass, hasViewportRoot(plan)) - + stylesheetSuffix(plan.styles.rules, rootClass, plan), operations.get('style.scss')!), + + emitScss(plan.styles.outcomes, rootClass, viewportRoot) + + sharedStyles + + (viewportRoot ? emitViewportFlowMarginPriority(plan.styles.rules ?? [], rootClass) : ''), operations.get('style.scss')!), // Shared styles are loaded by WordPress in both contexts. The editor stylesheet is a stable, // owned template seam for explicitly confirmed editor-only affordances. sourceFile('editor.scss', 'scss', emitScss([], rootClass) @@ -523,6 +526,41 @@ function centeredMaxWidthGroup( return maxWidth && leftAuto && rightAuto; } +/** + * WordPress gives every direct child of an is-layout-flow group a theme block-gap margin after + * package styles load. Keep only authored simple-class start margins above that host rule for a + * source canvas; other flow children still receive the theme gap normally. + */ +function emitViewportFlowMarginPriority( + rules: NonNullable, + rootClass: string, + depth = 0, +): string { + const indent = ' '.repeat(depth); + return rules.flatMap((rule) => { + if (rule.kind === 'conditional') { + const nested = emitViewportFlowMarginPriority(rule.rules, rootClass, depth + 1); + return nested ? `${indent}@${rule.name} ${rule.prelude} {\n${nested}\n${indent}}` : ''; + } + const selector = simpleStaticClassSelector(rule.selector); + const declarations = rule.declarations.filter(({ property }) => ( + property === 'margin-top' || property === 'margin-block-start' + )); + if (!selector || declarations.length === 0) return ''; + return `${indent}${rootClass} ${selector} { ${declarations.map((declaration) => ( + `${declaration.property}: ${declaration.value}${declaration.important ? ' !important' : ''};` + )).join(' ')} }`; + }).filter(Boolean).join('\n') + (depth === 0 ? '\n' : ''); +} + +function simpleStaticClassSelector(selector: string): string | undefined { + const trimmed = selector.trim(); + const subjects = nativeSelectorSubjects(trimmed); + const subject = subjects?.[0]; + if (!subject || subjects.length !== 1 || subject.state || subject.relation || subject.staticSelector !== trimmed) return undefined; + return subject.staticSelector; +} + /** Typed PHP emitter. It registers the package directory, letting WordPress load block.json metadata. */ export function emitPhp(): string { return ` { const input = plan(); input.structure = [{ id: 'canvas', block: 'core/group', children: [{ - id: 'layout', block: 'core/group', attributes: { className: 'mx-auto max-w-screen' }, + id: 'layout', block: 'core/group', attributes: { className: 'mx-auto max-w-screen' }, children: [{ + id: 'copy', block: 'core/paragraph', attributes: { className: 'source-gap' }, + }], }], }]; input.styles.rules = [ @@ -153,10 +155,12 @@ describe('registered-block source compiler', () => { { property: 'margin-left', value: 'auto' }, { property: 'margin-right', value: 'auto' }, ] }, { kind: 'style', selector: '.max-w-screen', declarations: [{ property: 'max-width', value: '80rem' }] }, + { kind: 'style', selector: '.source-gap', declarations: [{ property: 'margin-top', value: '1.5rem' }] }, ]; const style = sourceFile(compileRegisteredBlock(input).files, 'style.scss').content; expect(style).toContain('.wp-block-acme-callout {\n width: 100%;\n max-width: none !important;'); + expect(style).toContain('.wp-block-acme-callout .source-gap { margin-top: 1.5rem; }'); }); it('uses the full pinned WordPress schema, including support-value constraints', () => {