From c8fa728db7a3e912a5804afecd169a9556cf03b0 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:38:06 +0200 Subject: [PATCH 01/38] fix(site): preserve unmanaged post fields on update --- dev/test/site-apply-wpcli.test.ts | 9 +++++---- src/site/apply-wpcli.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/dev/test/site-apply-wpcli.test.ts b/dev/test/site-apply-wpcli.test.ts index 1ba1def..197c8e8 100644 --- a/dev/test/site-apply-wpcli.test.ts +++ b/dev/test/site-apply-wpcli.test.ts @@ -23,19 +23,20 @@ async function execute(request:SiteApplyRequest,flags:Record={}) $d=json_decode(base64_decode('${data}'),true);$flags=$d['flags'];$writes=0; $options=array($d['option']=>array('version'=>1,'siteId'=>'sample','entities'=>array(),'options'=>array(),'operation'=>array('token'=>'token','planHash'=>'confirmed','status'=>'started','currentStep'=>$d['step'],'stepEvidence'=>$d['evidence'])),$d['option'].'_lock'=>'token','show_on_front'=>'posts','page_on_front'=>'0'); if(isset($flags['wrongToken']))$options[$d['option'].'_lock']='foreign';if(isset($flags['wrongStep']))$options[$d['option']]['operation']['currentStep']['entityId']='foreign'; -$p=(object)array('ID'=>22,'post_type'=>'page','post_content'=>$d['state']['content'],'post_title'=>$d['state']['title'],'post_status'=>$d['state']['status'],'post_name'=>'home','post_parent'=>0); +$p=(object)array('ID'=>22,'post_type'=>'page','post_content'=>$d['state']['content'],'post_title'=>$d['state']['title'],'post_status'=>$d['state']['status'],'post_name'=>'home','post_parent'=>0,'post_author'=>31,'post_excerpt'=>'Human excerpt','post_date'=>'2025-02-03 04:05:06','post_date_gmt'=>'2025-02-03 03:05:06','menu_order'=>19); if(isset($flags['humanStatus']))$p->post_status='private'; $meta=array(22=>array('_wp_page_template'=>'index','_block_runner_site'=>array($d['owner']))); function home_url(){return 'https://example.test';}function site_url(){return home_url();}function get_current_blog_id(){return 1;}function get_current_user_id(){return 7;}function current_user_can($c){return true;}function get_stylesheet(){return 'sample';}function get_stylesheet_directory(){global $d;return $d['root'];}function wp_json_encode($v){return json_encode($v);} function get_option($k,$default=false){global $options;return $options[$k]??$default;}function add_option($k,$v,$unused='',$autoload=false){global $options;if(array_key_exists($k,$options))return false;$options[$k]=$v;return true;}function delete_option($k){global $options;unset($options[$k]);return true;}function update_option($k,$v,$autoload=false){global $options,$writes;$writes++;$options[$k]=$v;return true;} function get_post($id){global $p;return $id===22?$p:null;}function get_posts($args){global $p,$flags;return isset($flags['occupied'])?array($p):array();}function get_post_meta($id,$k,$single){global $meta;return $single?($meta[$id][$k]??''):($meta[$id][$k]??array());} function wp_slash($v){return $v;}function is_wp_error($v){return false;}function wp_insert_post($v,$errors){global $p,$writes,$flags;$writes++;$p=(object)array('ID'=>22,'post_type'=>$v['post_type'],'post_content'=>$v['post_content'],'post_title'=>$v['post_title'],'post_status'=>$v['post_status'],'post_name'=>$v['post_name'],'post_parent'=>$v['post_parent']);return 22;} +function wp_update_post($v,$errors){global $p,$writes;$writes++;foreach(array('post_type','post_title','post_content','post_status','post_name','post_parent') as $key)if(array_key_exists($key,$v))$p->$key=$v[$key];return (int)$v['ID'];} function update_post_meta($id,$k,$v){global $meta,$flags;if(isset($flags['ownershipCrash'])&&$k==='_block_runner_site')throw new Exception('crash');$meta[$id][$k]=$k==='_block_runner_site'?array($v):$v;return true;} function add_post_meta($id,$k,$v,$unique){global $meta,$flags;if(isset($flags['ownershipCrash']))throw new Exception('crash');$meta[$id][$k]=array($v);return true;} `; - const {stdout}=await run('php',['-r',stub+args.at(-1)!+'\necho "\\n" . json_encode(array("writes"=>$writes,"options"=>$options,"meta"=>$meta));'],{encoding:'utf8'});const lines=stdout.split('\n');output=lines[1]!;return lines[0]!; + const {stdout}=await run('php',['-r',stub+args.at(-1)!+'\necho "\\n" . json_encode(array("writes"=>$writes,"options"=>$options,"meta"=>$meta,"post"=>$p));'],{encoding:'utf8'});const lines=stdout.split('\n');output=lines[1]!;return lines[0]!; }); - return {response,evidence:JSON.parse(output) as {writes:number;options:Record;meta:Record},argsSeen}; + return {response,evidence:JSON.parse(output) as {writes:number;options:Record;meta:Record;post:Record},argsSeen}; } describe('fixed PHP mutation boundary executed with bounded WordPress stubs',()=>{ it('sanitizes thrown transport stderr without attaching raw output or a cause',async()=>{let error:unknown;try{await siteApplyWpCli({wpPath:'/wp'},boundary,{action:'lock'},async()=>{throw Object.assign(new Error('bootstrap SENTINEL_PRIVATE_WP_CREDENTIAL'),{stderr:'SENTINEL_PRIVATE_WP_CREDENTIAL'});});}catch(value){error=value;}expect(error).toBeInstanceOf(Error);expect((error as Error).message).toContain('site apply transport failed');expect(String(error)).not.toContain('SENTINEL_PRIVATE_WP_CREDENTIAL');expect(JSON.stringify(error)).not.toContain('SENTINEL_PRIVATE_WP_CREDENTIAL');}); @@ -45,7 +46,7 @@ describe('fixed PHP mutation boundary executed with bounded WordPress stubs',()= const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})},{[flag]:true});expect(r.response.status).toBe('refused');expect(r.evidence.writes).toBe(0); }); it('rejects a forged unowned update at the fixed PHP boundary with zero mutations',async()=>{const unowned=structuredClone(before);unowned.entities[0]!.ownership=[];const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'Forged'}},before:unowned,stateHash:managedStateHash({...state,title:'Forged'})});expect(r.response.status).toBe('refused');expect(r.evidence.writes).toBe(0);}); - it('writes exact managed fields and ownership without shell evaluation',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})});expect(r.response).toEqual({status:'complete',id:22});expect(r.argsSeen.slice(0,3)).toEqual(['--path=/wp path;$(false)','--user=7','eval']);}); + it('writes exact managed fields and ownership without shell evaluation',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})});expect(r.response).toEqual({status:'complete',id:22});expect(r.evidence.post).toMatchObject({post_title:'New',post_author:31,post_excerpt:'Human excerpt',post_date:'2025-02-03 04:05:06',post_date_gmt:'2025-02-03 03:05:06',menu_order:19});expect(r.argsSeen.slice(0,3)).toEqual(['--path=/wp path;$(false)','--user=7','eval']);}); it('refuses occupied slug/parent creation before insert',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'create',desired:state},before,stateHash:managedStateHash(state)},{occupied:true});expect(r.response.status).toBe('refused');expect(r.evidence.writes).toBe(0);}); it('reports insertion before ownership failure as uncertain',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'create',desired:state},before,stateHash:managedStateHash(state)},{ownershipCrash:true});expect(r.response.status).toBe('outcome_unknown');expect(r.evidence.writes).toBe(1);}); it('normalizes the native numeric front-page option only for its exact before-state check',async()=>{const r=await execute({action:'mutate',step:{key:'option:page_on_front',kind:'option',entityId:'page_on_front',status:'started'},operation:{action:'set-option',kind:'option',entityId:'page_on_front',desired:{value:22}},before,stateHash:managedStateHash({value:22})});expect(r.response.status).toBe('complete');expect(r.evidence.options.page_on_front).toBe(22);}); diff --git a/src/site/apply-wpcli.ts b/src/site/apply-wpcli.ts index 3eeec60..b7d6178 100644 --- a/src/site/apply-wpcli.ts +++ b/src/site/apply-wpcli.ts @@ -113,7 +113,7 @@ $result=(function() use($e,$r,&$attempted,$same,$state) { $data=array('post_type'=>$type,'post_title'=>$desired['title'],'post_content'=>$desired['content'],'post_status'=>$desired['status']); if($id) $data['ID']=$id; if($kind==='page') { $data['post_name']=$desired['slug']; $data['post_parent']=$desired['parentId']; } - $attempted=true; $written=wp_insert_post(wp_slash($data),true); if(is_wp_error($written)||!$written) throw new Exception('insert'); $id=(int)$written; + $attempted=true; $written=$id ? wp_update_post(wp_slash($data),true) : wp_insert_post(wp_slash($data),true); if(is_wp_error($written)||!$written) throw new Exception('save'); $id=(int)$written; if($kind==='page') update_post_meta($id,'_wp_page_template',$desired['template']); $owner=array('version'=>1,'siteId'=>$e['siteId'],'entityId'=>$op['entityId'],'kind'=>$kind,'lastAppliedHash'=>$r['stateHash']); if(isset($op['id'])) update_post_meta($id,'${SITE_OWNERSHIP_META}',$owner); else if(!add_post_meta($id,'${SITE_OWNERSHIP_META}',$owner,true)) throw new Exception('ownership'); From 5811bb6d92546281da3887e2b17d821e946f0e1c Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:39:00 +0200 Subject: [PATCH 02/38] perf(site): transmit only the selected mutation baseline --- dev/test/site-apply-wpcli.test.ts | 13 +++++++++++++ src/site/apply-wpcli.ts | 12 +++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/dev/test/site-apply-wpcli.test.ts b/dev/test/site-apply-wpcli.test.ts index 197c8e8..6ad4864 100644 --- a/dev/test/site-apply-wpcli.test.ts +++ b/dev/test/site-apply-wpcli.test.ts @@ -47,6 +47,19 @@ describe('fixed PHP mutation boundary executed with bounded WordPress stubs',()= }); it('rejects a forged unowned update at the fixed PHP boundary with zero mutations',async()=>{const unowned=structuredClone(before);unowned.entities[0]!.ownership=[];const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'Forged'}},before:unowned,stateHash:managedStateHash({...state,title:'Forged'})});expect(r.response.status).toBe('refused');expect(r.evidence.writes).toBe(0);}); it('writes exact managed fields and ownership without shell evaluation',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})});expect(r.response).toEqual({status:'complete',id:22});expect(r.evidence.post).toMatchObject({post_title:'New',post_author:31,post_excerpt:'Human excerpt',post_date:'2025-02-03 04:05:06',post_date_gmt:'2025-02-03 03:05:06',menu_order:19});expect(r.argsSeen.slice(0,3)).toEqual(['--path=/wp path;$(false)','--user=7','eval']);}); + it('does not transmit unrelated content or snapshot inventories with a selected mutation',async()=>{ + const request:SiteApplyRequest={action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})}; + const capture=async(value:SiteApplyRequest)=>{ + let encoded='';await siteApplyWpCli({wpPath:'/wp'},boundary,value,async(_exe,args)=>{encoded=args.at(-1)!.match(/base64_decode\('([^']+)'\)/)![1]!;return JSON.stringify({status:'complete',id:22});});return encoded; + }; + const baseline=await capture(request); + const larger=structuredClone(request);if(larger.action!=='mutate')throw new Error('fixture action'); + larger.before.entities.push({id:9001,kind:'page',route:'/unrelated',state:{content:'x'.repeat(500_000)},ownership:[]}); + larger.before.overrides.push({id:9002,kind:'wp_template',theme:['other'],content:'x'.repeat(500_000)}); + expect(await capture(larger)).toBe(baseline); + const payload=JSON.parse(Buffer.from(baseline,'base64').toString()); + expect(payload.request.before).toEqual({entities:[before.entities[0]],options:{}}); + }); it('refuses occupied slug/parent creation before insert',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'create',desired:state},before,stateHash:managedStateHash(state)},{occupied:true});expect(r.response.status).toBe('refused');expect(r.evidence.writes).toBe(0);}); it('reports insertion before ownership failure as uncertain',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'create',desired:state},before,stateHash:managedStateHash(state)},{ownershipCrash:true});expect(r.response.status).toBe('outcome_unknown');expect(r.evidence.writes).toBe(1);}); it('normalizes the native numeric front-page option only for its exact before-state check',async()=>{const r=await execute({action:'mutate',step:{key:'option:page_on_front',kind:'option',entityId:'page_on_front',status:'started'},operation:{action:'set-option',kind:'option',entityId:'page_on_front',desired:{value:22}},before,stateHash:managedStateHash({value:22})});expect(r.response.status).toBe('complete');expect(r.evidence.options.page_on_front).toBe(22);}); diff --git a/src/site/apply-wpcli.ts b/src/site/apply-wpcli.ts index b7d6178..8ad3cb6 100644 --- a/src/site/apply-wpcli.ts +++ b/src/site/apply-wpcli.ts @@ -61,7 +61,17 @@ function runWithInput(executable: string, args: string[], input: string): Promis } function script(boundary: SiteApplyBoundary, request: SiteApplyRequest): string { - const encoded = Buffer.from(JSON.stringify({ ...boundary, request, ...(request.action==='mutate'&&request.operation.id?{beforeStateHash:managedStateHash(request.before.entities.find(entity=>entity.id===request.operation.id)?.state ?? {})}:{}), option:siteRecordOption(boundary.siteId), lock: `${siteRecordOption(boundary.siteId)}_lock` })).toString('base64'); + // The executor keeps complete transition evidence. PHP needs only this mutation's baseline. + const payloadRequest = request.action === 'mutate' ? { + ...request, + before: { + entities: request.operation.id ? request.before.entities.filter(entity => entity.id === request.operation.id) : [], + options: request.operation.kind === 'option' + ? { [request.operation.entityId]: request.before.options[request.operation.entityId] } + : {}, + }, + } : request; + const encoded = Buffer.from(JSON.stringify({ ...boundary, request: payloadRequest, ...(request.action==='mutate'&&request.operation.id?{beforeStateHash:managedStateHash(request.before.entities.find(entity=>entity.id===request.operation.id)?.state ?? {})}:{}), option:siteRecordOption(boundary.siteId), lock: `${siteRecordOption(boundary.siteId)}_lock` })).toString('base64'); return ` $e=json_decode(base64_decode('${encoded}'),true); $r=$e['request']; $attempted=false; $same=function($a,$b) use (&$same) { if (is_array($a) && is_array($b)) { if(count($a)!==count($b)) return false; foreach($a as $k=>$v) if(!array_key_exists($k,$b)||!$same($v,$b[$k])) return false; return true; } return $a===$b; }; From ffb450970c84e108d069ed989ce864bf26070e6d Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:41:39 +0200 Subject: [PATCH 03/38] perf: defer theme schema and proof work --- dev/test/node-support.test.ts | 13 ++++ dev/test/proof-theme.test.ts | 62 +++++++++++++++++++ scripts/smoke-packed-cli-unsupported-node.mjs | 13 +++- src/index-implementation.ts | 8 +++ src/index.ts | 23 ++++--- src/proof/runner.ts | 3 +- src/theme/references.ts | 7 ++- src/theme/theme-json.ts | 12 +++- 8 files changed, 124 insertions(+), 17 deletions(-) diff --git a/dev/test/node-support.test.ts b/dev/test/node-support.test.ts index ff39caa..82985f2 100644 --- a/dev/test/node-support.test.ts +++ b/dev/test/node-support.test.ts @@ -22,6 +22,19 @@ describe('Node support contract', () => { ); }); + it('retains runtime site APIs on the guarded library entry point', async () => { + const library = await import('../../src/index.js'); + for (const name of [ + 'planSite', 'inspectSiteDrift', 'confirmSitePull', 'previewSitePull', + 'readSiteSnapshot', 'readSiteTemplateSnapshot', 'prepareSiteSource', + 'compileSite', 'compileThemeDocuments', 'writeCompiledSite', 'applySite', + ] as const) { + expect(library[name], name).toBeTypeOf('function'); + } + expect(library.SitePullPublicationError).toBeTypeOf('function'); + expect(library.SiteWriteError).toBeTypeOf('function'); + }); + it('keeps package, documentation, skill, and CI declarations aligned', async () => { const packageJson = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')); const readme = await readFile(new URL('../../README.md', import.meta.url), 'utf8'); diff --git a/dev/test/proof-theme.test.ts b/dev/test/proof-theme.test.ts index 7da5f14..f053f38 100644 --- a/dev/test/proof-theme.test.ts +++ b/dev/test/proof-theme.test.ts @@ -297,4 +297,66 @@ describe('theme Site Editor proof profile', () => { }); expect(blogdescription).toBe('Original site description'); }); + + it('omits the redundant browser fix payload and retains the final fix comparison', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'block-runner-theme-roundtrip-fix-')); + const theme = path.join(root, 'fixture-theme'); + const config = path.join(root, 'wp-env.json'); + const outputDir = path.join(root, 'receipts'); + const markup = '

Fixture

'; + await mkdir(path.join(theme, 'templates'), { recursive: true }); + await writeFile(path.join(theme, 'style.css'), '/*\nTheme Name: Fixture\nVersion: 1.0.0\n*/\n'); + await writeFile(path.join(theme, 'templates', 'index.html'), markup); + await writeFile(config, JSON.stringify({ core: 'WordPress/WordPress#7.1', phpVersion: '8.4' })); + let blogdescription = 'Original site description'; + let browserFiles: Array> = []; + const result = await runProof({ + theme, + wpEnvConfig: config, + outputDir, + commandRunner: async (command, args, options) => { + if (args.includes('option') && args.includes('get') && args.includes('blogdescription')) { + return { command, args: [...args], exitCode: 0, stdout: JSON.stringify(blogdescription), stderr: '' }; + } + if (args.includes('option') && args.includes('update') && args.includes('blogdescription')) { + blogdescription = args[args.indexOf('blogdescription') + 1]!; + return { command, args: [...args], exitCode: 0, stdout: 'Success', stderr: '' }; + } + if (options.phase === 'browser-theme-save') { + const configPath = args[args.indexOf('--config') + 1]!; + const browserConfig = JSON.parse(await readFile(configPath, 'utf8')) as { files: Array> }; + browserFiles = browserConfig.files; + const output = args[args.indexOf('--out') + 1]!; + await writeFile(output, JSON.stringify({ + theme: 'fixture-theme', + runtimeBinding: { verified: true }, + files: [{ + file: 'templates/index.html', + status: 'pass', + loadedRaw: markup, + savedRaw: markup, + editorSave: { serialized: markup }, + }], + })); + return { command, args: [...args], exitCode: 0, stdout: '', stderr: '' }; + } + return { command, args: [...args], exitCode: 0, stdout: '', stderr: '' }; + }, + }); + + expect(browserFiles).toHaveLength(1); + expect(browserFiles[0]).not.toHaveProperty('fix'); + const roundtrip = result.receipt.gates.find((gate) => gate.gate === 'theme_roundtrip'); + expect(roundtrip?.status).toBe('pass'); + expect(roundtrip?.details).toMatchObject({ + files: [{ + file: 'templates/index.html', + fixComparison: { equal: false }, + sourceLoadComparison: { equal: true }, + sourceComparison: { equal: true }, + editorRestComparison: { equal: true }, + }], + }); + expect(blogdescription).toBe('Original site description'); + }); }); diff --git a/scripts/smoke-packed-cli-unsupported-node.mjs b/scripts/smoke-packed-cli-unsupported-node.mjs index bcd89ee..6068064 100644 --- a/scripts/smoke-packed-cli-unsupported-node.mjs +++ b/scripts/smoke-packed-cli-unsupported-node.mjs @@ -5,7 +5,7 @@ * dependencies load. */ import { spawnSync } from 'node:child_process'; -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -27,8 +27,13 @@ try { run('npm', ['install', '--engine-strict', '--no-audit', '--no-fund', tarball], consumer); const cli = path.join(consumer, 'node_modules', 'block-runner', 'dist', 'cli.js'); + const dependencyEntry = path.join(consumer, 'node_modules', '@wordpress', 'blocks', 'build-module', 'index.mjs'); + const dependencySentinel = path.join(consumer, 'dependency-loaded'); + writeFileSync(dependencyEntry, `import { writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(dependencySentinel)}, 'loaded');\nthrow new Error('DEPENDENCY_LOAD_SENTINEL');\n`); expectUnsupported('CLI', [cli, '--version']); + expectDependencyNotLoaded(dependencySentinel); expectUnsupported('library', ['--input-type=module', '--eval', "await import('block-runner')"]); + expectDependencyNotLoaded(dependencySentinel); console.log('Packed CLI and library rejected unsupported Node ' + unsupportedNode + ' before dependency loading.'); } finally { @@ -36,6 +41,12 @@ try { trash(consumer); } +function expectDependencyNotLoaded(sentinel) { + if (existsSync(sentinel)) { + throw new Error('Packed entry point loaded a production dependency before rejecting unsupported Node.'); + } +} + function expectUnsupported(subject, args) { const result = spawnSync('npx', ['--yes', '--package=node@' + unsupportedNode, 'node', ...args], { cwd: consumer, diff --git a/src/index-implementation.ts b/src/index-implementation.ts index 437cf84..d403b36 100644 --- a/src/index-implementation.ts +++ b/src/index-implementation.ts @@ -1,5 +1,13 @@ export { canonicalize } from './gate/canonicalize.js'; export { validate } from './gate/validate.js'; +export { planSite } from './site/plan.js'; +export { inspectSiteDrift } from './site/drift.js'; +export { confirmSitePull, previewSitePull, SitePullPublicationError } from './site/pull.js'; +export { readSiteSnapshot, readSiteTemplateSnapshot } from './site/wpcli.js'; +export { prepareSiteSource } from './site/input.js'; +export { compileSite, compileThemeDocuments } from './site/compile.js'; +export { SiteWriteError, writeCompiledSite } from './site/write.js'; +export { applySite } from './site/apply.js'; export { convert } from './convert/assemble.js'; export { author, collectSourceEvidence } from './author/index.js'; export { diff --git a/src/index.ts b/src/index.ts index 155c615..e64bdb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -119,24 +119,28 @@ export const { writeAuthoringOutput, writeGeneratedRegisteredBlock, writePluginOutput, + planSite, + inspectSiteDrift, + confirmSitePull, + previewSitePull, + SitePullPublicationError, + readSiteSnapshot, + readSiteTemplateSnapshot, + prepareSiteSource, + compileSite, + compileThemeDocuments, + SiteWriteError, + writeCompiledSite, + applySite, } = library; - -export { planSite } from './site/plan.js'; -export { inspectSiteDrift } from './site/drift.js'; export type { SiteDriftFinding, SiteDriftFindingStatus, SiteDriftOptions, SiteDriftReport } from './site/drift.js'; -export { confirmSitePull, previewSitePull, SitePullPublicationError } from './site/pull.js'; export type { SitePullOptions, SitePullResult, SitePullSelection } from './site/pull.js'; -export { readSiteSnapshot } from './site/wpcli.js'; -export { readSiteTemplateSnapshot } from './site/wpcli.js'; export type { SitePlanOptions } from './site/plan.js'; export type { SiteWpCliOptions } from './site/wpcli.js'; export type { SiteTemplateObservation, SiteTemplateSnapshot } from './site/wpcli.js'; export type { SitePlan, SiteSnapshot } from './site/plan-types.js'; -export { prepareSiteSource } from './site/input.js'; -export { compileSite, compileThemeDocuments } from './site/compile.js'; export type { CompileSiteOptions, CompileSiteResult, CompileThemeDocumentsResult } from './site/compile.js'; -export { SiteWriteError, writeCompiledSite } from './site/write.js'; export type { SiteWriteOptions, SiteWriteResult } from './site/write.js'; export type * from './index-implementation.js'; @@ -153,5 +157,4 @@ export type UnsupportedPluginLayoutError = import('./index-implementation.js').U export type PublicationInterruptedError = import('./index-implementation.js').PublicationInterruptedError; export type PluginPublicationInterruptedError = import('./index-implementation.js').PluginPublicationInterruptedError; -export { applySite } from './site/apply.js'; export type { SiteApplyOptions, SiteApplyReceipt } from './site/apply.js'; diff --git a/src/proof/runner.ts b/src/proof/runner.ts index 83672fe..e344bee 100644 --- a/src/proof/runner.ts +++ b/src/proof/runner.ts @@ -1516,8 +1516,7 @@ async function runThemeBrowserProof( const reportPath = path.join(work, 'result.json'); const files = []; for (const file of theme.files) { - const fixed = await canonicalize(file.content); - files.push({ file: file.file, kind: file.kind, content: file.content, fix: fixed.output ?? file.content }); + files.push({ file: file.file, kind: file.kind, content: file.content }); } let baseUrl: string; try { diff --git a/src/theme/references.ts b/src/theme/references.ts index 6b81518..274429f 100644 --- a/src/theme/references.ts +++ b/src/theme/references.ts @@ -19,6 +19,7 @@ export async function findThemeReferenceIssues( ): Promise { const references: ReportItem[] = []; const skippedKinds = new Set(); + const presetRegistries = new Map(); for (const entry of templateParts(themeJson)) { if (!hasPartFile(files, entry.name)) { @@ -40,7 +41,11 @@ export async function findThemeReferenceIssues( nodes.forEach((block, index) => { const blockPath = `${parent}[${index}]`; const attributes = isRecord(block.attributes) ? block.attributes : {}; - const registry = presetRegistry(themeJson, block.name); + let registry = presetRegistries.get(block.name); + if (!registry) { + registry = presetRegistry(themeJson, block.name); + presetRegistries.set(block.name, registry); + } const warn = (code: string, reason: string, details: Record): void => { references.push({ block: block.name, diff --git a/src/theme/theme-json.ts b/src/theme/theme-json.ts index 39181f2..93818ac 100644 --- a/src/theme/theme-json.ts +++ b/src/theme/theme-json.ts @@ -5,7 +5,12 @@ import type { BlockRunnerReport, CommandName, ReportItem } from '../types.js'; export const WORDPRESS_THEME_SCHEMA_VERSION = '7.1' as const; export const WORDPRESS_THEME_SCHEMA_URL = `https://schemas.wp.org/wp/${WORDPRESS_THEME_SCHEMA_VERSION}/theme.json`; -const validateThemeSchema = new Ajv({ allErrors: true, strict: false }).compile(WORDPRESS_THEME_SCHEMA_7_1); +let validateThemeSchema: ReturnType | undefined; + +function getThemeSchemaValidator(): NonNullable { + validateThemeSchema ??= new Ajv({ allErrors: true, strict: false }).compile(WORDPRESS_THEME_SCHEMA_7_1); + return validateThemeSchema; +} /** Validate one theme.json against the vendored WordPress schema, returning parsed JSON for reuse. */ export function validateThemeJson(content: string, file: string, command: CommandName = 'validate'): @@ -31,9 +36,10 @@ export function validateThemeJson(content: string, file: string, command: Comman } const items: ReportItem[] = []; - const valid = validateThemeSchema(parsed); + const schemaValidator = getThemeSchemaValidator(); + const valid = schemaValidator(parsed); if (!valid) { - for (const error of validateThemeSchema.errors ?? []) { + for (const error of schemaValidator.errors ?? []) { const pointer = errorPointer(error); items.push({ status: 'invalid', From 00d2296b902bddceb6a36eb7d74271e8149fb65b Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:41:43 +0200 Subject: [PATCH 04/38] fix: bound site asset preparation --- dev/test/site-contract.test.ts | 45 ++++++++++++++++++++++++- src/authoring/destination.ts | 31 +----------------- src/publication.ts | 60 ++++++++++++++++++++++++++++++++-- src/site/assets.ts | 4 +-- src/site/input.ts | 28 ++++++++++++++-- 5 files changed, 129 insertions(+), 39 deletions(-) diff --git a/dev/test/site-contract.test.ts b/dev/test/site-contract.test.ts index 897eb33..8b24de3 100644 --- a/dev/test/site-contract.test.ts +++ b/dev/test/site-contract.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, realpath, rm, symlink } from 'node:fs/promises'; +import { mkdtemp, readFile, realpath, rm, symlink, truncate, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -51,6 +51,29 @@ describe('site intent contract', () => { } }); + it('uses canonical source identity independent of object key insertion order', async () => { + const input = await loadIntent(); + const baseline = await prepareSiteSource(input, fixtureRoot); + const reordered = Object.fromEntries(Object.entries(input).reverse()); + const nested = reordered.theme as Record; + reordered.theme = Object.fromEntries(Object.entries(nested).reverse()); + const reversed = await prepareSiteSource(reordered, fixtureRoot); + expect(baseline.ok).toBe(true); + expect(reversed.ok).toBe(true); + if (baseline.ok && reversed.ok) expect(reversed.source.sourceIdentity).toBe(baseline.source.sourceIdentity); + }); + + it('reports malformed native tree nodes and children without throwing during graph preparation', async () => { + const input = await loadIntent(); + input.pages[0].body.blocks = [null, { block: 'core/group', children: { block: 'core/paragraph' } }]; + const diagnostics: Array<{ path: string; code: string }> = []; + expect(() => validateSiteIntent(input, diagnostics as any)).not.toThrow(); + expect(diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: '/pages/0/body/blocks/0', code: 'intent-node-required' }), + expect.objectContaining({ path: '/pages/0/body/blocks/1/children', code: 'array-required' }), + ])); + }); + it('preserves deliberately empty content and permissive native attrs', async () => { const input = await loadIntent(); input.pages[0].body = { blocks: [] }; @@ -162,4 +185,24 @@ describe('site intent contract', () => { validateSiteIntent(input, diagnostics as any); expect(diagnostics).toContainEqual(expect.objectContaining({ path: '/assetUses/0/attribute', code: 'unsupported-attribute' })); }); + + it('rejects oversized assets before reading their contents', async () => { + const root = await mkdtemp(path.join(await realpath(tmpdir()), 'block-runner-site-large-asset-')); + try { + const input = await loadIntent(); + const oversized = path.join(root, 'oversized.png'); + const { mkdir } = await import('node:fs/promises'); + await mkdir(path.dirname(oversized), { recursive: true }); + await writeFile(oversized, 'x'); + await truncate(oversized, 20 * 1024 * 1024 + 1); + input.assets = [input.assets[0]]; + input.assets[0].path = 'oversized.png'; + input.assetUses = []; + const result = await prepareSiteSource(input, root); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics).toContainEqual(expect.objectContaining({ path: '/assets/0/path', code: 'asset-read' })); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/src/authoring/destination.ts b/src/authoring/destination.ts index e7ce1f1..37b674d 100644 --- a/src/authoring/destination.ts +++ b/src/authoring/destination.ts @@ -5,7 +5,7 @@ import { REGISTERED_BLOCK_TEMPLATE_VERSION, REGISTERED_BLOCK_STYLE_EMITTER_VERSI type GeneratedRegisteredBlock } from './generate.js'; import { hashAuthoringPlan, type AuthoringPlan, type AuthoringFileOperation } from './schema.js'; import { classifyRegisteredBlockRegeneration } from './regeneration.js'; -import { hashBytes, isNotFound as publicationNotFound, publicationHash, publishPublicationEntries, publishStagedFile, readStableRegularFile, reconcilePublicationCompletion, regularFileIdentity, replacePublicationJournal, sameFileIdentity, stagePublicationBytes, validatePublicationRetry, verifyPublicationTargets, verifyStagedPublicationBytes } from '../publication.js'; +import { canonicalizeTrustedSystemAlias, hashBytes, isNotFound as publicationNotFound, publicationHash, publishPublicationEntries, publishStagedFile, readStableRegularFile, reconcilePublicationCompletion, regularFileIdentity, replacePublicationJournal, sameFileIdentity, stagePublicationBytes, validatePublicationRetry, verifyPublicationTargets, verifyStagedPublicationBytes } from '../publication.js'; /** A read-only representation of the filesystem state relevant to a plan. */ export interface DestinationInspection { @@ -726,35 +726,6 @@ async function inspectDirectoryPath(directory: string, entries: Map `/private/tmp`). They are not destinations a caller can create or redirect. Verify - * the exact platform mapping before translating it, then inspect every remaining component - * lexically. No caller-controlled symlink is ever followed. - */ -async function canonicalizeTrustedSystemAlias(outputDirectory: string): Promise { - const directory = path.resolve(outputDirectory); - for (const [alias, physical] of [ - ['/tmp', '/private/tmp'], - ['/var', '/private/var'], - ['/etc', '/private/etc'], - ] as const) { - if (directory !== alias && !directory.startsWith(`${alias}${path.sep}`)) { - continue; - } - const stats = await lstatMaybe(alias); - if (!stats?.isSymbolicLink()) { - return directory; - } - const target = path.resolve(path.dirname(alias), await readlink(alias)); - if (target !== physical) { - throw new Error(`symbolic-link path is not allowed in authoring destination: ${alias}`); - } - return path.join(physical, path.relative(alias, directory)); - } - return directory; -} - async function inspectPlannedPath(directory: string, relativePath: string, entries: Map): Promise { const safePath = assertSafePlannedPath(relativePath); let current = directory; diff --git a/src/publication.ts b/src/publication.ts index 9148bf6..2575b9f 100644 --- a/src/publication.ts +++ b/src/publication.ts @@ -5,7 +5,8 @@ * abstraction: each adapter owns its plan, approval policy, journal schema and presentation. */ import { createHash, randomUUID } from 'node:crypto'; -import { link, lstat, open, readFile, rename, unlink } from 'node:fs/promises'; +import { link, lstat, open, readFile, readlink, rename, unlink } from 'node:fs/promises'; +import path from 'node:path'; export interface PublicationFileIdentity { dev: string; @@ -25,6 +26,27 @@ export function publicationHash(value: Buffer): string { return `sha256:${hashBytes(value)}`; } +/** Resolve only verified macOS compatibility aliases; caller-owned symlinks remain disallowed. */ +export async function canonicalizeTrustedSystemAlias(outputDirectory: string): Promise { + const directory = path.resolve(outputDirectory); + for (const [alias, physical] of [ + ['/tmp', '/private/tmp'], + ['/var', '/private/var'], + ['/etc', '/private/etc'], + ] as const) { + if (directory !== alias && !directory.startsWith(`${alias}${path.sep}`)) continue; + let stats; + try { stats = await lstat(alias); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (!stats?.isSymbolicLink()) return directory; + const target = path.resolve(path.dirname(alias), await readlink(alias)); + if (target !== physical) throw new Error(`symbolic-link path is not allowed in authoring destination: ${alias}`); + return path.join(physical, path.relative(alias, directory)); + } + return directory; +} + export function sameFileIdentity(left: PublicationFileIdentity, right: PublicationFileIdentity | undefined): boolean { return Boolean(right) && JSON.stringify(left) === JSON.stringify(right); } @@ -42,14 +64,46 @@ export async function regularFileIdentity(file: string, error: string, includeCo } /** Read only a stable regular file; an identity change during the read is rejected. */ -export async function readStableRegularFile(file: string, error: string): Promise<{ content: Buffer; identity: PublicationFileIdentity }> { +export async function readStableRegularFile(file: string, error: string, maximumBytes?: number): Promise<{ content: Buffer; identity: PublicationFileIdentity }> { const before = await regularFileIdentity(file, error); - const content = await readFile(file); + if (maximumBytes !== undefined && Number(before.size) > maximumBytes) throw new Error(error); + let content: Buffer; + if (maximumBytes === undefined) { + content = await readFile(file); + } else { + const handle = await open(file, 'r'); + try { + const opened = await handle.stat(); + if (!opened.isFile() || !sameFileIdentity(before, identityFromStats(opened))) throw new Error(error); + const chunks: Buffer[] = []; + let total = 0; + while (total <= maximumBytes) { + const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maximumBytes + 1 - total)); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null); + if (bytesRead === 0) break; + chunks.push(chunk.subarray(0, bytesRead)); + total += bytesRead; + } + if (total > maximumBytes) throw new Error(error); + content = Buffer.concat(chunks, total); + } finally { + await handle.close(); + } + } const after = await regularFileIdentity(file, error); if (!sameFileIdentity(before, after)) throw new Error(error); return { content, identity: after }; } +function identityFromStats(stats: Awaited>): PublicationFileIdentity { + const withNs = stats as typeof stats & { mtimeNs?: bigint; ctimeNs?: bigint }; + return { + dev: String(stats.dev), ino: String(stats.ino), mode: Number(stats.mode), size: String(stats.size), + mtimeNs: String(withNs.mtimeNs ?? BigInt(Math.round(Number(stats.mtimeMs) * 1_000_000))), + ctimeNs: String(withNs.ctimeNs ?? BigInt(Math.round(Number(stats.ctimeMs) * 1_000_000))), + }; +} + /** Stage bytes exclusively and durably before any target can be published. */ export async function stagePublicationBytes(temporary: string, bytes: Buffer, mode = 0o600): Promise { const handle = await open(temporary, 'wx', mode); diff --git a/src/site/assets.ts b/src/site/assets.ts index 88566bd..6e56308 100644 --- a/src/site/assets.ts +++ b/src/site/assets.ts @@ -47,11 +47,11 @@ export function buildSiteThemeAssets(source: PreparedSiteSource): SiteThemeAsset diagnostics.push(issue(at, 'prepared-asset-mismatch', 'prepared bytes do not match the declared site asset')); continue; } - const bytes = Buffer.from(prepared.bytes); - if (bytes.length === 0 || bytes.length > MAX_ASSET_BYTES) { + if (prepared.bytes.byteLength === 0 || prepared.bytes.byteLength > MAX_ASSET_BYTES) { diagnostics.push(issue(`${at}/path`, 'asset-size', 'asset is empty or exceeds 20 MiB')); continue; } + const bytes = Buffer.from(prepared.bytes); const hash = createHash('sha256').update(bytes).digest('hex'); if (hash !== prepared.sha256) { diagnostics.push(issue(`${at}/path`, 'asset-hash', 'prepared asset bytes no longer match their SHA-256')); diff --git a/src/site/input.ts b/src/site/input.ts index 0723552..26a1c9b 100644 --- a/src/site/input.ts +++ b/src/site/input.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { lstat } from 'node:fs/promises'; import path from 'node:path'; +import { canonicalJson } from '../proof/receipt.js'; import { validateSyncedPatternOverrideContent } from './patterns.js'; import { readStableRegularFile } from '../publication.js'; import type { IntentNode, IntentTree } from '../types.js'; @@ -19,6 +20,7 @@ import type { const SAFE_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; const ROUTE_SEGMENT = /^[^/?#\\\u0000]+$/; const ASSET_ATTRIBUTES = new Set(['url', 'attrs.url', 'attrs.src', 'attrs.id']); +const MAX_ASSET_BYTES = 20 * 1024 * 1024; /** Validate a site envelope and read each declared asset from the caller's explicit asset root. */ export async function prepareSiteSource(input: unknown, assetRoot: string): Promise { @@ -42,7 +44,7 @@ export async function prepareSiteSource(input: unknown, assetRoot: string): Prom const absolutePath = path.resolve(canonicalRoot, ...declaration.path.split('/')); if (!isWithin(canonicalRoot, absolutePath)) throw new Error('path escapes the asset root'); const beforeChain = await inspectPathChain(canonicalRoot, declaration.path); - const stable = await readStableRegularFile(absolutePath, 'asset changed or is not a regular file'); + const stable = await readStableRegularFile(absolutePath, 'asset changed or is not a regular file', MAX_ASSET_BYTES); const afterChain = await inspectPathChain(canonicalRoot, declaration.path); if (!samePathChain(beforeChain, afterChain) || stable.content.byteLength !== afterChain.at(-1)?.size) { throw new Error('asset changed while it was being prepared'); @@ -62,7 +64,7 @@ export async function prepareSiteSource(input: unknown, assetRoot: string): Prom } if (diagnostics.length > 0) return { ok: false, diagnostics }; - const intentJson = JSON.stringify(validated); + const intentJson = canonicalJson(validated); return { ok: true, diagnostics: [], @@ -563,7 +565,27 @@ function validateTree(value: unknown, pathName: string, diagnostics: SiteInputDi checkKeys(value, ['blocks'], pathName, diagnostics); // Preserve native intent trees verbatim. The document compiler/finalizer owns their detailed // validity; this boundary consumes only the tree root for references and asset-use paths. - return value as unknown as IntentTree; + let valid = true; + const pending: Array<{ nodes: unknown[]; path: string }> = [{ nodes: value.blocks, path: `${pathName}/blocks` }]; + while (pending.length > 0) { + const current = pending.pop()!; + for (let index = current.nodes.length - 1; index >= 0; index -= 1) { + const nodePath = `${current.path}/${index}`; + const candidate = current.nodes[index]; + if (!isRecord(candidate) || typeof candidate.block !== 'string' || candidate.block.length === 0) { + diagnostics.push(issue(nodePath, 'intent-node-required', 'each block must be an object with a non-empty block name')); + valid = false; + continue; + } + if (Object.hasOwn(candidate, 'children')) { + if (!Array.isArray(candidate.children)) { + diagnostics.push(issue(`${nodePath}/children`, 'array-required', 'block children must be an array when present')); + valid = false; + } else pending.push({ nodes: candidate.children, path: `${nodePath}/children` }); + } + } + } + return valid ? value as unknown as IntentTree : undefined; } function collectTrees( From 5df9ccc81b345f8a89665e19ccdd7b04006472fd Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:43:18 +0200 Subject: [PATCH 05/38] fix: seal compiled site artifacts --- dev/test/site-patterns.test.ts | 13 ++++- dev/test/site-write.test.ts | 65 ++++++++++++++++++----- src/site/compile.ts | 95 ++++++++++++++++++++++++++++------ src/site/write.ts | 20 +++++-- 4 files changed, 160 insertions(+), 33 deletions(-) diff --git a/dev/test/site-patterns.test.ts b/dev/test/site-patterns.test.ts index b425848..7200bce 100644 --- a/dev/test/site-patterns.test.ts +++ b/dev/test/site-patterns.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { validateSiteIntent } from '../../src/site/input.js'; -import { compileSite, compileSitePageDocuments, compileSiteSyncedPatterns } from '../../src/site/compile.js'; +import { compileSite, compileSitePageDocuments, compileSiteSyncedPattern, compileSiteSyncedPatterns } from '../../src/site/compile.js'; import type { PreparedSiteSource, SiteIntent, ThemeArtifact } from '../../src/site/types.js'; import { managedStateHash, reconcileSitePlan } from '../../src/site/plan.js'; import type { SiteEntity, SiteSnapshot } from '../../src/site/plan-types.js'; @@ -154,7 +154,10 @@ describe('site synced pattern contract and compiler', () => { ])); intent.syncedPatterns!.definitions.card!.title = 'Renamed card'; - const renamed: PreparedSiteSource = { ...source, sourceIntent: intent }; + const renamedResult = await (await import('../../src/site/input.js')).prepareSiteSource(intent, fixtureRoot); + expect(renamedResult.ok).toBe(true); + if (!renamedResult.ok) return; + const renamed: PreparedSiteSource = renamedResult.source; const updated = await compileSiteSyncedPatterns(renamed, { patternBindings: { card: 642 } }); const updatePlan = reconcileSitePlan(renamed, { ...theme, sourceIntent: intent }, [], snapshot, {}, [], updated, { card: 642 }); expect(updatePlan.operations.find((operation) => operation.kind === 'pattern')).toMatchObject({ action: 'update', id: 642, entityId: 'card', desired: { title: 'Renamed card' } }); @@ -239,5 +242,11 @@ describe('site synced pattern contract and compiler', () => { expect(pending[0]).not.toHaveProperty('content'); const bound = await compileSiteSyncedPatterns(prepared.source, { attachmentBindings: { 'home-hero': { id: 73, url: 'https://example.test/hero.png' } } }); expect(bound[0]!.content).toContain('"id":73'); + const single = await compileSiteSyncedPattern(prepared.source, 'card', { attachmentBindings: { 'home-hero': { id: 73, url: 'https://example.test/hero.png' } } }); + expect(single).toMatchObject({ ok: true, pattern: { id: 'card', content: expect.stringContaining('"id":73') } }); + const missing = await compileSiteSyncedPattern(prepared.source, 'missing'); + expect(missing).toMatchObject({ ok: false, diagnostics: [expect.objectContaining({ code: 'unknown-pattern-id' })] }); + const invalidSource = await compileSiteSyncedPattern({ ...prepared.source, sourceIdentity: 'stale' }, 'card'); + expect(invalidSource).toMatchObject({ ok: false, diagnostics: [expect.objectContaining({ code: 'prepared-source-identity-mismatch' })] }); }); }); diff --git a/dev/test/site-write.test.ts b/dev/test/site-write.test.ts index d9ea2c5..1f1950b 100644 --- a/dev/test/site-write.test.ts +++ b/dev/test/site-write.test.ts @@ -1,10 +1,11 @@ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { lstat, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { prepareSiteSource } from '../../src/site/input.js'; +import { calculateSiteArtifactIdentity, compileSite } from '../../src/site/compile.js'; import { SiteWriteError, writeCompiledSite } from '../../src/site/write.js'; import type { CompiledSite, PreparedSiteSource, SiteFile } from '../../src/site/types.js'; @@ -26,19 +27,24 @@ function file(filePath: string, text: string): SiteFile { } function artifact(source: PreparedSiteSource): CompiledSite { - return { + const themeFiles = [file('style.css', 'body { color: #20201e; }')]; + const targetBindings = {}; + const targetBindingFingerprint = createHash('sha256').update(JSON.stringify({ attachmentBindings: targetBindings })).digest('hex'); + const value: CompiledSite = { sourceIntent: source.sourceIntent, sourceAssets: source.assets, - targetBindings: {}, - targetBindingFingerprint: 'empty-bindings', - themeFiles: [file('style.css', 'body { color: #20201e; }')], + targetBindings, + targetBindingFingerprint, + themeFiles, pages: [], syncedPatterns: [], assets: [], diagnostics: [], - inventory: [], - artifactIdentity: 'compiled-site-test', + inventory: themeFiles.map(({ path: filePath, sha256, bytes }) => ({ path: filePath, sha256, size: bytes.byteLength })), + artifactIdentity: '', }; + value.artifactIdentity = calculateSiteArtifactIdentity(source, value); + return value; } describe('site bundle publication', () => { @@ -78,19 +84,23 @@ describe('site bundle publication', () => { const { root, source } = await setup(); try { const pendingPageBindings = source.unresolvedAssetUses.filter(({ documentId }) => documentId.startsWith('page:')); + const themeFiles = [file('style.css', 'body { color: #20201e; }')]; + const targetBindings = {}; + const targetBindingFingerprint = createHash('sha256').update(JSON.stringify({ attachmentBindings: targetBindings, themeAssetBaseUrl: 'https://example.test/wp-content/themes/sample-studio' })).digest('hex'); const themeArtifact = { sourceIntent: source.sourceIntent, sourceAssets: source.assets, - targetBindings: {}, - targetBindingFingerprint: 'theme-bindings-test', + targetBindings, + targetBindingFingerprint, themeAssetBaseUrl: 'https://example.test/wp-content/themes/sample-studio', - themeFiles: [file('style.css', 'body { color: #20201e; }')], + themeFiles, syncedPatterns: [], pendingPageBindings, diagnostics: [], - inventory: [], - artifactIdentity: 'compiled-theme-test', + inventory: themeFiles.map(({ path: filePath, sha256, bytes }) => ({ path: filePath, sha256, size: bytes.byteLength })), + artifactIdentity: '', }; + themeArtifact.artifactIdentity = calculateSiteArtifactIdentity(source, themeArtifact); const output = path.join(root, 'theme-bundle'); const result = await writeCompiledSite(output, source, themeArtifact); expect(result.artifactType).toBe('theme'); @@ -99,7 +109,7 @@ describe('site bundle publication', () => { expect(record.pendingPageBindings).toEqual(pendingPageBindings); expect(record).toMatchObject({ targetBindings: {}, - targetBindingFingerprint: 'theme-bindings-test', + targetBindingFingerprint, themeAssetBaseUrl: 'https://example.test/wp-content/themes/sample-studio', }); } finally { @@ -147,6 +157,21 @@ describe('site bundle publication', () => { } }); + it('accepts and canonicalizes the trusted macOS /tmp output alias', async () => { + const { root, source } = await setup(); + const name = `block-runner-site-alias-${randomUUID()}`; + const aliasOutput = path.join('/tmp', name); + const canonicalOutput = path.join(await realpath('/tmp'), name); + try { + const result = await writeCompiledSite(aliasOutput, source, artifact(source)); + expect(result.directory).toBe(canonicalOutput); + expect(await stat(path.join(canonicalOutput, 'site.manifest.json'))).toBeDefined(); + } finally { + await rm(canonicalOutput, { recursive: true, force: true }); + await rm(root, { recursive: true, force: true }); + } + }); + it('retains staging and refuses publication when source bytes change after staging', async () => { const { root, source } = await setup(); try { @@ -168,4 +193,18 @@ describe('site bundle publication', () => { await rm(root, { recursive: true, force: true }); } }); + + it('rejects compiler payload changes after the artifact identity was sealed', async () => { + const { root, source } = await setup(); + try { + const compiled = await compileSite(source, { attachmentBindings: { 'home-hero': { id: 73, url: 'https://example.test/hero.png' } } }); + expect(compiled.ok).toBe(true); + if (!compiled.ok) return; + compiled.site.pages[0]!.content += ''; + await expect(writeCompiledSite(path.join(root, 'mutated-bundle'), source, compiled.site)).rejects.toThrow('compiled artifact no longer matches its compiler identity'); + await expect(lstat(path.join(root, 'mutated-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/src/site/compile.ts b/src/site/compile.ts index aa74eca..9851914 100644 --- a/src/site/compile.ts +++ b/src/site/compile.ts @@ -42,6 +42,40 @@ export type CompileThemeDocumentsResult = | { ok: true; artifact: ThemeArtifact; diagnostics: ReportItem[] } | { ok: false; diagnostics: ReportItem[] }; +export type CompileSiteSyncedPatternResult = + | { ok: true; pattern: CompiledSyncedPattern } + | { ok: false; diagnostics: ReportItem[] }; + +/** Recompute compiler provenance from the retained source and current artifact payload. */ +export function calculateSiteArtifactIdentity(source: PreparedSiteSource, artifact: CompiledSite | ThemeArtifact): string { + const targetBindingFingerprint = hashTargetContext(artifact.targetBindings, artifact.themeAssetBaseUrl); + if (artifact.targetBindingFingerprint !== targetBindingFingerprint) throw new Error('compiled artifact target bindings do not match their fingerprint'); + const themeFiles = artifact.themeFiles.map((file) => { + const bytes = Buffer.from(file.bytes); + const sha256 = createHash('sha256').update(bytes).digest('hex'); + if (file.sha256 !== sha256) throw new Error(`compiled file ${file.path} no longer matches its SHA-256`); + return { path: file.path, sha256, size: bytes.byteLength }; + }).sort((left, right) => left.path.localeCompare(right.path)); + const inventory = artifact.inventory.slice().sort((left, right) => left.path.localeCompare(right.path)); + if (canonicalJson(inventory) !== canonicalJson(themeFiles)) throw new Error('compiled artifact inventory does not match its theme files'); + const identityInput = { + sourceIdentity: createHash('sha256').update(canonicalJson(source.sourceIntent)).digest('hex'), + sourceAssets: source.assets.map(({ id, path, sha256, bytes }) => { + if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`prepared source asset ${id} no longer matches its SHA-256`); + return { id, path, sha256 }; + }).sort((left, right) => left.id.localeCompare(right.id)), + targetBindingFingerprint, + themeFiles, + ...('pendingPageBindings' in artifact ? { + syncedPatterns: artifact.syncedPatterns.map(({ id, title, content, pendingUses, pendingBindings, diagnostics }) => ({ id, title, content, pendingUses, pendingBindings, diagnostics })), + } : { + pages: artifact.pages.map(({ id, route, title, parentId, template, content, status }) => ({ id, route, title, parentId, template, content, status })), + syncedPatterns: artifact.syncedPatterns.map(({ id, title, content, diagnostics }) => ({ id, title, content, diagnostics })), + }), + }; + return hashJson(identityInput); +} + const HARD_REFERENCE_CODES = new Set(['unresolved-template-part', 'unresolved-pattern', 'undeclared-preset']); interface CompiledDocument { @@ -100,9 +134,9 @@ export async function compileThemeDocuments( && !options.attachmentBindings?.[use.assetId]), diagnostics, inventory, - artifactIdentity: hashJson({ sourceIdentity: source.sourceIdentity, sourceAssets: sourceAssetIdentity(source), targetBindingFingerprint, themeFiles: inventory, - syncedPatterns: syncedPatterns.map(({ id, title, content, pendingUses, pendingBindings, diagnostics }) => ({ id, title, content, pendingUses, pendingBindings, diagnostics })) }), + artifactIdentity: '', }; + artifact.artifactIdentity = calculateSiteArtifactIdentity(source, artifact); return { ok: true, artifact, diagnostics }; } @@ -165,15 +199,9 @@ export async function compileSite( assets, diagnostics, inventory, - artifactIdentity: hashJson({ - sourceIdentity: source.sourceIdentity, - sourceAssets: sourceAssetIdentity(source), - targetBindingFingerprint, - themeFiles: inventory, - pages: pages.map(({ id, route, title, parentId, template, content, status }) => ({ id, route, title, parentId, template, content, status })), - syncedPatterns: syncedPatterns.map(({ id, title, content, diagnostics }) => ({ id, title, content, diagnostics })), - }), + artifactIdentity: '', }; + site.artifactIdentity = calculateSiteArtifactIdentity(source, site); return { ok: true, site, diagnostics }; } @@ -204,7 +232,43 @@ export async function compileSitePageDocuments(source: PreparedSiteSource, optio export async function compileSiteSyncedPatterns(source: PreparedSiteSource, options: CompileSiteOptions = {}, allowPendingBindings = true): Promise { const prepared = validatePreparedSource(source); if (!prepared.ok) return []; + return compileSiteSyncedPatternsFromIntent(source, prepared.intent, options, allowPendingBindings); +} + +/** Compile one current database-owned definition with the caller's current destination bindings. */ +export async function compileSiteSyncedPattern( + source: PreparedSiteSource, + patternId: string, + options: CompileSiteOptions = {}, + allowPendingBindings = true, +): Promise { + const prepared = validatePreparedSource(source); + if (!prepared.ok) return { ok: false, diagnostics: prepared.diagnostics }; const intent = prepared.intent; + const definition = intent.syncedPatterns?.definitions[patternId]; + if (!definition) return { ok: false, diagnostics: [invalid(`synced-pattern:${patternId}`, 'unknown-pattern-id', `unknown synced pattern "${patternId}"`)] }; + + const selected: SiteIntent = { + ...intent, + syncedPatterns: { + version: 1, + definitions: { [patternId]: definition }, + uses: intent.syncedPatterns!.uses.filter((use) => use.documentId === `synced-pattern:${patternId}`), + }, + }; + const patterns = await compileSiteSyncedPatternsFromIntent(source, selected, options, allowPendingBindings); + const pattern = patterns[0]; + if (!pattern) return { ok: false, diagnostics: [invalid(`synced-pattern:${patternId}`, 'pattern-compilation-failed', 'synced pattern definition was not compiled')] }; + if (!pattern.ok) return { ok: false, diagnostics: pattern.diagnostics }; + return { ok: true, pattern }; +} + +function compileSiteSyncedPatternsFromIntent( + source: PreparedSiteSource, + intent: SiteIntent, + options: CompileSiteOptions, + allowPendingBindings: boolean, +): Promise { return compileSyncedPatterns(intent, options.patternBindings, (body, documentId) => { const result = bindDocument(body, documentId, intent, source, options, allowPendingBindings); return result.diagnostics; @@ -500,6 +564,10 @@ function validatePreparedSource(source: PreparedSiteSource): { ok: true; intent: if (!intent) { return { ok: false, diagnostics: inputDiagnostics.map((entry) => ({ status: 'invalid', reason: entry.message, source: { path: entry.path }, code: entry.code })) }; } + const expectedSourceIdentity = createHash('sha256').update(canonicalJson(intent)).digest('hex'); + if (source.sourceIdentity !== expectedSourceIdentity) { + return { ok: false, diagnostics: [invalid('site', 'prepared-source-identity-mismatch', 'prepared source identity does not match its canonical site intent')] }; + } if (!Array.isArray(source?.assets)) { return { ok: false, diagnostics: [invalid('site', 'prepared-asset-mismatch', 'prepared asset inventory is missing')] }; } @@ -509,7 +577,8 @@ function validatePreparedSource(source: PreparedSiteSource): { ok: true; intent: } for (const declaration of intent.assets) { const prepared = byId.get(declaration.id); - if (!prepared || prepared.sha256.length !== 64 || !/^[a-f0-9]{64}$/.test(prepared.sha256) + if (!prepared || !(prepared.bytes instanceof Uint8Array) || prepared.bytes.byteLength === 0 || prepared.bytes.byteLength > 20 * 1024 * 1024 + || prepared.sha256.length !== 64 || !/^[a-f0-9]{64}$/.test(prepared.sha256) || createHash('sha256').update(prepared.bytes).digest('hex') !== prepared.sha256 || prepared.path !== declaration.path || prepared.role !== declaration.role || prepared.kind !== declaration.kind) { return { ok: false, diagnostics: [invalid('site', 'prepared-asset-mismatch', `prepared asset ${declaration.id} does not match the declared source asset`)] }; @@ -555,10 +624,6 @@ function usedBindings( return Object.fromEntries(ids.map((id) => [id, structuredClone(bindings[id]!)])); } -function sourceAssetIdentity(source: PreparedSiteSource): Array<{ id: string; path: string; sha256: string }> { - return source.assets.map(({ id, path, sha256 }) => ({ id, path, sha256 })).sort((left, right) => left.id.localeCompare(right.id)); -} - function assetRole(intent: SiteIntent, id: string): SiteIntent['assets'][number]['role'] | undefined { return intent.assets.find((asset) => asset.id === id)?.role; } diff --git a/src/site/write.ts b/src/site/write.ts index 2809265..7f9acd7 100644 --- a/src/site/write.ts +++ b/src/site/write.ts @@ -1,7 +1,8 @@ import { randomUUID } from 'node:crypto'; import { chmod, lstat, mkdir, open, rename, rmdir } from 'node:fs/promises'; import path from 'node:path'; -import { hashBytes } from '../publication.js'; +import { canonicalizeTrustedSystemAlias, hashBytes } from '../publication.js'; +import { calculateSiteArtifactIdentity } from './compile.js'; import { verifyPreparedSiteAssets } from './assets.js'; import type { CompiledSite, PreparedSiteSource, SiteFile, ThemeArtifact } from './types.js'; @@ -40,7 +41,7 @@ export async function writeCompiledSite( assertArtifactSource(source, artifact); await verifyPreparedSiteAssets(source); - const target = path.resolve(outputDirectory); + const target = await canonicalizeTrustedSystemAlias(outputDirectory); const parent = path.dirname(target); const name = path.basename(target); if (!name || name === '.' || name === path.parse(target).root) throw new Error('bundle output needs a named new directory'); @@ -106,6 +107,7 @@ function buildBundleFiles(source: PreparedSiteSource, artifact: CompiledSite | T }; add('site.intent.json', `${JSON.stringify(source.sourceIntent, null, 2)}\n`); for (const asset of source.assets) { + assertBoundedAsset(asset.id, asset.bytes); const bytes = Buffer.from(asset.bytes); if (hashBytes(bytes) !== asset.sha256) throw new Error(`prepared source asset ${asset.id} no longer matches its SHA-256`); add(`source/assets/${asset.path}`, bytes); @@ -218,12 +220,24 @@ function assertArtifactSource(source: PreparedSiteSource, artifact: CompiledSite const compiledAssets = new Map(artifact.sourceAssets.map((asset) => [asset.id, asset])); if (compiledAssets.size !== source.assets.length || source.assets.some((asset) => { const compiled = compiledAssets.get(asset.id); - return !compiled || compiled.sha256 !== asset.sha256 || compiled.path !== asset.path || !Buffer.from(compiled.bytes).equals(Buffer.from(asset.bytes)); + return !compiled || !isBoundedAssetBytes(asset.bytes) || !isBoundedAssetBytes(compiled.bytes) + || compiled.sha256 !== asset.sha256 || compiled.path !== asset.path || !Buffer.from(compiled.bytes).equals(Buffer.from(asset.bytes)); })) throw new Error('compiled artifact does not retain the exact prepared source assets'); if (!artifact.artifactIdentity || !artifact.targetBindingFingerprint || !artifact.targetBindings || !Array.isArray(artifact.themeFiles) || ('assets' in artifact && !Array.isArray(artifact.assets))) { throw new Error('compiled artifact is incomplete'); } + if (calculateSiteArtifactIdentity(source, artifact) !== artifact.artifactIdentity) { + throw new Error('compiled artifact no longer matches its compiler identity'); + } +} + +function assertBoundedAsset(id: string, bytes: Uint8Array): void { + if (!isBoundedAssetBytes(bytes)) throw new Error(`prepared source asset ${id} is empty or exceeds 20 MiB`); +} + +function isBoundedAssetBytes(bytes: unknown): bytes is Uint8Array { + return bytes instanceof Uint8Array && bytes.byteLength > 0 && bytes.byteLength <= 20 * 1024 * 1024; } function assertRelativePath(value: string): void { From 3d4c34c594367028f828535d5b510aa4d8e41bd3 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:46:25 +0200 Subject: [PATCH 06/38] fix(site): serialize applies across ownership namespaces --- dev/test/site-apply-wpcli.test.ts | 26 ++++++++++++++++++-------- dev/test/site-apply.test.ts | 14 +++++++++++--- docs/reference.md | 15 +++++++++++++++ src/site/apply-wpcli.ts | 14 +++++++++++--- src/site/apply.ts | 2 +- src/site/media-import.ts | 4 +++- src/site/wpcli.ts | 2 ++ 7 files changed, 61 insertions(+), 16 deletions(-) diff --git a/dev/test/site-apply-wpcli.test.ts b/dev/test/site-apply-wpcli.test.ts index 6ad4864..9e661e7 100644 --- a/dev/test/site-apply-wpcli.test.ts +++ b/dev/test/site-apply-wpcli.test.ts @@ -6,27 +6,29 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { siteApplyWpCli, type SiteApplyBoundary, type SiteApplyRequest } from '../../src/site/apply-wpcli.js'; import { managedStateHash } from '../../src/site/plan.js'; -import { siteRecordOption } from '../../src/site/wpcli.js'; +import { siteRecordOption, SITE_APPLY_LOCK_OPTION } from '../../src/site/wpcli.js'; import type { SiteSnapshot } from '../../src/site/plan-types.js'; const run=promisify(execFile); const boundary:SiteApplyBoundary={siteId:'sample',planHash:'confirmed',token:'token',target:{homeUrl:'https://example.test',siteUrl:'https://example.test',blogId:1},principal:{userId:7,capabilities:{edit_pages:true}},theme:{slug:'sample',assetBaseUrl:'https://example.test/theme',inventory:[]}}; const state={content:'Hello',title:'Home',status:'draft',slug:'home',parentId:0,template:'index'}; const step={key:'page:home',kind:'page' as const,entityId:'home',status:'started' as const}; const before:SiteSnapshot={target:boundary.target,runtime:{wordpress:'7.1',php:'8.4'},principal:boundary.principal,theme:boundary.theme,routing:{permalinkStructure:'/%postname%/',rewriteRulesHash:'rules',routes:[]},entities:[{id:22,kind:'page',route:'/home',state,ownership:[{version:1,siteId:'sample',entityId:'home',kind:'page',lastAppliedHash:managedStateHash(state)}]}],overrides:[],navigation:[],options:{show_on_front:'posts',page_on_front:0},siteRecord:null}; -async function execute(request:SiteApplyRequest,flags:Record={}) { +async function execute(request:SiteApplyRequest,flags:Record={},selectedBoundary:SiteApplyBoundary=boundary) { const root=await mkdtemp(path.join(await realpath(os.tmpdir()),'apply-php-')); - const data=Buffer.from(JSON.stringify({root,flags,state,owner:before.entities[0]!.ownership[0],option:siteRecordOption('sample'),step:request.action==='mutate'?request.step:step,evidence:request.action==='mutate'?{operation:request.operation,beforeState:request.operation.kind==='option'?request.before.options[request.operation.entityId]:request.before.entities.find(entity=>entity.id===request.operation.id)??null}:null})).toString('base64'); + const data=Buffer.from(JSON.stringify({root,flags,state,owner:before.entities[0]!.ownership[0],option:siteRecordOption(selectedBoundary.siteId),lock:SITE_APPLY_LOCK_OPTION,step:request.action==='mutate'?request.step:step,evidence:request.action==='mutate'?{operation:request.operation,beforeState:request.operation.kind==='option'?request.before.options[request.operation.entityId]:request.before.entities.find(entity=>entity.id===request.operation.id)??null}:null})).toString('base64'); let output='';let argsSeen:string[]=[]; - const response=await siteApplyWpCli({wpPath:'/wp path;$(false)',wpUser:'7'},boundary,request,async(_executable,args)=>{ + const response=await siteApplyWpCli({wpPath:'/wp path;$(false)',wpUser:'7'},selectedBoundary,request,async(_executable,args)=>{ argsSeen=args; const stub=` $d=json_decode(base64_decode('${data}'),true);$flags=$d['flags'];$writes=0; -$options=array($d['option']=>array('version'=>1,'siteId'=>'sample','entities'=>array(),'options'=>array(),'operation'=>array('token'=>'token','planHash'=>'confirmed','status'=>'started','currentStep'=>$d['step'],'stepEvidence'=>$d['evidence'])),$d['option'].'_lock'=>'token','show_on_front'=>'posts','page_on_front'=>'0'); -if(isset($flags['wrongToken']))$options[$d['option'].'_lock']='foreign';if(isset($flags['wrongStep']))$options[$d['option']]['operation']['currentStep']['entityId']='foreign'; +$options=array($d['option']=>array('version'=>1,'siteId'=>'sample','entities'=>array(),'options'=>array(),'operation'=>array('token'=>'token','planHash'=>'confirmed','status'=>'started','currentStep'=>$d['step'],'stepEvidence'=>$d['evidence'])),$d['lock']=>'token','show_on_front'=>'posts','page_on_front'=>'0'); +if(isset($flags['noLock']))unset($options[$d['lock']]);if(isset($flags['legacyLock']))$options[$d['option'].'_lock']='legacy'; +class TestWpdb {public $options='options';function esc_like($s){return addcslashes($s,'_%');}function prepare($sql,$pattern){return $sql;}function get_col($sql){global $options;return array_keys($options);}}$wpdb=new TestWpdb(); +if(isset($flags['wrongToken']))$options[$d['lock']]='foreign';if(isset($flags['wrongStep']))$options[$d['option']]['operation']['currentStep']['entityId']='foreign'; $p=(object)array('ID'=>22,'post_type'=>'page','post_content'=>$d['state']['content'],'post_title'=>$d['state']['title'],'post_status'=>$d['state']['status'],'post_name'=>'home','post_parent'=>0,'post_author'=>31,'post_excerpt'=>'Human excerpt','post_date'=>'2025-02-03 04:05:06','post_date_gmt'=>'2025-02-03 03:05:06','menu_order'=>19); if(isset($flags['humanStatus']))$p->post_status='private'; $meta=array(22=>array('_wp_page_template'=>'index','_block_runner_site'=>array($d['owner']))); -function home_url(){return 'https://example.test';}function site_url(){return home_url();}function get_current_blog_id(){return 1;}function get_current_user_id(){return 7;}function current_user_can($c){return true;}function get_stylesheet(){return 'sample';}function get_stylesheet_directory(){global $d;return $d['root'];}function wp_json_encode($v){return json_encode($v);} +function home_url(){return 'https://example.test';}function site_url(){return home_url();}function get_current_blog_id(){global $flags;return $flags['blogId']??1;}function get_current_user_id(){return 7;}function current_user_can($c){return true;}function get_stylesheet(){return 'sample';}function get_stylesheet_directory(){global $d;return $d['root'];}function wp_json_encode($v){return json_encode($v);} function get_option($k,$default=false){global $options;return $options[$k]??$default;}function add_option($k,$v,$unused='',$autoload=false){global $options;if(array_key_exists($k,$options))return false;$options[$k]=$v;return true;}function delete_option($k){global $options;unset($options[$k]);return true;}function update_option($k,$v,$autoload=false){global $options,$writes;$writes++;$options[$k]=$v;return true;} function get_post($id){global $p;return $id===22?$p:null;}function get_posts($args){global $p,$flags;return isset($flags['occupied'])?array($p):array();}function get_post_meta($id,$k,$single){global $meta;return $single?($meta[$id][$k]??''):($meta[$id][$k]??array());} function wp_slash($v){return $v;}function is_wp_error($v){return false;}function wp_insert_post($v,$errors){global $p,$writes,$flags;$writes++;$p=(object)array('ID'=>22,'post_type'=>$v['post_type'],'post_content'=>$v['post_content'],'post_title'=>$v['post_title'],'post_status'=>$v['post_status'],'post_name'=>$v['post_name'],'post_parent'=>$v['post_parent']);return 22;} @@ -41,7 +43,15 @@ function add_post_meta($id,$k,$v,$unique){global $meta,$flags;if(isset($flags['o describe('fixed PHP mutation boundary executed with bounded WordPress stubs',()=>{ it('sanitizes thrown transport stderr without attaching raw output or a cause',async()=>{let error:unknown;try{await siteApplyWpCli({wpPath:'/wp'},boundary,{action:'lock'},async()=>{throw Object.assign(new Error('bootstrap SENTINEL_PRIVATE_WP_CREDENTIAL'),{stderr:'SENTINEL_PRIVATE_WP_CREDENTIAL'});});}catch(value){error=value;}expect(error).toBeInstanceOf(Error);expect((error as Error).message).toContain('site apply transport failed');expect(String(error)).not.toContain('SENTINEL_PRIVATE_WP_CREDENTIAL');expect(JSON.stringify(error)).not.toContain('SENTINEL_PRIVATE_WP_CREDENTIAL');}); it.each(['SENTINEL_PRIVATE_WP_CREDENTIAL','null'])('sanitizes invalid boundary output %s',async(output)=>{await expect(siteApplyWpCli({wpPath:'/wp'},boundary,{action:'lock'},async()=>output)).rejects.toThrow('invalid boundary readback');}); - it('acquires an atomic lock, refuses stale locks, and releases only its token',async()=>{expect((await execute({action:'lock'})).response.status).toBe('refused');expect((await execute({action:'unlock'},{wrongToken:true})).response.status).toBe('refused');expect((await execute({action:'unlock'})).response.status).toBe('complete');}); + it('locks the blog across namespaces, refuses the same token and legacy locks, and releases only its owner',async()=>{ + expect((await execute({action:'lock'})).response.status).toBe('refused'); + expect((await execute({action:'lock'},{},{...boundary,siteId:'other',token:'other-token'})).response.status).toBe('refused'); + expect((await execute({action:'lock'},{noLock:true,legacyLock:true})).response.status).toBe('refused'); + expect((await execute({action:'lock'},{noLock:true})).response.status).toBe('complete'); + expect((await execute({action:'lock'},{noLock:true,blogId:2},{...boundary,target:{...boundary.target,blogId:2}})).response.status).toBe('complete'); + expect((await execute({action:'unlock'},{wrongToken:true})).response.status).toBe('refused'); + expect((await execute({action:'unlock'})).response.status).toBe('complete'); + }); it.each(['humanStatus','wrongToken','wrongStep'])('rechecks %s before insertion',async(flag)=>{ const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})},{[flag]:true});expect(r.response.status).toBe('refused');expect(r.evidence.writes).toBe(0); }); diff --git a/dev/test/site-apply.test.ts b/dev/test/site-apply.test.ts index a286ff9..cd48905 100644 --- a/dev/test/site-apply.test.ts +++ b/dev/test/site-apply.test.ts @@ -8,7 +8,7 @@ import { readSitePlanInput, reconcileSitePlan, managedStateHash } from '../../sr import { compileThemeDocuments, compileSitePageDocuments, compileSiteSyncedPatterns } from '../../src/site/compile.js'; import type { SiteSnapshot } from '../../src/site/plan-types.js'; import { siteApplyWpCli, type SiteApplyBoundary, type SiteApplyRequest } from '../../src/site/apply-wpcli.js'; -import { siteRecordOption } from '../../src/site/wpcli.js'; +import { siteRecordOption, SITE_APPLY_LOCK_OPTION } from '../../src/site/wpcli.js'; function phpAssociateEmptyObjects(value:unknown):unknown { if(Array.isArray(value))return value.map(phpAssociateEmptyObjects); if(value&&typeof value==='object'){const entries=Object.entries(value);return entries.length?Object.fromEntries(entries.map(([key,item])=>[key,phpAssociateEmptyObjects(item)])):[];} @@ -56,7 +56,7 @@ async function fixture(extra?: 'media'|'patterns',normalizeMarkerEmptyObjects=fa } return JSON.stringify({status:'complete'}); }}; - return {root,source,plan,options,live,transports,requests,setFault:(value:typeof fault)=>{fault=value;},setRouteMutation:(value:typeof routeMutation)=>{routeMutation=value;},count:()=>mutations,setLock:()=>{lock='stale';}}; + return {root,source,plan,options,live,transports,requests,setFault:(value:typeof fault)=>{fault=value;},setRouteMutation:(value:typeof routeMutation)=>{routeMutation=value;},count:()=>mutations,setLock:(token='stale')=>{lock=token;},releaseLock:()=>{lock=null;}}; } describe('confirmed database apply',()=>{ it('streams a large default mutation script to a short eval-file stdin invocation',async()=>{ @@ -77,7 +77,7 @@ describe('confirmed database apply',()=>{ expect(received.input.startsWith('{ @@ -106,6 +106,14 @@ describe('confirmed database apply',()=>{ }); it('refuses completed replay while another operation is unresolved',async()=>{const f=await fixture();expect((await applySite(f.plan,f.options,f.transports)).status).toBe('completed');f.live.siteRecord!.operation={token:'other',planHash:'other',status:'outcome_unknown'};await expect(applySite(f.plan,f.options,f.transports)).rejects.toThrow('outstanding');expect(f.count()).toBe(3);}); it('refuses concurrent/stale lock without steal or expiry',async()=>{const f=await fixture();f.setLock();await expect(applySite(f.plan,f.options,f.transports)).rejects.toThrow('never stolen');expect(f.count()).toBe(0);}); + it.each(['owned','noowner'] as const)('refuses a stranded same-operation lock after %s response loss, preserving evidence',async(fault)=>{ + const f=await fixture();f.setFault(fault);const interrupted=await applySite(f.plan,f.options,f.transports);expect(interrupted.status).toBe('outcome_unknown'); + f.setLock(interrupted.token);const evidence=await readFile(f.options.journal); + await expect(applySite(f.plan,f.options,f.transports)).rejects.toThrow('never stolen');expect(await readFile(f.options.journal)).toEqual(evidence);expect(f.count()).toBe(1); + // Simulate guarded operator reconciliation only after all executors have stopped. + f.releaseLock();const resumed=await applySite(f.plan,f.options,f.transports); + expect(resumed.status).toBe(fault==='owned'?'completed':'outcome_unknown');expect(f.count()).toBe(fault==='owned'?3:1);expect(f.live.entities).toHaveLength(1); + }); it('retains uncertain create and never duplicates it without exact ownership proof',async()=>{ const f=await fixture();f.setFault('unknown');const result=await applySite(f.plan,f.options,f.transports);expect(result.status).toBe('outcome_unknown');expect(result.steps[0]!.step.status).toBe('outcome_unknown');expect(f.count()).toBe(1); const replay=await applySite(f.plan,f.options,f.transports);expect(replay.status).toBe('outcome_unknown');expect(f.count()).toBe(1); diff --git a/docs/reference.md b/docs/reference.md index ce7de3a..41a7d12 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -277,6 +277,21 @@ Use the CLI in shell scripts, pre-commit hooks or CI. - run: npx block-runner validate "content/**/*.html" --strict ``` +### Interrupted site apply + +`site apply` holds one lock per WordPress blog, shared by every Block Runner site +identity. A held lock is refused even when its token matches the journal; a token +cannot prove that an earlier WP-CLI or PHP process has stopped. Older namespace +locks also block new applies. + +After an interruption, preserve the confirmed plan, journal and WordPress operation +marker. First establish that every executor and its WP-CLI/PHP descendants have +stopped. Inspect the exact lock value and native mutation outcome before removing +only that observed lock through an operator-controlled compare-and-delete. Keep +the operation marker and journal. Repeating the same confirmed operation can +continue only when their ownership and readback evidence prove the outcome; +unknown effects are never retried automatically. + ## Library The library is ESM-only and requires Node.js ^20.19.0 || ^22.13.0 || >=24.0.0. This means diff --git a/src/site/apply-wpcli.ts b/src/site/apply-wpcli.ts index 8ad3cb6..44ad729 100644 --- a/src/site/apply-wpcli.ts +++ b/src/site/apply-wpcli.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { siteRecordOption, SITE_OWNERSHIP_META, type SiteWpCliOptions, type SiteReadTransport } from './wpcli.js'; +import { siteRecordOption, SITE_OWNERSHIP_META, SITE_APPLY_LOCK_OPTION, type SiteWpCliOptions, type SiteReadTransport } from './wpcli.js'; import { managedStateHash } from './plan.js'; import type { SiteSnapshot, SiteApplyStep, SitePlanOperation } from './plan-types.js'; export interface SiteApplyBoundary { @@ -71,7 +71,7 @@ function script(boundary: SiteApplyBoundary, request: SiteApplyRequest): string : {}, }, } : request; - const encoded = Buffer.from(JSON.stringify({ ...boundary, request: payloadRequest, ...(request.action==='mutate'&&request.operation.id?{beforeStateHash:managedStateHash(request.before.entities.find(entity=>entity.id===request.operation.id)?.state ?? {})}:{}), option:siteRecordOption(boundary.siteId), lock: `${siteRecordOption(boundary.siteId)}_lock` })).toString('base64'); + const encoded = Buffer.from(JSON.stringify({ ...boundary, request: payloadRequest, ...(request.action==='mutate'&&request.operation.id?{beforeStateHash:managedStateHash(request.before.entities.find(entity=>entity.id===request.operation.id)?.state ?? {})}:{}), option:siteRecordOption(boundary.siteId), lock: SITE_APPLY_LOCK_OPTION })).toString('base64'); return ` $e=json_decode(base64_decode('${encoded}'),true); $r=$e['request']; $attempted=false; $same=function($a,$b) use (&$same) { if (is_array($a) && is_array($b)) { if(count($a)!==count($b)) return false; foreach($a as $k=>$v) if(!array_key_exists($k,$b)||!$same($v,$b[$k])) return false; return true; } return $a===$b; }; @@ -87,7 +87,15 @@ $result=(function() use($e,$r,&$attempted,$same,$state) { foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root,FilesystemIterator::SKIP_DOTS)) as $file) { if($file->isLink()) return array('status'=>'refused'); if($file->isFile()) $inventory[]=array('path'=>str_replace(DIRECTORY_SEPARATOR,'/',substr($file->getPathname(),strlen($root)+1)),'sha256'=>hash_file('sha256',$file->getPathname()),'size'=>$file->getSize()); } usort($inventory,function($a,$b){return strcmp($a['path'],$b['path']);}); if(!$same($inventory,$e['theme']['inventory'])) return array('status'=>'refused'); - if($r['action']==='lock') return array('status'=>add_option($e['lock'],$e['token'],'',false)?'complete':'refused'); + if($r['action']==='lock') { + // A previous namespace lock cannot be silently bypassed during the transition. + global $wpdb; + $pattern=$wpdb->esc_like('_block_runner_site_').'%' . $wpdb->esc_like('_lock'); + $legacyLocks=$wpdb->get_col($wpdb->prepare("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",$pattern)); + foreach($legacyLocks as $legacy) if(preg_match('/^_block_runner_site_[a-f0-9]{64}_lock$/',$legacy) && get_option($legacy,null)!==null) return array('status'=>'refused'); + // A matching token identifies an operation, not one still-running process. + return array('status'=>add_option($e['lock'],$e['token'],'',false)?'complete':'refused'); + } if(get_option($e['lock'])!==$e['token']) return array('status'=>'refused'); $record=get_option($e['option'],null); if($r['action']==='marker') { diff --git a/src/site/apply.ts b/src/site/apply.ts index 59c6f81..1eaeae3 100644 --- a/src/site/apply.ts +++ b/src/site/apply.ts @@ -80,7 +80,7 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans const marker=live.siteRecord?.operation; if(marker && marker.status!=='completed' && (!journal || marker.planHash!==confirmationHash||marker.token!==journal.token)) throw new Error('outstanding site operation blocks apply; preserve its receipt and reconcile explicitly'); if(!journal && !same(comparable(live),comparable(plan.snapshot))) throw new Error('confirmed before-state changed; prepare a fresh plan'); - if((await call({action:'lock'})).status!=='complete') throw new Error('site apply lock exists; stale locks require explicit operator reconciliation and are never stolen'); + if((await call({action:'lock'})).status!=='complete') throw new Error(`site apply lock exists for ${plan.snapshot.target.homeUrl}; preserve confirmed plan, native marker and journal ${journalPath}. Stop all executors and WP-CLI descendants before guarded operator lock reconciliation; locks are never stolen`); try { live=await read(); assertIdentity(); if(!journal && !same(comparable(live),comparable(plan.snapshot))) throw new Error('confirmed before-state changed while acquiring lock'); diff --git a/src/site/media-import.ts b/src/site/media-import.ts index 5d5ba43..ad7f3a9 100644 --- a/src/site/media-import.ts +++ b/src/site/media-import.ts @@ -6,7 +6,7 @@ import { hashCanonicalJson } from '../proof/receipt.js'; import { verifyPreparedSiteAssets } from './assets.js'; import { attachmentVersionKey } from './media.js'; import { managedStateHash, observedAttachmentBindings, type SitePlanInput } from './plan.js'; -import { readSiteSnapshot, siteRecordOption, SITE_OWNERSHIP_META, type SiteReadTransport, type SiteWpCliOptions } from './wpcli.js'; +import { readSiteSnapshot, siteRecordOption, SITE_OWNERSHIP_META, SITE_APPLY_LOCK_OPTION, type SiteReadTransport, type SiteWpCliOptions } from './wpcli.js'; import type { SitePlan, SiteSnapshot } from './plan-types.js'; import type { PreparedSiteSource, SiteAttachmentBinding } from './types.js'; @@ -209,6 +209,7 @@ function importScript(input: { assetRoot: input.assetRoot, relativePath: input.relativePath, recordOption: siteRecordOption(input.plan.siteId), + lockOption: SITE_APPLY_LOCK_OPTION, }; const encoded = Buffer.from(JSON.stringify(expected)).toString('base64'); return ` @@ -221,6 +222,7 @@ $result = (function() use ($expected, &$attempted) { if ((int)get_current_user_id() !== (int)$expected['principalId']) return array('status'=>'refused'); foreach ($expected['capabilities'] as $capability=>$allowed) if ((bool)current_user_can($capability) !== (bool)$allowed) return array('status'=>'refused'); if (get_stylesheet() !== $expected['themeSlug']) return array('status'=>'refused'); + if (get_option($expected['lockOption'], null) !== $expected['token']) return array('status'=>'refused'); $record = get_option($expected['recordOption'], null); $operation = is_array($record) ? ($record['operation'] ?? null) : null; $step = is_array($operation) ? ($operation['currentStep'] ?? null) : null; diff --git a/src/site/wpcli.ts b/src/site/wpcli.ts index 1432fba..013017f 100644 --- a/src/site/wpcli.ts +++ b/src/site/wpcli.ts @@ -4,6 +4,8 @@ import { promisify } from 'node:util'; import type { SiteSnapshot } from './plan-types.js'; export const SITE_OWNERSHIP_META = '_block_runner_site'; +/** WordPress options are blog-local; ownership namespaces share this mutation lock. */ +export const SITE_APPLY_LOCK_OPTION = '_block_runner_site_apply_lock'; export function siteRecordOption(siteId: string): string { return `_block_runner_site_${createHash('sha256').update(siteId).digest('hex')}`; } From e5ce2f4e70a966e8c91bd46b80d0ec84b809606c Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:48:22 +0200 Subject: [PATCH 07/38] fix: preserve legacy site identities --- dev/test/site-contract.test.ts | 38 ++++++++++++++++++++++++++++++++-- dev/test/site-patterns.test.ts | 4 ++++ dev/test/site-write.test.ts | 14 +++++++++++++ src/site/assets.ts | 5 +++-- src/site/compile.ts | 8 +++---- src/site/input.ts | 17 ++++++++++++++- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/dev/test/site-contract.test.ts b/dev/test/site-contract.test.ts index 8b24de3..a338277 100644 --- a/dev/test/site-contract.test.ts +++ b/dev/test/site-contract.test.ts @@ -1,9 +1,11 @@ -import { mkdtemp, readFile, realpath, rm, symlink, truncate, writeFile } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; +import { cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, truncate, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { prepareSiteSource, validateSiteIntent } from '../../src/site/input.js'; +import { prepareSiteSource, siteSourceIdentityMatches, validateSiteIntent } from '../../src/site/input.js'; +import { verifyPreparedSiteAssets } from '../../src/site/assets.js'; const fixtureRoot = fileURLToPath(new URL('./fixtures/site-intent', import.meta.url)); const intentPath = fileURLToPath(new URL('./fixtures/site-intent/site.intent.json', import.meta.url)); @@ -63,6 +65,17 @@ describe('site intent contract', () => { if (baseline.ok && reversed.ok) expect(reversed.source.sourceIdentity).toBe(baseline.source.sourceIdentity); }); + it('accepts a legacy insertion-order source identity and verifies its source bytes unchanged', async () => { + const prepared = await prepareSiteSource(await loadIntent(), fixtureRoot); + expect(prepared.ok).toBe(true); + if (!prepared.ok) return; + const legacyIdentity = createHash('sha256').update(JSON.stringify(prepared.source.sourceIntent)).digest('hex'); + const legacySource = { ...prepared.source, sourceIdentity: legacyIdentity }; + expect(siteSourceIdentityMatches(legacySource.sourceIntent, legacyIdentity)).toBe(true); + await expect(verifyPreparedSiteAssets(legacySource)).resolves.toBeUndefined(); + expect(siteSourceIdentityMatches(legacySource.sourceIntent, 'sha256-not-current')).toBe(false); + }); + it('reports malformed native tree nodes and children without throwing during graph preparation', async () => { const input = await loadIntent(); input.pages[0].body.blocks = [null, { block: 'core/group', children: { block: 'core/paragraph' } }]; @@ -205,4 +218,25 @@ describe('site intent contract', () => { await rm(root, { recursive: true, force: true }); } }); + + it('ignores unrelated changes to ancestor directory metadata while preparing stable assets', async () => { + const temporaryBase = await realpath(tmpdir()); + const root = await mkdtemp(path.join(temporaryBase, 'block-runner-site-parent-mutation-')); + try { + await cp(path.join(fixtureRoot, 'media'), path.join(root, 'media'), { recursive: true }); + await cp(path.join(fixtureRoot, 'styles'), path.join(root, 'styles'), { recursive: true }); + const input = await loadIntent(); + const mutateAncestor = async (): Promise => { + for (let index = 0; index < 60; index += 1) { + const sibling = path.join(temporaryBase, `site-asset-noise-${randomUUID()}`); + await mkdir(sibling); + await rm(sibling, { recursive: true }); + } + }; + const [prepared] = await Promise.all([prepareSiteSource(input, root), mutateAncestor()]); + expect(prepared.ok).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/dev/test/site-patterns.test.ts b/dev/test/site-patterns.test.ts index 7200bce..05b5929 100644 --- a/dev/test/site-patterns.test.ts +++ b/dev/test/site-patterns.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; @@ -248,5 +249,8 @@ describe('site synced pattern contract and compiler', () => { expect(missing).toMatchObject({ ok: false, diagnostics: [expect.objectContaining({ code: 'unknown-pattern-id' })] }); const invalidSource = await compileSiteSyncedPattern({ ...prepared.source, sourceIdentity: 'stale' }, 'card'); expect(invalidSource).toMatchObject({ ok: false, diagnostics: [expect.objectContaining({ code: 'prepared-source-identity-mismatch' })] }); + const legacyIdentity = createHash('sha256').update(JSON.stringify(prepared.source.sourceIntent)).digest('hex'); + const legacy = await compileSiteSyncedPattern({ ...prepared.source, sourceIdentity: legacyIdentity }, 'card', { attachmentBindings: { 'home-hero': { id: 73, url: 'https://example.test/hero.png' } } }); + expect(legacy).toMatchObject({ ok: true, pattern: { id: 'card', content: expect.stringContaining('"id":73') } }); }); }); diff --git a/dev/test/site-write.test.ts b/dev/test/site-write.test.ts index 1f1950b..d6a5112 100644 --- a/dev/test/site-write.test.ts +++ b/dev/test/site-write.test.ts @@ -117,6 +117,20 @@ describe('site bundle publication', () => { } }); + it('publishes a previously prepared source without rewriting its legacy source identity', async () => { + const { root, source } = await setup(); + try { + const legacyIdentity = createHash('sha256').update(JSON.stringify(source.sourceIntent)).digest('hex'); + const legacySource = { ...source, sourceIdentity: legacyIdentity }; + const output = path.join(root, 'legacy-bundle'); + await writeCompiledSite(output, legacySource, artifact(legacySource)); + const manifest = JSON.parse(await readFile(path.join(output, 'site.manifest.json'), 'utf8')) as { sourceIdentity: string }; + expect(manifest.sourceIdentity).toBe(legacyIdentity); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it('rejects an existing target without changing its contents', async () => { const { root, source } = await setup(); try { diff --git a/src/site/assets.ts b/src/site/assets.ts index 6e56308..ed5b324 100644 --- a/src/site/assets.ts +++ b/src/site/assets.ts @@ -130,10 +130,11 @@ export function buildSiteThemeAssets(source: PreparedSiteSource): SiteThemeAsset /** Re-read the declared source through the same no-symlink boundary and reject changed bytes. */ export async function verifyPreparedSiteAssets(source: PreparedSiteSource): Promise { - const { prepareSiteSource } = await import('./input.js'); + const { prepareSiteSource, siteSourceIdentityMatches } = await import('./input.js'); const refreshed = await prepareSiteSource(source.sourceIntent, source.assetRoot); if (!refreshed.ok) throw new Error(`site source assets changed or became unsafe: ${refreshed.diagnostics.map(({ path: at, message }) => `${at}: ${message}`).join('; ')}`); - if (refreshed.source.sourceIdentity !== source.sourceIdentity) throw new Error('site intent changed after source preparation'); + if (!siteSourceIdentityMatches(source.sourceIntent, source.sourceIdentity) + || !siteSourceIdentityMatches(refreshed.source.sourceIntent, source.sourceIdentity)) throw new Error('site intent changed after source preparation'); const current = new Map(refreshed.source.assets.map((asset) => [asset.id, asset])); if (current.size !== source.assets.length || source.assets.some((asset) => current.get(asset.id)?.sha256 !== asset.sha256)) { throw new Error('site source assets changed after source preparation'); diff --git a/src/site/compile.ts b/src/site/compile.ts index 9851914..eee71b4 100644 --- a/src/site/compile.ts +++ b/src/site/compile.ts @@ -11,7 +11,7 @@ import { buildSiteThemeAssets } from './assets.js'; import { bindNativeImage, declaredEmbedAspectRatio, validateNativeImageSettings } from './media.js'; import { patternOverrideContent } from '../authoring/pattern-overrides.js'; import { compileSyncedPatterns, type CompiledSyncedPattern } from './patterns.js'; -import { validateSiteIntent } from './input.js'; +import { siteSourceIdentityMatches, validateSiteIntent } from './input.js'; import type { CompiledSite, CompiledSitePage, @@ -48,6 +48,7 @@ export type CompileSiteSyncedPatternResult = /** Recompute compiler provenance from the retained source and current artifact payload. */ export function calculateSiteArtifactIdentity(source: PreparedSiteSource, artifact: CompiledSite | ThemeArtifact): string { + if (!siteSourceIdentityMatches(source.sourceIntent, source.sourceIdentity)) throw new Error('prepared source identity does not match its site intent'); const targetBindingFingerprint = hashTargetContext(artifact.targetBindings, artifact.themeAssetBaseUrl); if (artifact.targetBindingFingerprint !== targetBindingFingerprint) throw new Error('compiled artifact target bindings do not match their fingerprint'); const themeFiles = artifact.themeFiles.map((file) => { @@ -59,7 +60,7 @@ export function calculateSiteArtifactIdentity(source: PreparedSiteSource, artifa const inventory = artifact.inventory.slice().sort((left, right) => left.path.localeCompare(right.path)); if (canonicalJson(inventory) !== canonicalJson(themeFiles)) throw new Error('compiled artifact inventory does not match its theme files'); const identityInput = { - sourceIdentity: createHash('sha256').update(canonicalJson(source.sourceIntent)).digest('hex'), + sourceIdentity: source.sourceIdentity, sourceAssets: source.assets.map(({ id, path, sha256, bytes }) => { if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`prepared source asset ${id} no longer matches its SHA-256`); return { id, path, sha256 }; @@ -564,8 +565,7 @@ function validatePreparedSource(source: PreparedSiteSource): { ok: true; intent: if (!intent) { return { ok: false, diagnostics: inputDiagnostics.map((entry) => ({ status: 'invalid', reason: entry.message, source: { path: entry.path }, code: entry.code })) }; } - const expectedSourceIdentity = createHash('sha256').update(canonicalJson(intent)).digest('hex'); - if (source.sourceIdentity !== expectedSourceIdentity) { + if (!siteSourceIdentityMatches(intent, source.sourceIdentity)) { return { ok: false, diagnostics: [invalid('site', 'prepared-source-identity-mismatch', 'prepared source identity does not match its canonical site intent')] }; } if (!Array.isArray(source?.assets)) { diff --git a/src/site/input.ts b/src/site/input.ts index 26a1c9b..eb5f677 100644 --- a/src/site/input.ts +++ b/src/site/input.ts @@ -81,6 +81,18 @@ export async function prepareSiteSource(input: unknown, assetRoot: string): Prom }; } +/** Match both current canonical and previously persisted insertion-order source identities. */ +export function siteSourceIdentityMatches(sourceIntent: unknown, identity: unknown): identity is string { + if (typeof identity !== 'string') return false; + const diagnostics: SiteInputDiagnostic[] = []; + const validated = validateSiteIntent(sourceIntent, diagnostics); + if (!validated || diagnostics.length > 0) return false; + const canonicalIdentity = createHash('sha256').update(canonicalJson(validated)).digest('hex'); + if (identity === canonicalIdentity) return true; + const legacyIdentity = createHash('sha256').update(JSON.stringify(validated)).digest('hex'); + return identity === legacyIdentity; +} + /** Graph and envelope checks are intentionally local to this contract, not a general schema engine. */ export function validateSiteIntent(input: unknown, diagnostics: SiteInputDiagnostic[] = []): SiteIntent | undefined { if (!isRecord(input)) { @@ -728,7 +740,10 @@ async function inspectPathChain(root: string, relative?: string): Promise { const next = after[index]; - return next !== undefined && snapshot.path === next.path && snapshot.dev === next.dev && snapshot.ino === next.ino && snapshot.mode === next.mode && snapshot.size === next.size && snapshot.mtimeMs === next.mtimeMs && snapshot.ctimeMs === next.ctimeMs; + if (!next || snapshot.path !== next.path || snapshot.dev !== next.dev || snapshot.ino !== next.ino || snapshot.mode !== next.mode) return false; + // Directory contents can change because unrelated files are created beside the asset. + // The final file still requires full metadata stability; its descriptor read is checked too. + return index !== before.length - 1 || (snapshot.size === next.size && snapshot.mtimeMs === next.mtimeMs && snapshot.ctimeMs === next.ctimeMs); }); } From a74b4eeef249d3e95dad23b24a1ef4d98fc3314d Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:48:36 +0200 Subject: [PATCH 08/38] perf(site): reuse verified planning input and skip retained-item reads --- dev/test/site-apply.test.ts | 22 +++++++++++++++++++++- dev/test/site-plan.test.ts | 3 +++ src/site/apply.ts | 24 ++++++++++++++++-------- src/site/plan.ts | 11 +++++++++-- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/dev/test/site-apply.test.ts b/dev/test/site-apply.test.ts index cd48905..1c3cbec 100644 --- a/dev/test/site-apply.test.ts +++ b/dev/test/site-apply.test.ts @@ -2,9 +2,10 @@ import { mkdtemp, realpath, writeFile, readFile, copyFile, chmod } from 'node:fs import path from 'node:path'; import os from 'node:os'; import { hashCanonicalJson } from '../../src/proof/receipt.js'; +import { hashBytes } from '../../src/publication.js'; import { describe, it, expect } from 'vitest'; import { applySite, type SiteApplyOptions } from '../../src/site/apply.js'; -import { readSitePlanInput, reconcileSitePlan, managedStateHash } from '../../src/site/plan.js'; +import { readSitePlanInput, reconcileSitePlan, planPreparedSite, managedStateHash } from '../../src/site/plan.js'; import { compileThemeDocuments, compileSitePageDocuments, compileSiteSyncedPatterns } from '../../src/site/compile.js'; import type { SiteSnapshot } from '../../src/site/plan-types.js'; import { siteApplyWpCli, type SiteApplyBoundary, type SiteApplyRequest } from '../../src/site/apply-wpcli.js'; @@ -59,6 +60,25 @@ async function fixture(extra?: 'media'|'patterns',normalizeMarkerEmptyObjects=fa return {root,source,plan,options,live,transports,requests,setFault:(value:typeof fault)=>{fault=value;},setRouteMutation:(value:typeof routeMutation)=>{routeMutation=value;},count:()=>mutations,setLock:(token='stale')=>{lock=token;},releaseLock:()=>{lock=null;}}; } describe('confirmed database apply',()=>{ + it('keeps previously confirmed insertion-order source identities exact during semantic reconstruction',async()=>{ + const f=await fixture();const input=await readSitePlanInput(f.source,f.root); + input.source.sourceIdentity=hashBytes(Buffer.from(JSON.stringify(input.source.sourceIntent))); + const plan=await planPreparedSite(input,structuredClone(f.live));const confirmed=plan.confirmationHash; + const result=await applySite(plan,{...f.options,confirm:confirmed},f.transports); + expect(result.status).toBe('completed');expect(result.planHash).toBe(confirmed);expect(plan.sourceIdentity).toBe(input.source.sourceIdentity);expect(f.count()).toBe(3); + }); + it.each(['none','live','source'] as const)('checks retained items together and refuses late %s changes',async(change)=>{ + const f=await fixture();expect((await applySite(f.plan,f.options,f.transports)).status).toBe('completed'); + const input=await readSitePlanInput(f.source,f.root);const plan=await planPreparedSite(input,structuredClone(f.live)); + expect(plan.operations.every(op=>op.action==='retain')).toBe(true); + let reads=0;const originalRead=f.transports.read; + const result=await applySite(plan,{...f.options,confirm:plan.confirmationHash,journal:path.join(f.root,'retained.json')},{...f.transports,read:async()=>{ + reads++;if(reads===4){if(change==='live')f.live.navigation.push({id:99,content:'human edit',status:'publish'});if(change==='source'){const source=JSON.parse(await readFile(f.source,'utf8'));source.pages[0].title='Human edit';await writeFile(f.source,JSON.stringify(source));}} + return originalRead(); + }}); + expect(result.status).toBe(change==='none'?'completed':'partial');expect(reads).toBe(change==='none'?5:4);expect(f.count()).toBe(3); + if(change!=='none')expect(result.error).toContain(change==='live'?'live state changed before completion':'source bytes changed before completion'); + }); it('streams a large default mutation script to a short eval-file stdin invocation',async()=>{ const root=await mkdtemp(path.join(await realpath(os.tmpdir()),'site-apply-stdin-')); const capture=path.join(root,'capture.json'); diff --git a/dev/test/site-plan.test.ts b/dev/test/site-plan.test.ts index de0f565..1234394 100644 --- a/dev/test/site-plan.test.ts +++ b/dev/test/site-plan.test.ts @@ -112,6 +112,9 @@ describe('source and retained bundle boundaries',()=>{ const prepared=await prepareSiteSource(input,assetRoot);if(!prepared.ok)throw new Error('fixture invalid'); const manifest={formatVersion:1,siteId:input.siteId,sourceIdentity:prepared.source.sourceIdentity,sourceAssets:[],targetBindings:{},bundleFiles:[{path:'site.intent.json',sha256:hashBytes(bytes),size:bytes.length}]};await writeFile(path.join(root,'site.manifest.json'),JSON.stringify(manifest)); expect((await readSitePlanInput(root)).source.sourceIdentity).toBe(prepared.source.sourceIdentity); + manifest.sourceIdentity=hashBytes(Buffer.from(JSON.stringify(prepared.source.sourceIntent))); + await writeFile(path.join(root,'site.manifest.json'),JSON.stringify(manifest)); + expect((await readSitePlanInput(root)).source.sourceIdentity).toBe(manifest.sourceIdentity); await writeFile(path.join(root,'site.intent.json'),Buffer.from('corrupt'));await expect(readSitePlanInput(root)).rejects.toThrow('retained bundle bytes differ'); const linked=path.join(root,'linked');await symlink(root,linked);await expect(readSitePlanInput(linked)).rejects.toThrow('symlinks'); }); diff --git a/src/site/apply.ts b/src/site/apply.ts index 1eaeae3..a10bb07 100644 --- a/src/site/apply.ts +++ b/src/site/apply.ts @@ -3,10 +3,11 @@ import { lstat, open, rename } from 'node:fs/promises'; import path from 'node:path'; import { readStableRegularFile } from '../publication.js'; import { hashCanonicalJson } from '../proof/receipt.js'; -import { compileSite, compileSiteSyncedPatterns, compileThemeDocuments } from './compile.js'; -import { readSitePlanInput, planSite, observedAttachmentBindings, managedStateHash } from './plan.js'; +import { compileSite, compileSiteSyncedPattern, compileThemeDocuments } from './compile.js'; +import { readSitePlanInput, planPreparedSite, observedAttachmentBindings, managedStateHash } from './plan.js'; import { importPlannedSiteAttachment, SiteMediaImportOutcomeUnknown } from './media-import.js'; import { attachmentVersionKey } from './media.js'; +import { siteSourceIdentityMatches } from './input.js'; import { syncedPatternOrder } from './patterns.js'; import { readSiteSnapshot, type SiteWpCliOptions, type SiteReadTransport } from './wpcli.js'; import { siteApplyWpCli, type SiteApplyBoundary, type SiteApplyRequest } from './apply-wpcli.js'; @@ -73,9 +74,10 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans } if(!plan.executable||plan.conflicts.length) throw new Error('plan is not executable'); const input=await readSitePlanInput(options.source,options.assetRoot); - if(input.source.sourceIdentity!==plan.sourceIdentity||input.source.sourceIntent.siteId!==plan.siteId||!same(input.source.assets.map(({id,path,sha256})=>({id,path,sha256})),plan.assets)) throw new Error('source or asset bytes changed after confirmation'); + if(!siteSourceIdentityMatches(input.source.sourceIntent,plan.sourceIdentity)||input.source.sourceIntent.siteId!==plan.siteId||!same(input.source.assets.map(({id,path,sha256})=>({id,path,sha256})),plan.assets)) throw new Error('source or asset bytes changed after confirmation'); + input.source.sourceIdentity=plan.sourceIdentity; // Reconstruct the semantic plan from retained source and its confirmed baseline, never trusting edited operation claims. - const semantic=await planSite(options.source,options,async()=>JSON.stringify(plan.snapshot)); + const semantic=await planPreparedSite(input,plan.snapshot,options); if(semantic.confirmationHash!==plan.confirmationHash)throw new Error('confirmed plan operations do not match source and baseline reconciliation'); const marker=live.siteRecord?.operation; if(marker && marker.status!=='completed' && (!journal || marker.planHash!==confirmationHash||marker.token!==journal.token)) throw new Error('outstanding site operation blocks apply; preserve its receipt and reconcile explicitly'); @@ -123,10 +125,11 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans const perform=async(op:SitePlanOperation):Promise=>{ const key=op.kind==='attachment'?attachmentVersionKey(op.entityId,String(op.desired?.sha256)):`${op.kind}:${op.entityId}`; const done=journal!.steps.find(item=>item.step.key===key&&item.step.status==='completed'); if(done){prove(done,live);return;} + // Retained operations have no effects. Initial and final full observations bind them. + if(op.action==='retain') return; live=await read(); assertIdentity(); if(!same(comparable(live),comparable(journal!.observed))) throw new Error('live state changed immediately before mutation'); const refreshed=await readSitePlanInput(options.source,options.assetRoot); - if(refreshed.source.sourceIdentity!==plan.sourceIdentity||!same(refreshed.source.assets.map(({id,path,sha256})=>({id,path,sha256})),plan.assets)) throw new Error('source bytes changed immediately before mutation'); - if(op.action==='retain') return; + if(!siteSourceIdentityMatches(refreshed.source.sourceIntent,plan.sourceIdentity)||!same(refreshed.source.assets.map(({id,path,sha256})=>({id,path,sha256})),plan.assets)) throw new Error('source bytes changed immediately before mutation'); if(op.action==='update'){const entity=live.entities.find(entity=>entity.id===op.id);const owner=entity?.ownership.length===1?entity.ownership[0]:undefined;if(!entity||!owner||owner.version!==1||owner.siteId!==plan.siteId||owner.entityId!==op.entityId||owner.kind!==op.kind||owner.lastAppliedHash!==managedStateHash(entity.state))throw new Error('update requires exact existing ownership and full managed baseline');} const step:SiteApplyStep={key,kind:op.kind,entityId:op.entityId,...(op.kind==='attachment'?{assetSha256:String(op.desired?.sha256)}:{}),status:'started'}; const record={step,operation:op,beforeState:op.kind==='option'?live.options[op.entityId]:live.entities.find(entity=>entity.id===op.id)??null} as SiteApplyReceipt['steps'][number]; journal!.steps.push(record);journal!.currentStep=step; journal!.status='started';await save();await mark('started'); @@ -161,7 +164,8 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans attachments=observedAttachmentBindings(input,live); for(const id of syncedPatternOrder(input.source.sourceIntent)) { const op=plan.operations.find(op=>op.kind==='pattern'&&op.entityId===id);if(!op)throw new Error('missing planned pattern'); - const compiled=(await compileSiteSyncedPatterns(input.source,compileOptions())).find(pattern=>pattern.id===id); + const result=await compileSiteSyncedPattern(input.source,id,compileOptions()); + const compiled=result.ok?result.pattern:undefined; if(!compiled?.ok||compiled.content===undefined||compiled.pendingUses.length||compiled.pendingBindings.length) throw new Error('pattern dependency finalization failed'); const desired={title:compiled.title,content:compiled.content,status:'publish'}; const existing=op.id?live.entities.find(entity=>entity.id===op.id):undefined; @@ -185,7 +189,11 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans for(const op of plan.operations.filter(op=>op.kind==='option')) { let value=op.desired?.value;if(value&&typeof value==='object'&&'pendingPageId' in value)value=ids.get(String(value.pendingPageId));if(value===undefined)throw new Error('option page binding absent');await perform({...op,desired:{value}}); } - live=await read();assertIdentity();for(const record of journal.steps)prove(record,live); + live=await read();assertIdentity(); + if(!same(comparable(live),comparable(journal.observed)))throw new Error('live state changed before completion; prepare a fresh plan'); + const finalSource=await readSitePlanInput(options.source,options.assetRoot); + if(!siteSourceIdentityMatches(finalSource.source.sourceIntent,plan.sourceIdentity)||!same(finalSource.source.assets.map(({id,path,sha256})=>({id,path,sha256})),plan.assets))throw new Error('source bytes changed before completion'); + for(const record of journal.steps)prove(record,live); for(const page of compiled.site.pages){const entity=live.entities.find(entity=>entity.id===ids.get(page.id));if(!entity||entity.route!==page.route)throw new Error('actual page route differs from confirmed route');} delete journal.currentStep;await save();await mark('completed');journal.status='completed';journal.observed=live;await save();return journal; } catch(error) { diff --git a/src/site/plan.ts b/src/site/plan.ts index 9344d7d..eed21af 100644 --- a/src/site/plan.ts +++ b/src/site/plan.ts @@ -3,7 +3,7 @@ import { readSiteSnapshot, type SiteWpCliOptions, type SiteReadTransport } from import { lstat } from 'node:fs/promises'; import path from 'node:path'; import { hashBytes, readStableRegularFile } from '../publication.js'; -import { prepareSiteSource } from './input.js'; +import { prepareSiteSource, siteSourceIdentityMatches } from './input.js'; import { hashCanonicalJson } from '../proof/receipt.js'; import type { PreparedSiteSource, CompiledSitePage, SiteInputDiagnostic, ThemeArtifact, SiteAttachmentBinding } from './types.js'; import type { SiteEntity, SitePlan, SitePlanOperation, SiteSnapshot } from './plan-types.js'; @@ -236,7 +236,9 @@ export async function readSitePlanInput(inputPath: string, assetRoot?: string): const prepared = await prepareSiteSource(retainedIntent, retainedAssetRoot); if (!prepared.ok) throw new Error(prepared.diagnostics.map(item=>`${item.path}: ${item.message}`).join('\n')); const source = prepared.source; - if (source.sourceIntent.siteId !== manifest.siteId || source.sourceIdentity !== manifest.sourceIdentity) throw new Error('retained source identity differs from bundle manifest'); + if (source.sourceIntent.siteId !== manifest.siteId || !siteSourceIdentityMatches(source.sourceIntent, manifest.sourceIdentity)) throw new Error('retained source identity differs from bundle manifest'); + // Keep persisted identity exact: accepting old insertion-order hashes must not rewrite confirmations. + source.sourceIdentity = manifest.sourceIdentity; const assets = source.assets.map(asset=>({id:asset.id,path:asset.path,kind:asset.kind,role:asset.role,sha256:asset.sha256,size:asset.bytes.byteLength})); if (hashCanonicalJson(assets) !== hashCanonicalJson(manifest.sourceAssets)) throw new Error('retained source asset inventory differs from bundle manifest'); if (source.assets.some(asset=>!paths.has(`source/assets/${asset.path}`))) throw new Error('retained manifest omits declared source asset bytes'); @@ -286,6 +288,11 @@ export interface SitePlanOptions extends SiteWpCliOptions { assetRoot?: string; export async function planSite(inputPath: string, options: SitePlanOptions, transport?: SiteReadTransport): Promise { const input = await readSitePlanInput(inputPath, options.assetRoot); const observed = await readSiteSnapshot(options, input.source.sourceIntent.siteId, transport, input.source.sourceIntent.pages.map(page=>page.route)); + return planPreparedSite(input, observed, options); +} + +/** Reconstruct semantics from already verified source and an explicit snapshot, without filesystem or WordPress reads. */ +export async function planPreparedSite(input: SitePlanInput, observed: SiteSnapshot, options: Pick = {}): Promise { if (input.sourceIntentState === 'unreconciled') { const body = { version: 1 as const, From 714b9fc4d9d6b3505fdaed0b115d2d6c135ab9e9 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:51:02 +0200 Subject: [PATCH 09/38] test: refresh site media fixtures --- dev/test/site-media.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/dev/test/site-media.test.ts b/dev/test/site-media.test.ts index 0516144..24eeba1 100644 --- a/dev/test/site-media.test.ts +++ b/dev/test/site-media.test.ts @@ -4,7 +4,8 @@ import { fileURLToPath } from 'node:url'; import { compileSitePageDocuments } from '../../src/site/compile.js'; import { attachmentVersionKey, bindNativeImage, declaredEmbedAspectRatio } from '../../src/site/media.js'; import { managedStateHash, reconcileSitePlan } from '../../src/site/plan.js'; -import { prepareSiteSource } from '../../src/site/input.js'; +import { prepareSiteSource, validateSiteIntent } from '../../src/site/input.js'; +import { canonicalJson } from '../../src/proof/receipt.js'; import type { SiteEntity, SiteSnapshot } from '../../src/site/plan-types.js'; import type { PreparedSiteSource, SiteAttachmentBinding, ThemeArtifact } from '../../src/site/types.js'; import type { IntentNode } from '../../src/types.js'; @@ -32,6 +33,12 @@ function source(sha256: string): PreparedSiteSource { assets: [{ id: 'hero', path: 'hero.jpg', kind: 'image' as const, role: 'attachment' as const }], assetUses: [] }; return { sourceIntent, assetRoot: '/assets', assets: [{ ...sourceIntent.assets[0]!, sha256, bytes: new Uint8Array([1]) }], sourceIdentity: 'source', unresolvedAssetUses: [] }; } +function refreshSource(input: PreparedSiteSource): PreparedSiteSource { + const diagnostics: Array<{ path: string; code: string; message: string }> = []; + const sourceIntent = validateSiteIntent(input.sourceIntent, diagnostics); + if (!sourceIntent) throw new Error(JSON.stringify(diagnostics)); + return { ...input, sourceIntent, sourceIdentity: createHash('sha256').update(canonicalJson(sourceIntent)).digest('hex') }; +} function theme(input: PreparedSiteSource): ThemeArtifact { return { sourceIntent: input.sourceIntent, sourceAssets: input.assets, targetBindings: {}, targetBindingFingerprint: 'bindings', themeFiles: [], syncedPatterns: [], pendingPageBindings: [], diagnostics: [], inventory: [], artifactIdentity: 'theme' }; } @@ -43,7 +50,9 @@ describe('owned attachment versions', () => { const unrelated = attachment(22, nextHash, 'other'); unrelated.ownership = []; const input = source(oldHash); - const result = reconcileSitePlan(input, theme(input), compiledPages, snapshot([exact, unrelated])); + const observed = snapshot([exact, unrelated]); + observed.siteRecord!.entities[attachmentVersionKey('hero', oldHash)] = exact.id; + const result = reconcileSitePlan(input, theme(input), compiledPages, observed); expect(result.executable).toBe(true); expect(result.operations.find(operation => operation.kind === 'attachment')).toMatchObject({ action: 'retain', id: 21, entityId: 'hero' }); }); @@ -138,7 +147,7 @@ describe('native media metadata before Gutenberg finalization', () => { input.sourceIntent.pages[0]!.body.blocks = [{ block: 'core/image', url: 'source/hero.jpg', attrs: { id: 0, ...attrs } }]; input.sourceIntent.assetUses = [{ documentId: 'page:home', nodePath: [0], attribute: 'attrs.id', assetId: 'hero' }]; - const result = await compileSitePageDocuments(input); + const result = await compileSitePageDocuments(refreshSource(input)); expect(result.ok).toBe(false); expect(result.diagnostics).toContainEqual(expect.objectContaining({ status: 'invalid', code: 'attachment-image-metadata', reason: expect.stringContaining(reason), @@ -159,7 +168,7 @@ describe('native media metadata before Gutenberg finalization', () => { input.sourceIntent.pages[0]!.body.blocks = [{ block: 'core/image', url: 'source/hero.jpg', attrs: { id: 0, ...attrs } }]; input.sourceIntent.assetUses = [{ documentId: 'page:home', nodePath: [0], attribute: 'attrs.id', assetId: 'hero' }]; - const result = await compileSitePageDocuments(input); + const result = await compileSitePageDocuments(refreshSource(input)); expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); expect(result.pendingPages).toHaveLength(1); }); From 961cb72ccd83c66bb762a3c6c1523115b03a7ec0 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:51:57 +0200 Subject: [PATCH 10/38] fix(site): require reciprocal ownership and site records --- dev/test/site-media-import.test.ts | 5 ++++- dev/test/site-patterns.test.ts | 1 + dev/test/site-plan.test.ts | 8 +++++++- src/site/drift.ts | 12 ++++++++---- src/site/media.ts | 8 ++------ src/site/ownership.ts | 15 +++++++++++++++ src/site/plan.ts | 4 ++++ 7 files changed, 41 insertions(+), 12 deletions(-) create mode 100644 src/site/ownership.ts diff --git a/dev/test/site-media-import.test.ts b/dev/test/site-media-import.test.ts index a72ac6b..9c3186d 100644 --- a/dev/test/site-media-import.test.ts +++ b/dev/test/site-media-import.test.ts @@ -68,7 +68,10 @@ async function fixture(retainOld = false) { const compiledPage = { id: 'home', route: '/', title: 'Home', template: 'index', status: 'draft' as const, content: '

Home

', diagnostics: [] }; const observed = snapshot(); - if (retainOld) observed.entities.push(previousVersion()); + if (retainOld) { + observed.entities.push(previousVersion()); + observed.siteRecord!.entities[`attachment:hero@${'a'.repeat(64)}`]=8; + } const plan = reconcileSitePlan(source, theme, [compiledPage], observed); if (!plan.executable) throw new Error(JSON.stringify(plan.conflicts)); const token = 'started-token'; diff --git a/dev/test/site-patterns.test.ts b/dev/test/site-patterns.test.ts index 05b5929..be3180b 100644 --- a/dev/test/site-patterns.test.ts +++ b/dev/test/site-patterns.test.ts @@ -136,6 +136,7 @@ describe('site synced pattern contract and compiler', () => { const state = { title: 'Card', content: compiled[0]!.content!, status: 'publish' }; const entity: SiteEntity = { id: 642, kind: 'pattern', route: null, state, ownership: [{ version: 1, siteId: intent.siteId, entityId: 'card', kind: 'pattern', lastAppliedHash: managedStateHash(state) }] }; const snapshot: SiteSnapshot = { target: { homeUrl: 'https://example.test', siteUrl: 'https://example.test/wp', blogId: 1 }, runtime: { wordpress: '7.1.0', php: '8.4.0' }, principal: { userId: 1, capabilities: { edit_pages: true, edit_others_pages: true, edit_posts: true, edit_others_posts: true, publish_posts: true, publish_blocks: true, edit_published_posts: true, manage_options: true, edit_theme_options: true, upload_files: true } }, theme: { slug: intent.theme.slug, assetBaseUrl: 'https://example.test/theme', inventory: [] }, routing: { permalinkStructure: '/%postname%/', rewriteRulesHash: 'rules', routes: [] }, entities: [entity], overrides: [], navigation: [], options: { show_on_front: 'page', page_on_front: 0 }, siteRecord: null }; + snapshot.siteRecord={version:1,siteId:intent.siteId,entities:{'pattern:card':642},options:{},operation:null}; const plan = reconcileSitePlan(source, theme, [], snapshot, {}, [], compiled, { card: 642 }); expect(plan.executable).toBe(true); expect(plan.operations.find((operation) => operation.kind === 'pattern')).toMatchObject({ action: 'retain', id: 642, entityId: 'card' }); diff --git a/dev/test/site-plan.test.ts b/dev/test/site-plan.test.ts index 1234394..0d56043 100644 --- a/dev/test/site-plan.test.ts +++ b/dev/test/site-plan.test.ts @@ -59,9 +59,15 @@ describe('bounded site reconciliation',()=>{ expect(plan(observed).conflicts.some(item=>item.code==='managed-state-changed')).toBe(true); }); it('preserves ownership across desired title and route changes',()=>{ - const observed=snapshot();observed.entities=[owned()];const input=source();input.sourceIntent.pages[0]!.title='New title'; + const observed=snapshot();observed.entities=[owned()];observed.siteRecord={version:1,siteId:'sample',entities:{'page:home':14},options:{},operation:null};const input=source();input.sourceIntent.pages[0]!.title='New title'; const result=plan(observed,input);expect(result.executable).toBe(true);expect(result.operations.find(op=>op.kind==='page')).toMatchObject({action:'update',id:14}); }); + it('refuses ownership metadata without the matching site index instead of retaining or updating it',()=>{ + const observed=snapshot();observed.entities=[owned()]; + expect(plan(observed)).toMatchObject({executable:false,conflicts:expect.arrayContaining([expect.objectContaining({code:'ownership-record-missing'})])}); + observed.siteRecord={version:1,siteId:'sample',entities:{'page:home':14},options:{},operation:null};expect(plan(observed).executable).toBe(true); + observed.siteRecord.entities['page:home']=15;expect(plan(observed).executable).toBe(false); + }); it('blocks duplicate keys, changed theme/runtime, insufficient capability and outstanding unknown operations',()=>{ const observed=snapshot();const duplicate=owned();duplicate.id=15;observed.entities=[owned(),duplicate];observed.theme.slug='wrong';observed.runtime.wordpress='7.0';observed.principal.capabilities.manage_options=false;observed.siteRecord={version:1,siteId:'sample',entities:{},options:{},operation:{token:'x',planHash:'x',status:'outcome_unknown'}}; expect(plan(observed).conflicts.map(item=>item.code)).toEqual(expect.arrayContaining(['duplicate-ownership','theme-mismatch','runtime-mismatch','missing-capability','outstanding-operation'])); diff --git a/src/site/drift.ts b/src/site/drift.ts index bd70d9d..67d54e7 100644 --- a/src/site/drift.ts +++ b/src/site/drift.ts @@ -5,6 +5,7 @@ import { hashBytes, readStableRegularFile } from '../publication.js'; import type { SiteEntity, SiteSnapshot } from './plan-types.js'; import { managedStateHash, readSitePlanInput } from './plan.js'; import { verifiedOwnedAttachment } from './media.js'; +import { siteRecordIndexesEntity } from './ownership.js'; import { readSiteSnapshot, readSiteTemplateSnapshot, type SiteReadTransport, type SiteTemplateSnapshot, type SiteWpCliOptions } from './wpcli.js'; export type SiteDriftFindingStatus = 'clean' | 'equivalent-override' | 'changed' | 'missing' | 'orphaned' | 'conflict' | 'dependency' | 'blocked'; @@ -217,8 +218,7 @@ function inspectManagedEntities(snapshot: SiteSnapshot, siteId: string, desiredP const key = `attachment:${owner.entityId}@${originalSha}`; const matches = attachmentsByKey.get(key) ?? []; matches.push(entity); attachmentsByKey.set(key, matches); - const indexed = record?.entities[key] === entity.id - || (owner.assetSha256 === undefined && record?.entities[`attachment:${owner.entityId}`] === entity.id); + const indexed = siteRecordIndexesEntity(snapshot, siteId, entity); if (!baselineMatches || entity.state.sha256 !== originalSha) { unavailableAttachmentIds.add(entity.id); add({ kind: 'attachment', status: 'changed', entityId: key, wpId: entity.id, code: 'managed-attachment-changed', message: 'The owned attachment original-byte state differs from its complete last-applied baseline.' }); @@ -228,6 +228,8 @@ function inspectManagedEntities(snapshot: SiteSnapshot, siteId: string, desiredP } else if (!entity.attachment?.currentFileSha256) { unavailableAttachmentIds.add(entity.id); add({ kind: 'attachment', status: 'missing', entityId: key, wpId: entity.id, code: 'owned-attachment-file-missing', message: 'The owned attachment record exists but its current file is unavailable.' }); + } else if (entity.ownership.length !== 1) { + add({ kind: 'attachment', status: 'conflict', entityId: key, wpId: entity.id, code: 'ambiguous-attachment-ownership', message: 'The attachment has more than one ownership claim.' }); } else if (!indexed) { add({ kind: 'attachment', status: 'conflict', entityId: key, wpId: entity.id, code: 'ownership-record-missing', message: 'Owned attachment metadata has no matching version-qualified site-record ID.' }); } else { @@ -271,8 +273,10 @@ function inspectManagedEntities(snapshot: SiteSnapshot, siteId: string, desiredP if (!desiredPages.includes(key) && !desiredPatterns.includes(key)) add({ kind: key.startsWith('pattern:') ? 'pattern' : 'page', status: 'orphaned', entityId: key, wpId: id, code: 'orphaned-owned-entity', message: 'A previously managed entity is not declared by the current source bundle.' }); } for (const [key, entities] of byKey) { - if (!Object.hasOwn(previous, key)) add({ kind: key.startsWith('pattern:') ? 'pattern' : 'page', status: 'conflict', entityId: key, wpId: entities[0]?.id, code: 'ownership-record-missing', message: 'Ownership metadata exists without a matching site record entity baseline.' }); - else if (entities[0]?.id !== previous[key]) add({ kind: 'inspection', status: 'conflict', entityId: key, wpId: entities[0]?.id, code: 'ownership-id-conflict', message: 'Ownership metadata ID differs from the numeric ID in the site record.' }); + if (!siteRecordIndexesEntity(snapshot, siteId, entities[0]!)) { + if (!Object.hasOwn(previous, key)) add({ kind: key.startsWith('pattern:') ? 'pattern' : 'page', status: 'conflict', entityId: key, wpId: entities[0]?.id, code: 'ownership-record-missing', message: 'Ownership metadata exists without a matching site record entity baseline.' }); + else add({ kind: 'inspection', status: 'conflict', entityId: key, wpId: entities[0]?.id, code: 'ownership-id-conflict', message: 'Ownership metadata ID differs from the numeric ID in the site record.' }); + } } for (const [key, entities] of attachmentsByKey) { if (entities.length > 1) add({ kind: 'attachment', status: 'conflict', entityId: key, code: 'duplicate-logical-owner', message: 'Multiple attachment records claim the same exact site, logical ID, and original-byte version.' }); diff --git a/src/site/media.ts b/src/site/media.ts index 6d3477c..c6a8041 100644 --- a/src/site/media.ts +++ b/src/site/media.ts @@ -2,6 +2,7 @@ import type { IntentNode } from '../types.js'; import { hashCanonicalJson } from '../proof/receipt.js'; import type { SiteSnapshot } from './plan-types.js'; import type { SiteAttachmentBinding } from './types.js'; +import { siteRecordIndexesEntity } from './ownership.js'; export interface ImageBindingResult { url: string; @@ -43,12 +44,7 @@ export function verifiedOwnedAttachment( || (claim.assetSha256 === undefined && claim.lastAppliedHash === hashCanonicalJson(candidate.state) && candidate.state.sha256 === originalSha256))) .map(claim => ({ candidate, claim }))); if (versionClaims.length !== 1 || versionClaims[0]!.candidate.id !== wpId) return false; - const record = snapshot.siteRecord; - if (!record || record.version !== 1 || record.siteId !== siteId) return false; - const versionId = record.entities[attachmentVersionKey(entityId, originalSha256)]; - const legacyId = owner.assetSha256 === undefined ? record.entities[`attachment:${entityId}`] : undefined; - if (versionId !== undefined && legacyId !== undefined && versionId !== legacyId) return false; - return versionId === wpId || legacyId === wpId; + return siteRecordIndexesEntity(snapshot, siteId, entity); } /** Validate authored core/image settings that do not depend on destination metadata. */ diff --git a/src/site/ownership.ts b/src/site/ownership.ts new file mode 100644 index 0000000..79799ee --- /dev/null +++ b/src/site/ownership.ts @@ -0,0 +1,15 @@ +import type { SiteEntity, SiteSnapshot } from './plan-types.js'; + +/** Index agreement only; callers must also prove unique ownership and the managed baseline. */ +export function siteRecordIndexesEntity(snapshot: SiteSnapshot, siteId: string, entity: SiteEntity): boolean { + const record = snapshot.siteRecord; + const owner = entity.ownership.length === 1 ? entity.ownership[0] : undefined; + if (!record || record.version !== 1 || record.siteId !== siteId || !owner || owner.siteId !== siteId || owner.kind !== entity.kind) return false; + if (entity.kind !== 'attachment') return record.entities[`${owner.kind}:${owner.entityId}`] === entity.id; + const sha256 = owner.assetSha256 ?? entity.state.sha256; + if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(sha256)) return false; + const versionId = record.entities[`attachment:${owner.entityId}@${sha256}`]; + const legacyId = owner.assetSha256 === undefined ? record.entities[`attachment:${owner.entityId}`] : undefined; + if (versionId !== undefined && legacyId !== undefined && versionId !== legacyId) return false; + return versionId === entity.id || legacyId === entity.id; +} diff --git a/src/site/plan.ts b/src/site/plan.ts index eed21af..f725c32 100644 --- a/src/site/plan.ts +++ b/src/site/plan.ts @@ -8,6 +8,7 @@ import { hashCanonicalJson } from '../proof/receipt.js'; import type { PreparedSiteSource, CompiledSitePage, SiteInputDiagnostic, ThemeArtifact, SiteAttachmentBinding } from './types.js'; import type { SiteEntity, SitePlan, SitePlanOperation, SiteSnapshot } from './plan-types.js'; import { attachmentVersionKey } from './media.js'; +import { siteRecordIndexesEntity } from './ownership.js'; import type { CompiledSyncedPattern } from './patterns.js'; export function managedStateHash(state: Record): string { return hashCanonicalJson(state); } @@ -90,6 +91,9 @@ export function reconcileSitePlan(source: PreparedSiteSource, theme: ThemeArtifa } } + for (const [key, entity] of owned) { + if (!siteRecordIndexesEntity(snapshot, intent.siteId, entity)) issue(`/target/entities/${entity.id}`, 'ownership-record-missing', `owned entity ${key} has no matching site-record ID`); + } for (const [key, id] of Object.entries(snapshot.siteRecord?.entities ?? {})) { const legacyMatch = /^attachment:([^@]+)$/.exec(key); const entity = owned.get(key) ?? (legacyMatch ? snapshot.entities.find(item => item.kind === 'attachment' && item.id === id From fb201475073aed3795179dc2c41112524386a0ad Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:51:58 +0200 Subject: [PATCH 11/38] perf(site): reuse fresh marker and media readback observations --- dev/test/site-apply.test.ts | 10 ++++++++++ src/site/apply.ts | 9 +++++---- src/site/media-import.ts | 18 ++++++++++++++---- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/dev/test/site-apply.test.ts b/dev/test/site-apply.test.ts index 1c3cbec..df91a82 100644 --- a/dev/test/site-apply.test.ts +++ b/dev/test/site-apply.test.ts @@ -60,6 +60,16 @@ async function fixture(extra?: 'media'|'patterns',normalizeMarkerEmptyObjects=fa return {root,source,plan,options,live,transports,requests,setFault:(value:typeof fault)=>{fault=value;},setRouteMutation:(value:typeof routeMutation)=>{routeMutation=value;},count:()=>mutations,setLock:(token='stale')=>{lock=token;},releaseLock:()=>{lock=null;}}; } describe('confirmed database apply',()=>{ + it('uses started-marker readback as the fresh full conflict check before any native effect',async()=>{ + const f=await fixture();const originalMutate=f.transports.mutate; + const result=await applySite(f.plan,f.options,{...f.transports,mutate:async(executable,args)=>{ + const encoded=args.at(-1)!.match(/base64_decode\('([^']+)'\)/)![1]!;const {request}=JSON.parse(Buffer.from(encoded,'base64').toString()); + const response=await originalMutate(executable,args); + if(request.action==='marker'&&request.operation.currentStep?.status==='started')f.live.navigation.push({id:99,content:'Human edit',status:'publish'}); + return response; + }}); + expect(result.status).toBe('partial');expect(result.error).toContain('live state changed during marker persistence');expect(f.count()).toBe(0); + }); it('keeps previously confirmed insertion-order source identities exact during semantic reconstruction',async()=>{ const f=await fixture();const input=await readSitePlanInput(f.source,f.root); input.source.sourceIdentity=hashBytes(Buffer.from(JSON.stringify(input.source.sourceIntent))); diff --git a/src/site/apply.ts b/src/site/apply.ts index a10bb07..9764da5 100644 --- a/src/site/apply.ts +++ b/src/site/apply.ts @@ -5,7 +5,7 @@ import { readStableRegularFile } from '../publication.js'; import { hashCanonicalJson } from '../proof/receipt.js'; import { compileSite, compileSiteSyncedPattern, compileThemeDocuments } from './compile.js'; import { readSitePlanInput, planPreparedSite, observedAttachmentBindings, managedStateHash } from './plan.js'; -import { importPlannedSiteAttachment, SiteMediaImportOutcomeUnknown } from './media-import.js'; +import { importPlannedSiteAttachmentStep, SiteMediaImportOutcomeUnknown } from './media-import.js'; import { attachmentVersionKey } from './media.js'; import { siteSourceIdentityMatches } from './input.js'; import { syncedPatternOrder } from './patterns.js'; @@ -127,7 +127,7 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans const done=journal!.steps.find(item=>item.step.key===key&&item.step.status==='completed'); if(done){prove(done,live);return;} // Retained operations have no effects. Initial and final full observations bind them. if(op.action==='retain') return; - live=await read(); assertIdentity(); if(!same(comparable(live),comparable(journal!.observed))) throw new Error('live state changed immediately before mutation'); + // mark() performs the fresh full read and comparison before the effect, after journal persistence. const refreshed=await readSitePlanInput(options.source,options.assetRoot); if(!siteSourceIdentityMatches(refreshed.source.sourceIntent,plan.sourceIdentity)||!same(refreshed.source.assets.map(({id,path,sha256})=>({id,path,sha256})),plan.assets)) throw new Error('source bytes changed immediately before mutation'); if(op.action==='update'){const entity=live.entities.find(entity=>entity.id===op.id);const owner=entity?.ownership.length===1?entity.ownership[0]:undefined;if(!entity||!owner||owner.version!==1||owner.siteId!==plan.siteId||owner.entityId!==op.entityId||owner.kind!==op.kind||owner.lastAppliedHash!==managedStateHash(entity.state))throw new Error('update requires exact existing ownership and full managed baseline');} @@ -135,10 +135,11 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans const record={step,operation:op,beforeState:op.kind==='option'?live.options[op.entityId]:live.entities.find(entity=>entity.id===op.id)??null} as SiteApplyReceipt['steps'][number]; journal!.steps.push(record);journal!.currentStep=step; journal!.status='started';await save();await mark('started'); const beforeMutation=structuredClone(live); let attempted=false; + let mediaReadback:SiteSnapshot|undefined; try { attempted=true; if(op.kind==='attachment') { - try { await importPlannedSiteAttachment({plan,source:input.source,assetId:op.entityId,wp:options,authority:{token:boundary.token,journalPath},readTransport:transports.read,mutationTransport:transports.mutate}); } + try { const imported=await importPlannedSiteAttachmentStep({plan,source:input.source,assetId:op.entityId,wp:options,authority:{token:boundary.token,journalPath},readTransport:transports.read,mutationTransport:transports.mutate},live);mediaReadback=imported.snapshot; } catch(error) { if(error instanceof Error && !(error instanceof SiteMediaImportOutcomeUnknown))attempted=false; throw error; } } else { @@ -147,7 +148,7 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans if(result.status!=='complete') throw new Error('mutation outcome unknown'); if(result.id) record.operation={...op,id:result.id}; } - live=await read();assertIdentity();const entity=prove(record,live); + live=mediaReadback??await read();assertIdentity();const entity=prove(record,live); // Media helper returns exact ownership but does not persist the shared site index. assertTransition(beforeMutation,live,op,entity); if(entity) record.after=entity;else record.value=live.options[op.entityId]; diff --git a/src/site/media-import.ts b/src/site/media-import.ts index ad7f3a9..7d654bd 100644 --- a/src/site/media-import.ts +++ b/src/site/media-import.ts @@ -43,19 +43,29 @@ export class SiteMediaImportOutcomeUnknown extends Error { * started step is durable locally and visible in the selected WordPress site's operation marker. */ export async function importPlannedSiteAttachment(options: ImportPlannedSiteMediaOptions): Promise { + return (await importAttachment(options)).binding; +} + +/** Apply has just verified source and persisted/read back this exact started step. */ +export async function importPlannedSiteAttachmentStep(options: ImportPlannedSiteMediaOptions, before: SiteSnapshot): Promise<{ binding: SiteAttachmentBinding; snapshot: SiteSnapshot }> { + return importAttachment(options, before); +} + +async function importAttachment(options: ImportPlannedSiteMediaOptions, verifiedBefore?: SiteSnapshot): Promise<{ binding: SiteAttachmentBinding; snapshot: SiteSnapshot }> { const { plan, source, assetId, wp, authority } = options; const selected = validateSelectedCreate(plan, source, assetId); validateJournalAuthority(plan, selected.asset.id, selected.key, selected.asset.sha256, authority, await readJournal(authority.journalPath)); // Re-read the entire declared source through the regular-file/no-symlink boundary immediately // before the one target mutation attempt. - await verifyPreparedSiteAssets(source); + if (!verifiedBefore) await verifyPreparedSiteAssets(source); const refreshedAsset = source.assets.find(asset => asset.id === assetId); if (!refreshedAsset || refreshedAsset.sha256 !== selected.asset.sha256 || hashBytes(Buffer.from(refreshedAsset.bytes)) !== selected.asset.sha256) { throw new Error('declared attachment bytes changed after confirmation'); } - const before = await readSiteSnapshot(wp, plan.siteId, options.readTransport); + const routes = plan.snapshot.routing.routes.map(route => route.route); + const before = verifiedBefore ?? await readSiteSnapshot(wp, plan.siteId, options.readTransport, routes); assertTargetAndMarker(before, plan, selected.key, selected.asset.id, selected.asset.sha256, authority.token); if (before.theme.slug !== plan.snapshot.theme.slug || hashCanonicalJson(before.theme.inventory) !== hashCanonicalJson(plan.snapshot.theme.inventory) @@ -89,7 +99,7 @@ export async function importPlannedSiteAttachment(options: ImportPlannedSiteMedi const createdId = parseMutationResult(mutationResult, selected.asset.sha256); let after: SiteSnapshot; try { - after = await readSiteSnapshot(wp, plan.siteId, options.readTransport); + after = await readSiteSnapshot(wp, plan.siteId, options.readTransport, routes); } catch { throw new SiteMediaImportOutcomeUnknown(); } @@ -106,7 +116,7 @@ export async function importPlannedSiteAttachment(options: ImportPlannedSiteMedi if (!binding || binding.id !== createdId || matchingIds.length !== 1 || !exactEntity || exactEntity.postStatus !== 'inherit' || exactEntity.ownership.length !== 1 || exactEntity.state.sha256 !== selected.asset.sha256 || !exactOwner || exactOwner.lastAppliedHash !== managedStateHash(exactEntity.state) || !exactEntity.attachment?.sizes?.full || typeof exactEntity.attachment.url !== 'string') throw new Error('new attachment does not match its declared version and native metadata'); - return binding; + return { binding, snapshot: after }; } catch { throw new SiteMediaImportOutcomeUnknown(); } From e7d50c7a97da46f378b72fa165724f1a3cc243da Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:54:25 +0200 Subject: [PATCH 12/38] perf(site): hash only owned and referenced attachment files --- docs/reference.md | 5 +++++ src/site/apply.ts | 4 +++- src/site/media-import.ts | 5 +++-- src/site/plan.ts | 14 +++++++++++++- src/site/wpcli.ts | 17 +++++++++++------ 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 41a7d12..b09719d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -279,6 +279,11 @@ Use the CLI in shell scripts, pre-commit hooks or CI. ### Interrupted site apply +New site plans hash media files owned by Block Runner or explicitly referenced by +the source. Other attachments retain their native metadata and numeric IDs for +collision checks. Apply preserves any additional media-byte authority recorded in +an older confirmation. + `site apply` holds one lock per WordPress blog, shared by every Block Runner site identity. A held lock is refused even when its token matches the journal; a token cannot prove that an earlier WP-CLI or PHP process has stopped. Older namespace diff --git a/src/site/apply.ts b/src/site/apply.ts index 9764da5..af06cae 100644 --- a/src/site/apply.ts +++ b/src/site/apply.ts @@ -57,7 +57,9 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans catch(error) { if((error as NodeJS.ErrnoException).code!=='ENOENT') throw error; } // Use the confirmed routes even when they have no current numeric ID. const desiredRoutes=plan.snapshot.routing.routes.map(route=>route.route); - const read=()=>readSiteSnapshot(options,plan.siteId,transports.read,desiredRoutes); + // Retain explicit byte authority in existing confirmations, including legacy whole-library snapshots. + const mediaHashIds=plan.snapshot.entities.filter(entity=>entity.kind==='attachment'&&typeof entity.state.sha256==='string').map(entity=>entity.id); + const read=()=>readSiteSnapshot(options,plan.siteId,transports.read,desiredRoutes,mediaHashIds); let live=await read(); const boundary: SiteApplyBoundary={siteId:plan.siteId,planHash:confirmationHash,token:journal?.token ?? randomUUID(),target:plan.snapshot.target,principal:plan.snapshot.principal,theme:plan.snapshot.theme}; const call=(request:SiteApplyRequest)=>siteApplyWpCli(options,boundary,request,transports.mutate); diff --git a/src/site/media-import.ts b/src/site/media-import.ts index 7d654bd..8c835ac 100644 --- a/src/site/media-import.ts +++ b/src/site/media-import.ts @@ -65,7 +65,8 @@ async function importAttachment(options: ImportPlannedSiteMediaOptions, verified } const routes = plan.snapshot.routing.routes.map(route => route.route); - const before = verifiedBefore ?? await readSiteSnapshot(wp, plan.siteId, options.readTransport, routes); + const mediaHashIds=plan.snapshot.entities.filter(entity=>entity.kind==='attachment'&&typeof entity.state.sha256==='string').map(entity=>entity.id); + const before = verifiedBefore ?? await readSiteSnapshot(wp, plan.siteId, options.readTransport, routes, mediaHashIds); assertTargetAndMarker(before, plan, selected.key, selected.asset.id, selected.asset.sha256, authority.token); if (before.theme.slug !== plan.snapshot.theme.slug || hashCanonicalJson(before.theme.inventory) !== hashCanonicalJson(plan.snapshot.theme.inventory) @@ -99,7 +100,7 @@ async function importAttachment(options: ImportPlannedSiteMediaOptions, verified const createdId = parseMutationResult(mutationResult, selected.asset.sha256); let after: SiteSnapshot; try { - after = await readSiteSnapshot(wp, plan.siteId, options.readTransport, routes); + after = await readSiteSnapshot(wp, plan.siteId, options.readTransport, routes, mediaHashIds); } catch { throw new SiteMediaImportOutcomeUnknown(); } diff --git a/src/site/plan.ts b/src/site/plan.ts index f725c32..22d992b 100644 --- a/src/site/plan.ts +++ b/src/site/plan.ts @@ -291,10 +291,22 @@ export interface SitePlanOptions extends SiteWpCliOptions { assetRoot?: string; /** Source-first orchestration reads WordPress once and never publishes or mutates it. */ export async function planSite(inputPath: string, options: SitePlanOptions, transport?: SiteReadTransport): Promise { const input = await readSitePlanInput(inputPath, options.assetRoot); - const observed = await readSiteSnapshot(options, input.source.sourceIntent.siteId, transport, input.source.sourceIntent.pages.map(page=>page.route)); + const observed = await readSiteSnapshot(options, input.source.sourceIntent.siteId, transport, input.source.sourceIntent.pages.map(page=>page.route), referencedMediaIds(input.source.sourceIntent)); return planPreparedSite(input, observed, options); } +function referencedMediaIds(intent: PreparedSiteSource['sourceIntent']): number[] { + const ids = new Set(); + const visit = (nodes: import('../types.js').IntentNode[]): void => { + for (const node of nodes) { + if ((node.block === 'core/image' || node.block === 'core/cover') && Number.isSafeInteger(node.attrs?.id) && Number(node.attrs?.id) > 0) ids.add(Number(node.attrs!.id)); + if (node.children) visit(node.children); + } + }; + for (const document of [...Object.values(intent.templates), ...Object.values(intent.parts), ...Object.values(intent.patterns), ...Object.values(intent.syncedPatterns?.definitions ?? {}), ...intent.pages]) visit(document.body.blocks); + return [...ids].sort((a, b) => a - b); +} + /** Reconstruct semantics from already verified source and an explicit snapshot, without filesystem or WordPress reads. */ export async function planPreparedSite(input: SitePlanInput, observed: SiteSnapshot, options: Pick = {}): Promise { if (input.sourceIntentState === 'unreconciled') { diff --git a/src/site/wpcli.ts b/src/site/wpcli.ts index 013017f..19eeccc 100644 --- a/src/site/wpcli.ts +++ b/src/site/wpcli.ts @@ -38,7 +38,7 @@ const localRead: SiteReadTransport = async (executable, args) => { }; /** The adapter exposes only this fixed read operation, never arbitrary commands or mutation methods. */ -export async function readSiteSnapshot(options: SiteWpCliOptions, siteId: string, transport: SiteReadTransport = localRead, desiredRoutes: string[] = []): Promise { +export async function readSiteSnapshot(options: SiteWpCliOptions, siteId: string, transport: SiteReadTransport = localRead, desiredRoutes: string[] = [], mediaHashIds: readonly number[] = []): Promise { if (!options.wpPath || options.wpPath.includes('\0')) throw new Error('wpPath is required'); if (!/^[a-z0-9][a-z0-9-]*$/.test(siteId)) throw new Error('invalid siteId'); const args = [`--path=${options.wpPath}`]; @@ -46,7 +46,8 @@ export async function readSiteSnapshot(options: SiteWpCliOptions, siteId: string if (options.wpUrl) args.push(`--url=${options.wpUrl}`); if (options.wpUser) args.push(`--user=${options.wpUser}`); if (desiredRoutes.some(route=>!route.startsWith('/') || route.includes('\0') || route.includes('?') || route.includes('#'))) throw new Error('invalid desired route'); - args.push('eval', snapshotScript(siteId, desiredRoutes)); + if (mediaHashIds.some(id => !Number.isSafeInteger(id) || id < 1)) throw new Error('invalid referenced media ID'); + args.push('eval', snapshotScript(siteId, desiredRoutes, mediaHashIds)); const output = await transport(options.wpBinary ?? 'wp', args); let snapshot: SiteSnapshot; try { snapshot = JSON.parse(output) as SiteSnapshot; } catch { throw new Error('WP-CLI returned an invalid snapshot'); } @@ -164,9 +165,10 @@ function validateSnapshot(value: SiteSnapshot): void { if (!Number.isInteger(value.principal.userId) || value.principal.userId < 1) throw new Error('Select an authenticated WordPress principal with --wp-user or WP-CLI configuration (observed user ID 0)'); } -function snapshotScript(siteId: string, desiredRoutes: string[]): string { +function snapshotScript(siteId: string, desiredRoutes: string[], mediaHashIds: readonly number[]): string { const option = JSON.stringify(siteRecordOption(siteId)); const routes = Buffer.from(JSON.stringify(desiredRoutes.filter(route=>route!=='/'))).toString('base64'); + const mediaIds = Buffer.from(JSON.stringify([...new Set(mediaHashIds)])).toString('base64'); return ` // This process terminates after inspection; restoring core cron would spawn a write at shutdown. if (defined('ALTERNATE_WP_CRON') && ALTERNATE_WP_CRON) throw new Exception('alternate cron bootstrap is unsupported for read-only site planning'); @@ -174,6 +176,7 @@ remove_action('shutdown','_wp_cron'); $capabilities = array(); foreach (array('edit_pages','publish_pages','edit_others_pages','edit_posts','edit_others_posts','publish_posts','publish_blocks','edit_published_posts','upload_files','manage_options','edit_theme_options') as $cap) $capabilities[$cap] = current_user_can($cap); $entities = array(); $overrides = array(); $navigation = array(); +$mediaHashIds = array_fill_keys(json_decode(base64_decode('${mediaIds}'),true),true); $posts = get_posts(array('post_type'=>array('page','attachment','wp_block','wp_template','wp_template_part','wp_navigation'), 'post_status'=>array('publish','future','draft','pending','private','trash','inherit','auto-draft'), 'numberposts'=>-1, 'orderby'=>'ID', 'order'=>'ASC', 'suppress_filters'=>true)); foreach ($posts as $post) { if ($post->post_type === 'wp_template' || $post->post_type === 'wp_template_part') { @@ -181,6 +184,7 @@ foreach ($posts as $post) { } if ($post->post_type === 'wp_navigation') { $navigation[] = array('id'=>(int)$post->ID,'content'=>$post->post_content,'status'=>$post->post_status); continue; } $kind = $post->post_type === 'page' ? 'page' : ($post->post_type === 'attachment' ? 'attachment' : 'pattern'); + $meta = get_post_meta($post->ID, '${SITE_OWNERSHIP_META}', false); $state = array('content'=>$post->post_content, 'title'=>$post->post_title, 'status'=>$post->post_status); $route = null; $attachment = null; if ($kind === 'page') { @@ -193,10 +197,12 @@ foreach ($posts as $post) { if ((int)get_option('page_on_front') === (int)$post->ID && get_option('show_on_front') === 'page') $route = '/'; } if ($kind === 'attachment') { + // Keep native metadata/collision inventory for all media; read bytes only for owned or referenced IDs. + $hashBytes = count($meta) > 0 || isset($mediaHashIds[$post->ID]); $file = get_attached_file($post->ID); $original = wp_get_original_image_path($post->ID); if (!$original) $original = $file; $metadata = wp_get_attachment_metadata($post->ID); - $state = array('sha256'=>($original && is_file($original) && !is_link($original)) ? hash_file('sha256',$original) : null); + $state = array('sha256'=>($hashBytes && $original && is_file($original) && !is_link($original)) ? hash_file('sha256',$original) : null); $sizes = array(); $full = wp_get_attachment_image_src($post->ID, 'full'); if ($full) $sizes['full'] = array('url'=>$full[0], 'width'=>(int)$full[1], 'height'=>(int)$full[2]); @@ -204,9 +210,8 @@ foreach ($posts as $post) { $variant = wp_get_attachment_image_src($post->ID, $slug); if ($variant) $sizes[$slug] = array('url'=>$variant[0], 'width'=>(int)$variant[1], 'height'=>(int)$variant[2]); } - $attachment = array('url'=>wp_get_attachment_url($post->ID) ?: null,'linkUrl'=>get_attachment_link($post->ID) ?: null,'currentFileSha256'=>($file && is_file($file) && !is_link($file)) ? hash_file('sha256',$file) : null,'width'=>isset($metadata['width']) ? (int)$metadata['width'] : null,'height'=>isset($metadata['height']) ? (int)$metadata['height'] : null,'sizes'=>$sizes); + $attachment = array('url'=>wp_get_attachment_url($post->ID) ?: null,'linkUrl'=>get_attachment_link($post->ID) ?: null,'currentFileSha256'=>($hashBytes && $file && is_file($file) && !is_link($file)) ? ($file===$original ? $state['sha256'] : hash_file('sha256',$file)) : null,'width'=>isset($metadata['width']) ? (int)$metadata['width'] : null,'height'=>isset($metadata['height']) ? (int)$metadata['height'] : null,'sizes'=>$sizes); } - $meta = get_post_meta($post->ID, '${SITE_OWNERSHIP_META}', false); $entity = array('id'=>(int)$post->ID,'kind'=>$kind,'route'=>$route,'state'=>$state,'postStatus'=>$post->post_status,'ownership'=>$meta); if ($attachment) $entity['attachment'] = $attachment; $entities[] = $entity; From a1be31e0486016113540e8b82233f56daa8f95d3 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 03:56:06 +0200 Subject: [PATCH 13/38] fix(site): assign managed template metadata after native post updates --- dev/test/site-apply-wpcli.test.ts | 2 +- src/site/apply-wpcli.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/dev/test/site-apply-wpcli.test.ts b/dev/test/site-apply-wpcli.test.ts index 9e661e7..bf9f8f2 100644 --- a/dev/test/site-apply-wpcli.test.ts +++ b/dev/test/site-apply-wpcli.test.ts @@ -32,7 +32,7 @@ function home_url(){return 'https://example.test';}function site_url(){return ho function get_option($k,$default=false){global $options;return $options[$k]??$default;}function add_option($k,$v,$unused='',$autoload=false){global $options;if(array_key_exists($k,$options))return false;$options[$k]=$v;return true;}function delete_option($k){global $options;unset($options[$k]);return true;}function update_option($k,$v,$autoload=false){global $options,$writes;$writes++;$options[$k]=$v;return true;} function get_post($id){global $p;return $id===22?$p:null;}function get_posts($args){global $p,$flags;return isset($flags['occupied'])?array($p):array();}function get_post_meta($id,$k,$single){global $meta;return $single?($meta[$id][$k]??''):($meta[$id][$k]??array());} function wp_slash($v){return $v;}function is_wp_error($v){return false;}function wp_insert_post($v,$errors){global $p,$writes,$flags;$writes++;$p=(object)array('ID'=>22,'post_type'=>$v['post_type'],'post_content'=>$v['post_content'],'post_title'=>$v['post_title'],'post_status'=>$v['post_status'],'post_name'=>$v['post_name'],'post_parent'=>$v['post_parent']);return 22;} -function wp_update_post($v,$errors){global $p,$writes;$writes++;foreach(array('post_type','post_title','post_content','post_status','post_name','post_parent') as $key)if(array_key_exists($key,$v))$p->$key=$v[$key];return (int)$v['ID'];} +function wp_update_post($v,$errors){global $p,$writes;$writes++;foreach(array('post_type','post_title','post_content','post_status','post_name','post_parent') as $key)if(array_key_exists($key,$v))$p->$key=$v[$key];if(!array_key_exists('page_template',$v)||$v['page_template']!=='')throw new Exception('inherited page template validation');return (int)$v['ID'];} function update_post_meta($id,$k,$v){global $meta,$flags;if(isset($flags['ownershipCrash'])&&$k==='_block_runner_site')throw new Exception('crash');$meta[$id][$k]=$k==='_block_runner_site'?array($v):$v;return true;} function add_post_meta($id,$k,$v,$unique){global $meta,$flags;if(isset($flags['ownershipCrash']))throw new Exception('crash');$meta[$id][$k]=array($v);return true;} `; diff --git a/src/site/apply-wpcli.ts b/src/site/apply-wpcli.ts index 44ad729..fc40f4d 100644 --- a/src/site/apply-wpcli.ts +++ b/src/site/apply-wpcli.ts @@ -130,7 +130,8 @@ $result=(function() use($e,$r,&$attempted,$same,$state) { } $data=array('post_type'=>$type,'post_title'=>$desired['title'],'post_content'=>$desired['content'],'post_status'=>$desired['status']); if($id) $data['ID']=$id; - if($kind==='page') { $data['post_name']=$desired['slug']; $data['post_parent']=$desired['parentId']; } + // Template metadata is assigned below. wp_update_post otherwise inherits and revalidates the old template after writing. + if($kind==='page') { $data['post_name']=$desired['slug']; $data['post_parent']=$desired['parentId']; $data['page_template']=''; } $attempted=true; $written=$id ? wp_update_post(wp_slash($data),true) : wp_insert_post(wp_slash($data),true); if(is_wp_error($written)||!$written) throw new Exception('save'); $id=(int)$written; if($kind==='page') update_post_meta($id,'_wp_page_template',$desired['template']); $owner=array('version'=>1,'siteId'=>$e['siteId'],'entityId'=>$op['entityId'],'kind'=>$kind,'lastAppliedHash'=>$r['stateHash']); From e61bca9ce00c7af3201d22bcfa89ae82b0932255 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:00:35 +0200 Subject: [PATCH 14/38] fix: reject cycles in site block trees --- dev/test/site-contract.test.ts | 30 +++++++++++++++++++ src/site/input.ts | 54 +++++++++++++++++++++++++--------- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/dev/test/site-contract.test.ts b/dev/test/site-contract.test.ts index a338277..e48660f 100644 --- a/dev/test/site-contract.test.ts +++ b/dev/test/site-contract.test.ts @@ -87,6 +87,36 @@ describe('site intent contract', () => { ])); }); + it('reports self and mutual block-tree cycles but permits shared nodes across branches', async () => { + for (const makeCycle of [ + () => { const node: Record = { block: 'core/group' }; node.children = [node]; return [node]; }, + () => { + const first: Record = { block: 'core/group' }; + const second: Record = { block: 'core/group' }; + first.children = [second]; second.children = [first]; + return [first]; + }, + ]) { + const input = await loadIntent(); + input.pages[0].body.blocks = makeCycle(); + input.assetUses = []; + const diagnostics: Array<{ path: string; code: string }> = []; + expect(() => validateSiteIntent(input, diagnostics as any)).not.toThrow(); + expect(diagnostics).toContainEqual(expect.objectContaining({ path: expect.stringMatching(/^\/pages\/0\/body\/blocks\//), code: 'intent-tree-cycle' })); + } + + const input = await loadIntent(); + const shared = { block: 'core/paragraph', text: 'shared node' }; + input.pages[0].body.blocks = [ + { block: 'core/group', children: [shared] }, + { block: 'core/group', children: [shared] }, + ]; + input.assetUses = []; + const diagnostics: Array<{ path: string; code: string }> = []; + expect(validateSiteIntent(input, diagnostics as any)).toBeDefined(); + expect(diagnostics).toEqual([]); + }); + it('preserves deliberately empty content and permissive native attrs', async () => { const input = await loadIntent(); input.pages[0].body = { blocks: [] }; diff --git a/src/site/input.ts b/src/site/input.ts index eb5f677..b5b7177 100644 --- a/src/site/input.ts +++ b/src/site/input.ts @@ -578,22 +578,48 @@ function validateTree(value: unknown, pathName: string, diagnostics: SiteInputDi // Preserve native intent trees verbatim. The document compiler/finalizer owns their detailed // validity; this boundary consumes only the tree root for references and asset-use paths. let valid = true; - const pending: Array<{ nodes: unknown[]; path: string }> = [{ nodes: value.blocks, path: `${pathName}/blocks` }]; + const active = new Set(); + const pending: Array<{ kind: 'enter' | 'exit'; value: object; path: string }> = [ + { kind: 'enter', value: value.blocks, path: `${pathName}/blocks` }, + ]; while (pending.length > 0) { - const current = pending.pop()!; - for (let index = current.nodes.length - 1; index >= 0; index -= 1) { - const nodePath = `${current.path}/${index}`; - const candidate = current.nodes[index]; - if (!isRecord(candidate) || typeof candidate.block !== 'string' || candidate.block.length === 0) { - diagnostics.push(issue(nodePath, 'intent-node-required', 'each block must be an object with a non-empty block name')); - valid = false; - continue; - } - if (Object.hasOwn(candidate, 'children')) { - if (!Array.isArray(candidate.children)) { - diagnostics.push(issue(`${nodePath}/children`, 'array-required', 'block children must be an array when present')); + const frame = pending.pop()!; + if (frame.kind === 'exit') { + active.delete(frame.value); + continue; + } + if (active.has(frame.value)) { + diagnostics.push(issue(frame.path, 'intent-tree-cycle', 'block trees must not contain object or array cycles')); + valid = false; + continue; + } + active.add(frame.value); + pending.push({ kind: 'exit', value: frame.value, path: frame.path }); + if (Array.isArray(frame.value)) { + for (let index = frame.value.length - 1; index >= 0; index -= 1) { + const nodePath = `${frame.path}/${index}`; + const candidate = frame.value[index]; + if (candidate !== null && typeof candidate === 'object' && active.has(candidate)) { + diagnostics.push(issue(nodePath, 'intent-tree-cycle', 'block trees must not contain object or array cycles')); valid = false; - } else pending.push({ nodes: candidate.children, path: `${nodePath}/children` }); + continue; + } + if (!isRecord(candidate) || typeof candidate.block !== 'string' || candidate.block.length === 0) { + diagnostics.push(issue(nodePath, 'intent-node-required', 'each block must be an object with a non-empty block name')); + valid = false; + continue; + } + pending.push({ kind: 'enter', value: candidate, path: nodePath }); + } + continue; + } + const candidate = frame.value as Record; + if (Object.hasOwn(candidate, 'children')) { + if (!Array.isArray(candidate.children)) { + diagnostics.push(issue(`${frame.path}/children`, 'array-required', 'block children must be an array when present')); + valid = false; + } else { + pending.push({ kind: 'enter', value: candidate.children, path: `${frame.path}/children` }); } } } From 6b9e687953e6a0cf4f6ea1b9adede22371c8fb98 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:00:37 +0200 Subject: [PATCH 15/38] perf: reuse validated source for pattern compilation --- dev/test/site-patterns.test.ts | 9 ++++++++- src/site/compile.ts | 37 +++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/dev/test/site-patterns.test.ts b/dev/test/site-patterns.test.ts index be3180b..4853022 100644 --- a/dev/test/site-patterns.test.ts +++ b/dev/test/site-patterns.test.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { validateSiteIntent } from '../../src/site/input.js'; -import { compileSite, compileSitePageDocuments, compileSiteSyncedPattern, compileSiteSyncedPatterns } from '../../src/site/compile.js'; +import { compileSite, compileSitePageDocuments, compileSiteSyncedPattern, compileSiteSyncedPatterns, createSiteSyncedPatternCompiler } from '../../src/site/compile.js'; import type { PreparedSiteSource, SiteIntent, ThemeArtifact } from '../../src/site/types.js'; import { managedStateHash, reconcileSitePlan } from '../../src/site/plan.js'; import type { SiteEntity, SiteSnapshot } from '../../src/site/plan-types.js'; @@ -246,6 +246,13 @@ describe('site synced pattern contract and compiler', () => { expect(bound[0]!.content).toContain('"id":73'); const single = await compileSiteSyncedPattern(prepared.source, 'card', { attachmentBindings: { 'home-hero': { id: 73, url: 'https://example.test/hero.png' } } }); expect(single).toMatchObject({ ok: true, pattern: { id: 'card', content: expect.stringContaining('"id":73') } }); + const compiler = createSiteSyncedPatternCompiler(prepared.source); + expect(compiler.ok).toBe(true); + if (compiler.ok) { + const current = await compiler.compile('card', { attachmentBindings: { 'home-hero': { id: 74, url: 'https://example.test/current.png' } } }); + expect(current).toMatchObject({ ok: true, pattern: { id: 'card', content: expect.stringContaining('"id":74') } }); + } + expect(createSiteSyncedPatternCompiler({ ...prepared.source, sourceIdentity: 'stale' })).toMatchObject({ ok: false, diagnostics: [expect.objectContaining({ code: 'prepared-source-identity-mismatch' })] }); const missing = await compileSiteSyncedPattern(prepared.source, 'missing'); expect(missing).toMatchObject({ ok: false, diagnostics: [expect.objectContaining({ code: 'unknown-pattern-id' })] }); const invalidSource = await compileSiteSyncedPattern({ ...prepared.source, sourceIdentity: 'stale' }, 'card'); diff --git a/src/site/compile.ts b/src/site/compile.ts index eee71b4..d333458 100644 --- a/src/site/compile.ts +++ b/src/site/compile.ts @@ -245,9 +245,31 @@ export async function compileSiteSyncedPattern( ): Promise { const prepared = validatePreparedSource(source); if (!prepared.ok) return { ok: false, diagnostics: prepared.diagnostics }; - const intent = prepared.intent; + return compileSiteSyncedPatternFromIntent(source, prepared.intent, patternId, options, allowPendingBindings); +} + +/** Validate one prepared source once, then compile current pattern definitions with fresh bindings. */ +export function createSiteSyncedPatternCompiler(source: PreparedSiteSource): + | { ok: true; compile: (patternId: string, options?: CompileSiteOptions, allowPendingBindings?: boolean) => Promise } + | { ok: false; diagnostics: ReportItem[] } { + const prepared = validatePreparedSource(source); + if (!prepared.ok) return { ok: false, diagnostics: prepared.diagnostics }; + return { + ok: true, + compile: (patternId, options = {}, allowPendingBindings = true) => + compileSiteSyncedPatternFromIntent(source, prepared.intent, patternId, options, allowPendingBindings), + }; +} + +function compileSiteSyncedPatternFromIntent( + source: PreparedSiteSource, + intent: SiteIntent, + patternId: string, + options: CompileSiteOptions, + allowPendingBindings: boolean, +): Promise { const definition = intent.syncedPatterns?.definitions[patternId]; - if (!definition) return { ok: false, diagnostics: [invalid(`synced-pattern:${patternId}`, 'unknown-pattern-id', `unknown synced pattern "${patternId}"`)] }; + if (!definition) return Promise.resolve({ ok: false, diagnostics: [invalid(`synced-pattern:${patternId}`, 'unknown-pattern-id', `unknown synced pattern "${patternId}"`)] }); const selected: SiteIntent = { ...intent, @@ -257,11 +279,12 @@ export async function compileSiteSyncedPattern( uses: intent.syncedPatterns!.uses.filter((use) => use.documentId === `synced-pattern:${patternId}`), }, }; - const patterns = await compileSiteSyncedPatternsFromIntent(source, selected, options, allowPendingBindings); - const pattern = patterns[0]; - if (!pattern) return { ok: false, diagnostics: [invalid(`synced-pattern:${patternId}`, 'pattern-compilation-failed', 'synced pattern definition was not compiled')] }; - if (!pattern.ok) return { ok: false, diagnostics: pattern.diagnostics }; - return { ok: true, pattern }; + return compileSiteSyncedPatternsFromIntent(source, selected, options, allowPendingBindings).then((patterns) => { + const pattern = patterns[0]; + if (!pattern) return { ok: false as const, diagnostics: [invalid(`synced-pattern:${patternId}`, 'pattern-compilation-failed', 'synced pattern definition was not compiled')] }; + if (!pattern.ok) return { ok: false as const, diagnostics: pattern.diagnostics }; + return { ok: true as const, pattern }; + }); } function compileSiteSyncedPatternsFromIntent( From 40bdb1a916e2fcf5fdd98ff260c16b407cc82a83 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:02:03 +0200 Subject: [PATCH 16/38] refactor(site): clarify executor flow and reuse compiled patterns --- src/site/apply-wpcli.ts | 93 ++++-- src/site/apply.ts | 699 +++++++++++++++++++++++++++++++--------- src/site/plan.ts | 475 +++++++++++++++++++-------- src/site/wpcli.ts | 53 +-- 4 files changed, 980 insertions(+), 340 deletions(-) diff --git a/src/site/apply-wpcli.ts b/src/site/apply-wpcli.ts index fc40f4d..1dd90d2 100644 --- a/src/site/apply-wpcli.ts +++ b/src/site/apply-wpcli.ts @@ -3,33 +3,69 @@ import { siteRecordOption, SITE_OWNERSHIP_META, SITE_APPLY_LOCK_OPTION, type Sit import { managedStateHash } from './plan.js'; import type { SiteSnapshot, SiteApplyStep, SitePlanOperation } from './plan-types.js'; export interface SiteApplyBoundary { - siteId: string; planHash: string; token: string; target: SiteSnapshot['target']; principal: SiteSnapshot['principal']; theme: SiteSnapshot['theme']; + siteId: string; + planHash: string; + token: string; + target: SiteSnapshot['target']; + principal: SiteSnapshot['principal']; + theme: SiteSnapshot['theme']; } -export type SiteApplyRequest = - | { action: 'lock' | 'unlock' } - | { action: 'marker'; index?: { key: string; id: number }; operation: { token: string; planHash: string; status: 'started' | 'outcome_unknown' | 'completed'; target: SiteSnapshot['target']; principalId: number; currentStep?: SiteApplyStep; stepEvidence?: { operation: SitePlanOperation; beforeState: unknown } } } - | { action: 'mutate'; step: SiteApplyStep; operation: SitePlanOperation; before: SiteSnapshot; stateHash: string }; +export type SiteApplyRequest = { + action: 'lock' | 'unlock'; +} | { + action: 'marker'; + index?: { + key: string; + id: number; + }; + operation: { + token: string; + planHash: string; + status: 'started' | 'outcome_unknown' | 'completed'; + target: SiteSnapshot['target']; + principalId: number; + currentStep?: SiteApplyStep; + stepEvidence?: { + operation: SitePlanOperation; + beforeState: unknown; + }; + }; +} | { + action: 'mutate'; + step: SiteApplyStep; + operation: SitePlanOperation; + before: SiteSnapshot; + stateHash: string; +}; /** Only fixed operations are exposed. A transport error after invocation has uncertain effects. */ -export async function siteApplyWpCli(wp: SiteWpCliOptions, boundary: SiteApplyBoundary, request: SiteApplyRequest, transport?: SiteReadTransport): Promise<{status: string; id?: number}> { - if (!wp.wpPath || [wp.wpPath,wp.wpUrl,wp.wpUser,wp.wpBinary].some(value=>value?.includes('\0'))) throw new Error('invalid WP-CLI selection'); +export async function siteApplyWpCli(wp: SiteWpCliOptions, boundary: SiteApplyBoundary, request: SiteApplyRequest, transport?: SiteReadTransport): Promise<{ + status: string; + id?: number; +}> { + if (!wp.wpPath || [wp.wpPath, wp.wpUrl, wp.wpUser, wp.wpBinary].some(value => value?.includes('\0'))) throw new Error('invalid WP-CLI selection'); const selection = [`--path=${wp.wpPath}`, ...(wp.wpUrl ? [`--url=${wp.wpUrl}`] : []), ...(wp.wpUser ? [`--user=${wp.wpUser}`] : [])]; let output: string; try { - output = transport - ? await transport(wp.wpBinary ?? 'wp',[...selection,'eval',script(boundary,request)]) - : await runWithInput(wp.wpBinary ?? 'wp',[...selection,'eval-file','-'],` { return new Promise((resolve, reject) => { - const child = spawn(executable, args, { stdio: ['pipe', 'pipe', 'ignore'] }); + const child = spawn(executable, args, { + stdio: ['pipe', 'pipe', 'ignore'] + }); let output = ''; let settled = false; const fail = () => { @@ -56,22 +92,33 @@ function runWithInput(executable: string, args: string[], input: string): Promis settled = true; resolve(output); }); - try { child.stdin.end(input); } catch { fail(); } + try { + child.stdin.end(input); + } catch { + fail(); + } }); } - function script(boundary: SiteApplyBoundary, request: SiteApplyRequest): string { // The executor keeps complete transition evidence. PHP needs only this mutation's baseline. const payloadRequest = request.action === 'mutate' ? { ...request, before: { entities: request.operation.id ? request.before.entities.filter(entity => entity.id === request.operation.id) : [], - options: request.operation.kind === 'option' - ? { [request.operation.entityId]: request.before.options[request.operation.entityId] } - : {}, - }, + options: request.operation.kind === 'option' ? { + [request.operation.entityId]: request.before.options[request.operation.entityId] + } : {} + } } : request; - const encoded = Buffer.from(JSON.stringify({ ...boundary, request: payloadRequest, ...(request.action==='mutate'&&request.operation.id?{beforeStateHash:managedStateHash(request.before.entities.find(entity=>entity.id===request.operation.id)?.state ?? {})}:{}), option:siteRecordOption(boundary.siteId), lock: SITE_APPLY_LOCK_OPTION })).toString('base64'); + const encoded = Buffer.from(JSON.stringify({ + ...boundary, + request: payloadRequest, + ...(request.action === 'mutate' && request.operation.id ? { + beforeStateHash: managedStateHash(request.before.entities.find(entity => entity.id === request.operation.id)?.state ?? {}) + } : {}), + option: siteRecordOption(boundary.siteId), + lock: SITE_APPLY_LOCK_OPTION + })).toString('base64'); return ` $e=json_decode(base64_decode('${encoded}'),true); $r=$e['request']; $attempted=false; $same=function($a,$b) use (&$same) { if (is_array($a) && is_array($b)) { if(count($a)!==count($b)) return false; foreach($a as $k=>$v) if(!array_key_exists($k,$b)||!$same($v,$b[$k])) return false; return true; } return $a===$b; }; diff --git a/src/site/apply.ts b/src/site/apply.ts index af06cae..f771a84 100644 --- a/src/site/apply.ts +++ b/src/site/apply.ts @@ -3,7 +3,7 @@ import { lstat, open, rename } from 'node:fs/promises'; import path from 'node:path'; import { readStableRegularFile } from '../publication.js'; import { hashCanonicalJson } from '../proof/receipt.js'; -import { compileSite, compileSiteSyncedPattern, compileThemeDocuments } from './compile.js'; +import { compileSite, createSiteSyncedPatternCompiler, compileThemeDocuments } from './compile.js'; import { readSitePlanInput, planPreparedSite, observedAttachmentBindings, managedStateHash } from './plan.js'; import { importPlannedSiteAttachmentStep, SiteMediaImportOutcomeUnknown } from './media-import.js'; import { attachmentVersionKey } from './media.js'; @@ -12,204 +12,587 @@ import { syncedPatternOrder } from './patterns.js'; import { readSiteSnapshot, type SiteWpCliOptions, type SiteReadTransport } from './wpcli.js'; import { siteApplyWpCli, type SiteApplyBoundary, type SiteApplyRequest } from './apply-wpcli.js'; import type { SitePlan, SiteSnapshot, SitePlanOperation, SiteApplyStep, SiteEntity } from './plan-types.js'; - export interface SiteApplyOptions extends SiteWpCliOptions { - confirm: string; source: string; journal: string; assetRoot?: string; strict?: boolean; + confirm: string; + source: string; + journal: string; + assetRoot?: string; + strict?: boolean; } export interface SiteApplyReceipt { - version: 1; siteId: string; planHash: string; token: string; target: SiteSnapshot['target']; principalId: number; + version: 1; + siteId: string; + planHash: string; + token: string; + target: SiteSnapshot['target']; + principalId: number; status: 'started' | 'partial' | 'outcome_unknown' | 'completed'; - diagnostics: import('../types.js').ReportItem[]; pending: SitePlanOperation[]; - currentStep?: SiteApplyStep; steps: Array<{ step: SiteApplyStep; operation: SitePlanOperation; beforeState: unknown; after?: SiteEntity; value?: unknown }>; - observed: SiteSnapshot; error?: string; + diagnostics: import('../types.js').ReportItem[]; + pending: SitePlanOperation[]; + currentStep?: SiteApplyStep; + steps: Array<{ + step: SiteApplyStep; + operation: SitePlanOperation; + beforeState: unknown; + after?: SiteEntity; + value?: unknown; + }>; + observed: SiteSnapshot; + error?: string; lockRelease?: 'released' | 'unconfirmed'; limitation: string; } -export interface SiteApplyTransports { read?: SiteReadTransport; mutate?: SiteReadTransport } -const same = (a: unknown,b: unknown): boolean => hashCanonicalJson(a) === hashCanonicalJson(b); +export interface SiteApplyTransports { + read?: SiteReadTransport; + mutate?: SiteReadTransport; +} +const same = (a: unknown, b: unknown): boolean => hashCanonicalJson(a) === hashCanonicalJson(b); function comparable(snapshot: SiteSnapshot): unknown { - const copy = structuredClone(snapshot); if(copy.siteRecord) copy.siteRecord.operation=null; return copy; + const copy = structuredClone(snapshot); + if (copy.siteRecord) copy.siteRecord.operation = null; + return copy; } -function nativePageRoute(entity:SiteEntity,snapshot:SiteSnapshot):string { - if(snapshot.options.show_on_front==='page' && snapshot.options.page_on_front===entity.id)return '/'; - const segments:string[]=[];const visited=new Set();let current:SiteEntity|undefined=entity; - while(current){if(visited.has(current.id))throw new Error('observed page parent cycle');visited.add(current.id);if(!(snapshot.options.show_on_front==='page'&&snapshot.options.page_on_front===current.id))segments.unshift(String(current.state.slug));const parent:number=Number(current.state.parentId);current=parent?snapshot.entities.find(item=>item.kind==='page'&&item.id===parent):undefined;if(parent&&!current)throw new Error('observed page parent absent');} +function nativePageRoute(entity: SiteEntity, snapshot: SiteSnapshot): string { + if (snapshot.options.show_on_front === 'page' && snapshot.options.page_on_front === entity.id) return '/'; + const segments: string[] = []; + const visited = new Set(); + let current: SiteEntity | undefined = entity; + while (current) { + if (visited.has(current.id)) throw new Error('observed page parent cycle'); + visited.add(current.id); + if (!(snapshot.options.show_on_front === 'page' && snapshot.options.page_on_front === current.id)) segments.unshift(String(current.state.slug)); + const parent: number = Number(current.state.parentId); + current = parent ? snapshot.entities.find(item => item.kind === 'page' && item.id === parent) : undefined; + if (parent && !current) throw new Error('observed page parent absent'); + } return `/${segments.join('/')}`; } -function assertTransition(before:SiteSnapshot,after:SiteSnapshot,op:SitePlanOperation,entity?:SiteEntity):void { - const expected=structuredClone(before); - if(entity){expected.entities=expected.entities.filter(item=>item.id!==entity.id);expected.entities.push(entity);expected.entities.sort((a,b)=>a.id-b.id);if(op.kind!=='attachment')expected.siteRecord!.entities[`${op.kind}:${op.entityId}`]=entity.id;else {const key=attachmentVersionKey(op.entityId,String(op.desired?.sha256));if(after.siteRecord?.entities[key]===entity.id)expected.siteRecord!.entities[key]=entity.id;}} - if(op.kind==='option'){expected.options[op.entityId]=op.desired!.value;expected.siteRecord!.options[op.entityId]={lastAppliedValue:op.desired!.value};} - for(const page of expected.entities.filter(item=>item.kind==='page'))page.route=nativePageRoute(page,expected); +function assertTransition(before: SiteSnapshot, after: SiteSnapshot, op: SitePlanOperation, entity?: SiteEntity): void { + const expected = structuredClone(before); + if (entity) { + expected.entities = expected.entities.filter(item => item.id !== entity.id); + expected.entities.push(entity); + expected.entities.sort((a, b) => a.id - b.id); + if (op.kind !== 'attachment') expected.siteRecord!.entities[`${op.kind}:${op.entityId}`] = entity.id;else { + const key = attachmentVersionKey(op.entityId, String(op.desired?.sha256)); + if (after.siteRecord?.entities[key] === entity.id) expected.siteRecord!.entities[key] = entity.id; + } + } + if (op.kind === 'option') { + expected.options[op.entityId] = op.desired!.value; + expected.siteRecord!.options[op.entityId] = { + lastAppliedValue: op.desired!.value + }; + } + for (const page of expected.entities.filter(item => item.kind === 'page')) page.route = nativePageRoute(page, expected); // Request resolution changes as pages become available. // A front-page option may refresh WordPress's rewrite hash; every other routing field stays bound. - expected.routing.routes=after.routing.routes; - if(op.kind==='option'&&(op.entityId==='show_on_front'||op.entityId==='page_on_front')&&!same(before.options[op.entityId],op.desired?.value)&&same(after.options[op.entityId],op.desired?.value)&&typeof after.routing.rewriteRulesHash==='string'&&after.routing.rewriteRulesHash.length>0)expected.routing.rewriteRulesHash=after.routing.rewriteRulesHash; - if(!same(comparable(expected),comparable(after)))throw new Error('unexpected live changes during mutation readback'); + expected.routing.routes = after.routing.routes; + if (op.kind === 'option' && (op.entityId === 'show_on_front' || op.entityId === 'page_on_front') && !same(before.options[op.entityId], op.desired?.value) && same(after.options[op.entityId], op.desired?.value) && typeof after.routing.rewriteRulesHash === 'string' && after.routing.rewriteRulesHash.length > 0) expected.routing.rewriteRulesHash = after.routing.rewriteRulesHash; + if (!same(comparable(expected), comparable(after))) throw new Error('unexpected live changes during mutation readback'); } /** Confirmed database-only apply. Readback detects conflicts; it does not exclude human writes. */ export async function applySite(plan: SitePlan, options: SiteApplyOptions, transports: SiteApplyTransports = {}): Promise { - const {confirmationHash,...body}=plan; - if(options.confirm!==confirmationHash || hashCanonicalJson(body)!==confirmationHash || !same(plan.snapshotFingerprint,hashCanonicalJson(plan.snapshot))) throw new Error('exact plan confirmation and integrity are required'); - const journalPath=path.resolve(options.journal); await safePath(journalPath,true); + const { + confirmationHash, + ...body + } = plan; + if (options.confirm !== confirmationHash || hashCanonicalJson(body) !== confirmationHash || !same(plan.snapshotFingerprint, hashCanonicalJson(plan.snapshot))) throw new Error('exact plan confirmation and integrity are required'); + const journalPath = path.resolve(options.journal); + await safePath(journalPath, true); let journal: SiteApplyReceipt | undefined; - try { journal=JSON.parse((await readStableRegularFile(journalPath,'journal changed while reading')).content.toString('utf8')) as SiteApplyReceipt; } - catch(error) { if((error as NodeJS.ErrnoException).code!=='ENOENT') throw error; } + try { + journal = JSON.parse((await readStableRegularFile(journalPath, 'journal changed while reading')).content.toString('utf8')) as SiteApplyReceipt; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } // Use the confirmed routes even when they have no current numeric ID. - const desiredRoutes=plan.snapshot.routing.routes.map(route=>route.route); + const desiredRoutes = plan.snapshot.routing.routes.map(route => route.route); // Retain explicit byte authority in existing confirmations, including legacy whole-library snapshots. - const mediaHashIds=plan.snapshot.entities.filter(entity=>entity.kind==='attachment'&&typeof entity.state.sha256==='string').map(entity=>entity.id); - const read=()=>readSiteSnapshot(options,plan.siteId,transports.read,desiredRoutes,mediaHashIds); - let live=await read(); - const boundary: SiteApplyBoundary={siteId:plan.siteId,planHash:confirmationHash,token:journal?.token ?? randomUUID(),target:plan.snapshot.target,principal:plan.snapshot.principal,theme:plan.snapshot.theme}; - const call=(request:SiteApplyRequest)=>siteApplyWpCli(options,boundary,request,transports.mutate); - const assertIdentity=():void=>{ if(!same(live.target,plan.snapshot.target)||!same(live.principal,plan.snapshot.principal)||!same(live.theme,plan.snapshot.theme)) throw new Error('target, principal or installed theme changed'); }; + const mediaHashIds = plan.snapshot.entities.filter(entity => entity.kind === 'attachment' && typeof entity.state.sha256 === 'string').map(entity => entity.id); + const read = () => readSiteSnapshot(options, plan.siteId, transports.read, desiredRoutes, mediaHashIds); + let live = await read(); + const boundary: SiteApplyBoundary = { + siteId: plan.siteId, + planHash: confirmationHash, + token: journal?.token ?? randomUUID(), + target: plan.snapshot.target, + principal: plan.snapshot.principal, + theme: plan.snapshot.theme + }; + const call = (request: SiteApplyRequest) => siteApplyWpCli(options, boundary, request, transports.mutate); + const assertIdentity = (): void => { + if (!same(live.target, plan.snapshot.target) || !same(live.principal, plan.snapshot.principal) || !same(live.theme, plan.snapshot.theme)) throw new Error('target, principal or installed theme changed'); + }; assertIdentity(); - if(journal) { - if(journal.version!==1||journal.siteId!==plan.siteId||journal.planHash!==confirmationHash||!journal.token||!same(journal.target,plan.snapshot.target)||journal.principalId!==live.principal.userId) throw new Error('journal belongs to a different confirmed operation'); + if (journal) { + if (journal.version !== 1 || journal.siteId !== plan.siteId || journal.planHash !== confirmationHash || !journal.token || !same(journal.target, plan.snapshot.target) || journal.principalId !== live.principal.userId) throw new Error('journal belongs to a different confirmed operation'); // Completed replay deliberately precedes obsolete executable-plan preconditions and source reads. - if(journal.status==='completed') { - const outstanding=live.siteRecord?.operation;if(outstanding&&outstanding.status!=='completed')throw new Error('outstanding site operation blocks completed receipt replay'); - if(!same(comparable(live),comparable(journal.observed))) throw new Error('completed receipt no longer matches full live after-state; prepare a fresh plan'); + if (journal.status === 'completed') { + const outstanding = live.siteRecord?.operation; + if (outstanding && outstanding.status !== 'completed') throw new Error('outstanding site operation blocks completed receipt replay'); + if (!same(comparable(live), comparable(journal.observed))) throw new Error('completed receipt no longer matches full live after-state; prepare a fresh plan'); return journal; } } - if(!plan.executable||plan.conflicts.length) throw new Error('plan is not executable'); - const input=await readSitePlanInput(options.source,options.assetRoot); - if(!siteSourceIdentityMatches(input.source.sourceIntent,plan.sourceIdentity)||input.source.sourceIntent.siteId!==plan.siteId||!same(input.source.assets.map(({id,path,sha256})=>({id,path,sha256})),plan.assets)) throw new Error('source or asset bytes changed after confirmation'); - input.source.sourceIdentity=plan.sourceIdentity; + if (!plan.executable || plan.conflicts.length) throw new Error('plan is not executable'); + const input = await readSitePlanInput(options.source, options.assetRoot); + if (!siteSourceIdentityMatches(input.source.sourceIntent, plan.sourceIdentity) || input.source.sourceIntent.siteId !== plan.siteId || !same(input.source.assets.map(({ + id, + path, + sha256 + }) => ({ + id, + path, + sha256 + })), plan.assets)) throw new Error('source or asset bytes changed after confirmation'); + input.source.sourceIdentity = plan.sourceIdentity; // Reconstruct the semantic plan from retained source and its confirmed baseline, never trusting edited operation claims. - const semantic=await planPreparedSite(input,plan.snapshot,options); - if(semantic.confirmationHash!==plan.confirmationHash)throw new Error('confirmed plan operations do not match source and baseline reconciliation'); - const marker=live.siteRecord?.operation; - if(marker && marker.status!=='completed' && (!journal || marker.planHash!==confirmationHash||marker.token!==journal.token)) throw new Error('outstanding site operation blocks apply; preserve its receipt and reconcile explicitly'); - if(!journal && !same(comparable(live),comparable(plan.snapshot))) throw new Error('confirmed before-state changed; prepare a fresh plan'); - if((await call({action:'lock'})).status!=='complete') throw new Error(`site apply lock exists for ${plan.snapshot.target.homeUrl}; preserve confirmed plan, native marker and journal ${journalPath}. Stop all executors and WP-CLI descendants before guarded operator lock reconciliation; locks are never stolen`); + const semantic = await planPreparedSite(input, plan.snapshot, options); + if (semantic.confirmationHash !== plan.confirmationHash) throw new Error('confirmed plan operations do not match source and baseline reconciliation'); + const marker = live.siteRecord?.operation; + if (marker && marker.status !== 'completed' && (!journal || marker.planHash !== confirmationHash || marker.token !== journal.token)) throw new Error('outstanding site operation blocks apply; preserve its receipt and reconcile explicitly'); + if (!journal && !same(comparable(live), comparable(plan.snapshot))) throw new Error('confirmed before-state changed; prepare a fresh plan'); + if ((await call({ + action: 'lock' + })).status !== 'complete') throw new Error(`site apply lock exists for ${plan.snapshot.target.homeUrl}; preserve confirmed plan, native marker and journal ${journalPath}. Stop all executors and WP-CLI descendants before guarded operator lock reconciliation; locks are never stolen`); try { - live=await read(); assertIdentity(); - if(!journal && !same(comparable(live),comparable(plan.snapshot))) throw new Error('confirmed before-state changed while acquiring lock'); - journal ??= {version:1,siteId:plan.siteId,planHash:confirmationHash,token:boundary.token,target:plan.snapshot.target,principalId:plan.snapshot.principal.userId,status:'started',diagnostics:plan.diagnostics,pending:[],steps:[],observed:live,limitation:'Readback detects conflicts; the apply lock only serializes Block Runner applies and cannot exclude human edits or make WordPress hooks, metadata and media effects transactional.'}; - const save=()=>{journal!.pending=plan.operations.filter(op=>op.action!=='retain'&&!journal!.steps.some(item=>item.step.status==='completed'&&item.step.kind===op.kind&&item.step.entityId===op.entityId));return durableWrite(journalPath,journal!);}; - const mark=async(status:'started'|'outcome_unknown'|'completed',index?:{key:string;id:number}):Promise=>{ - const active=journal!.currentStep?journal!.steps.find(item=>item.step.key===journal!.currentStep!.key):undefined; - const evidence=active?{stepEvidence:{operation:active.operation,beforeState:active.beforeState}}:{}; - const beforeMarker=structuredClone(live); - const result=await call({action:'marker',...(index?{index}:{}),operation:{token:boundary.token,planHash:confirmationHash,status,target:boundary.target,principalId:boundary.principal.userId,...evidence,...(journal!.currentStep?{currentStep:journal!.currentStep}:{})}}); - if(result.status!=='complete') throw new Error('target operation marker readback failed'); - live=await read(); assertIdentity(); - beforeMarker.siteRecord ??= {version:1,siteId:plan.siteId,entities:{},options:{},operation:null}; - if(index)beforeMarker.siteRecord.entities[index.key]=index.id; - if(!same(comparable(beforeMarker),comparable(live)))throw new Error('live state changed during marker persistence'); - if(!same(live.siteRecord?.operation,{token:boundary.token,planHash:confirmationHash,status,target:boundary.target,principalId:boundary.principal.userId,...evidence,...(journal!.currentStep?{currentStep:journal!.currentStep}:{})})) throw new Error('target operation authority changed'); + live = await read(); + assertIdentity(); + if (!journal && !same(comparable(live), comparable(plan.snapshot))) throw new Error('confirmed before-state changed while acquiring lock'); + journal ??= { + version: 1, + siteId: plan.siteId, + planHash: confirmationHash, + token: boundary.token, + target: plan.snapshot.target, + principalId: plan.snapshot.principal.userId, + status: 'started', + diagnostics: plan.diagnostics, + pending: [], + steps: [], + observed: live, + limitation: 'Readback detects conflicts; the apply lock only serializes Block Runner applies and cannot exclude human edits or make WordPress hooks, metadata and media effects transactional.' }; - const prove=(record:SiteApplyReceipt['steps'][number], snapshot:SiteSnapshot):SiteEntity|undefined=>{ - const op=record.operation; - if(op.kind==='option') { if(!same(snapshot.options[op.entityId],op.desired?.value)||!same(snapshot.siteRecord?.options[op.entityId]?.lastAppliedValue,op.desired?.value)) throw new Error('option outcome lacks exact proof'); return; } - const candidates=snapshot.entities.filter(entity=>entity.kind===op.kind && (!op.id||entity.id===op.id) && entity.ownership.length===1 && entity.ownership.some(owner=>owner.siteId===plan.siteId&&owner.entityId===op.entityId&&owner.kind===op.kind&&owner.lastAppliedHash===managedStateHash(entity.state)&&(!record.step.assetSha256||owner.assetSha256===record.step.assetSha256))); - const entity=candidates.length===1?candidates[0]:undefined; - if(!entity || !same(entity.state,op.kind==='attachment'?{sha256:record.step.assetSha256}:op.desired)) throw new Error('mutation outcome lacks exact owned managed-state proof'); - if(entity.kind==='page'&&entity.route!==nativePageRoute(entity,snapshot))throw new Error('page route differs from its exact native parent/slug/options state'); - if(entity.kind==='attachment')observedAttachmentBindings(input,snapshot); - if(record.after && !same(entity,record.after)) throw new Error('completed entity changed after readback'); + const save = () => { + journal!.pending = plan.operations.filter(op => op.action !== 'retain' && !journal!.steps.some(item => item.step.status === 'completed' && item.step.kind === op.kind && item.step.entityId === op.entityId)); + return durableWrite(journalPath, journal!); + }; + const mark = async (status: 'started' | 'outcome_unknown' | 'completed', index?: { + key: string; + id: number; + }): Promise => { + const active = journal!.currentStep ? journal!.steps.find(item => item.step.key === journal!.currentStep!.key) : undefined; + const evidence = active ? { + stepEvidence: { + operation: active.operation, + beforeState: active.beforeState + } + } : {}; + const beforeMarker = structuredClone(live); + const result = await call({ + action: 'marker', + ...(index ? { + index + } : {}), + operation: { + token: boundary.token, + planHash: confirmationHash, + status, + target: boundary.target, + principalId: boundary.principal.userId, + ...evidence, + ...(journal!.currentStep ? { + currentStep: journal!.currentStep + } : {}) + } + }); + if (result.status !== 'complete') throw new Error('target operation marker readback failed'); + live = await read(); + assertIdentity(); + beforeMarker.siteRecord ??= { + version: 1, + siteId: plan.siteId, + entities: {}, + options: {}, + operation: null + }; + if (index) beforeMarker.siteRecord.entities[index.key] = index.id; + if (!same(comparable(beforeMarker), comparable(live))) throw new Error('live state changed during marker persistence'); + if (!same(live.siteRecord?.operation, { + token: boundary.token, + planHash: confirmationHash, + status, + target: boundary.target, + principalId: boundary.principal.userId, + ...evidence, + ...(journal!.currentStep ? { + currentStep: journal!.currentStep + } : {}) + })) throw new Error('target operation authority changed'); + }; + const prove = (record: SiteApplyReceipt['steps'][number], snapshot: SiteSnapshot): SiteEntity | undefined => { + const op = record.operation; + if (op.kind === 'option') { + if (!same(snapshot.options[op.entityId], op.desired?.value) || !same(snapshot.siteRecord?.options[op.entityId]?.lastAppliedValue, op.desired?.value)) throw new Error('option outcome lacks exact proof'); + return; + } + const candidates = snapshot.entities.filter(entity => entity.kind === op.kind && (!op.id || entity.id === op.id) && entity.ownership.length === 1 && entity.ownership.some(owner => owner.siteId === plan.siteId && owner.entityId === op.entityId && owner.kind === op.kind && owner.lastAppliedHash === managedStateHash(entity.state) && (!record.step.assetSha256 || owner.assetSha256 === record.step.assetSha256))); + const entity = candidates.length === 1 ? candidates[0] : undefined; + if (!entity || !same(entity.state, op.kind === 'attachment' ? { + sha256: record.step.assetSha256 + } : op.desired)) throw new Error('mutation outcome lacks exact owned managed-state proof'); + if (entity.kind === 'page' && entity.route !== nativePageRoute(entity, snapshot)) throw new Error('page route differs from its exact native parent/slug/options state'); + if (entity.kind === 'attachment') observedAttachmentBindings(input, snapshot); + if (record.after && !same(entity, record.after)) throw new Error('completed entity changed after readback'); return entity; }; // Never repeat a started mutation. Only an exact, unique owned outcome can advance it. - if(journal.currentStep && (journal.currentStep.status!=='completed'||!same(comparable(live),comparable(journal.observed)))) { - const pending=journal.steps.find(item=>item.step.key===journal!.currentStep!.key); - if(!pending) throw new Error('started journal has no exact step evidence'); - try { const entity=prove(pending,live); assertTransition(journal.observed,live,pending.operation,entity); if(entity) pending.after=entity; if(pending.operation.kind==='option'&&(pending.operation.entityId==='show_on_front'||pending.operation.entityId==='page_on_front'))for(const completed of journal.steps){if(completed.step.status==='completed'&&completed.after?.kind==='page'){const actual=live.entities.find(item=>item.kind==='page'&&item.id===completed.after!.id);if(actual)completed.after.route=nativePageRoute(actual,live);}} pending.step.status='completed'; journal.currentStep={...pending.step}; await save(); await mark('started',entity?{key:pending.step.key,id:entity.id}:undefined); journal.observed=live; await save(); } - catch { journal.status='outcome_unknown'; await save(); throw new Error('outcome_unknown: insufficient ownership/readback proof; no mutation may be retried'); } + if (journal.currentStep && (journal.currentStep.status !== 'completed' || !same(comparable(live), comparable(journal.observed)))) { + const pending = journal.steps.find(item => item.step.key === journal!.currentStep!.key); + if (!pending) throw new Error('started journal has no exact step evidence'); + try { + const entity = prove(pending, live); + assertTransition(journal.observed, live, pending.operation, entity); + if (entity) pending.after = entity; + if (pending.operation.kind === 'option' && (pending.operation.entityId === 'show_on_front' || pending.operation.entityId === 'page_on_front')) for (const completed of journal.steps) { + if (completed.step.status === 'completed' && completed.after?.kind === 'page') { + const actual = live.entities.find(item => item.kind === 'page' && item.id === completed.after!.id); + if (actual) completed.after.route = nativePageRoute(actual, live); + } + } + pending.step.status = 'completed'; + journal.currentStep = { + ...pending.step + }; + await save(); + await mark('started', entity ? { + key: pending.step.key, + id: entity.id + } : undefined); + journal.observed = live; + await save(); + } catch { + journal.status = 'outcome_unknown'; + await save(); + throw new Error('outcome_unknown: insufficient ownership/readback proof; no mutation may be retried'); + } + } + for (const completed of journal.steps.filter(item => item.step.status === 'completed')) prove(completed, live); + if (!journal.steps.length && !journal.observed.siteRecord && live.siteRecord) { + const expected = structuredClone(journal.observed); + expected.siteRecord = { + version: 1, + siteId: plan.siteId, + entities: {}, + options: {}, + operation: null + }; + if (!same(comparable(live), comparable(expected))) throw new Error('live before-state changed before the first mutation'); + journal.observed = live; } - for(const completed of journal.steps.filter(item=>item.step.status==='completed')) prove(completed,live); - if(!journal.steps.length && !journal.observed.siteRecord && live.siteRecord){const expected=structuredClone(journal.observed);expected.siteRecord={version:1,siteId:plan.siteId,entities:{},options:{},operation:null};if(!same(comparable(live),comparable(expected)))throw new Error('live before-state changed before the first mutation');journal.observed=live;} - if(!same(comparable(live),comparable(journal.observed))) throw new Error('live state changed since the durable last readback; prepare a fresh plan'); - journal.status='started'; delete journal.error; await save(); await mark('started'); journal.observed=live; await save(); - const perform=async(op:SitePlanOperation):Promise=>{ - const key=op.kind==='attachment'?attachmentVersionKey(op.entityId,String(op.desired?.sha256)):`${op.kind}:${op.entityId}`; - const done=journal!.steps.find(item=>item.step.key===key&&item.step.status==='completed'); if(done){prove(done,live);return;} + if (!same(comparable(live), comparable(journal.observed))) throw new Error('live state changed since the durable last readback; prepare a fresh plan'); + journal.status = 'started'; + delete journal.error; + await save(); + await mark('started'); + journal.observed = live; + await save(); + const perform = async (op: SitePlanOperation): Promise => { + const key = op.kind === 'attachment' ? attachmentVersionKey(op.entityId, String(op.desired?.sha256)) : `${op.kind}:${op.entityId}`; + const done = journal!.steps.find(item => item.step.key === key && item.step.status === 'completed'); + if (done) { + prove(done, live); + return; + } // Retained operations have no effects. Initial and final full observations bind them. - if(op.action==='retain') return; + if (op.action === 'retain') return; // mark() performs the fresh full read and comparison before the effect, after journal persistence. - const refreshed=await readSitePlanInput(options.source,options.assetRoot); - if(!siteSourceIdentityMatches(refreshed.source.sourceIntent,plan.sourceIdentity)||!same(refreshed.source.assets.map(({id,path,sha256})=>({id,path,sha256})),plan.assets)) throw new Error('source bytes changed immediately before mutation'); - if(op.action==='update'){const entity=live.entities.find(entity=>entity.id===op.id);const owner=entity?.ownership.length===1?entity.ownership[0]:undefined;if(!entity||!owner||owner.version!==1||owner.siteId!==plan.siteId||owner.entityId!==op.entityId||owner.kind!==op.kind||owner.lastAppliedHash!==managedStateHash(entity.state))throw new Error('update requires exact existing ownership and full managed baseline');} - const step:SiteApplyStep={key,kind:op.kind,entityId:op.entityId,...(op.kind==='attachment'?{assetSha256:String(op.desired?.sha256)}:{}),status:'started'}; - const record={step,operation:op,beforeState:op.kind==='option'?live.options[op.entityId]:live.entities.find(entity=>entity.id===op.id)??null} as SiteApplyReceipt['steps'][number]; journal!.steps.push(record);journal!.currentStep=step; journal!.status='started';await save();await mark('started'); - const beforeMutation=structuredClone(live); - let attempted=false; - let mediaReadback:SiteSnapshot|undefined; + const refreshed = await readSitePlanInput(options.source, options.assetRoot); + if (!siteSourceIdentityMatches(refreshed.source.sourceIntent, plan.sourceIdentity) || !same(refreshed.source.assets.map(({ + id, + path, + sha256 + }) => ({ + id, + path, + sha256 + })), plan.assets)) throw new Error('source bytes changed immediately before mutation'); + if (op.action === 'update') { + const entity = live.entities.find(entity => entity.id === op.id); + const owner = entity?.ownership.length === 1 ? entity.ownership[0] : undefined; + if (!entity || !owner || owner.version !== 1 || owner.siteId !== plan.siteId || owner.entityId !== op.entityId || owner.kind !== op.kind || owner.lastAppliedHash !== managedStateHash(entity.state)) throw new Error('update requires exact existing ownership and full managed baseline'); + } + const step: SiteApplyStep = { + key, + kind: op.kind, + entityId: op.entityId, + ...(op.kind === 'attachment' ? { + assetSha256: String(op.desired?.sha256) + } : {}), + status: 'started' + }; + const record = { + step, + operation: op, + beforeState: op.kind === 'option' ? live.options[op.entityId] : live.entities.find(entity => entity.id === op.id) ?? null + } as SiteApplyReceipt['steps'][number]; + journal!.steps.push(record); + journal!.currentStep = step; + journal!.status = 'started'; + await save(); + await mark('started'); + const beforeMutation = structuredClone(live); + let attempted = false; + let mediaReadback: SiteSnapshot | undefined; try { - attempted=true; - if(op.kind==='attachment') { - try { const imported=await importPlannedSiteAttachmentStep({plan,source:input.source,assetId:op.entityId,wp:options,authority:{token:boundary.token,journalPath},readTransport:transports.read,mutationTransport:transports.mutate},live);mediaReadback=imported.snapshot; } - catch(error) { if(error instanceof Error && !(error instanceof SiteMediaImportOutcomeUnknown))attempted=false; throw error; } + attempted = true; + if (op.kind === 'attachment') { + try { + const imported = await importPlannedSiteAttachmentStep({ + plan, + source: input.source, + assetId: op.entityId, + wp: options, + authority: { + token: boundary.token, + journalPath + }, + readTransport: transports.read, + mutationTransport: transports.mutate + }, live); + mediaReadback = imported.snapshot; + } catch (error) { + if (error instanceof Error && !(error instanceof SiteMediaImportOutcomeUnknown)) attempted = false; + throw error; + } + } else { + const result = await call({ + action: 'mutate', + step, + operation: op, + before: live, + stateHash: managedStateHash(op.desired!) + }); + if (result.status === 'refused') { + attempted = false; + throw new Error('fixed WordPress boundary refused before mutation'); + } + if (result.status !== 'complete') throw new Error('mutation outcome unknown'); + if (result.id) record.operation = { + ...op, + id: result.id + }; } - else { - const result=await call({action:'mutate',step,operation:op,before:live,stateHash:managedStateHash(op.desired!)}); - if(result.status==='refused'){attempted=false;throw new Error('fixed WordPress boundary refused before mutation');} - if(result.status!=='complete') throw new Error('mutation outcome unknown'); - if(result.id) record.operation={...op,id:result.id}; - } - live=mediaReadback??await read();assertIdentity();const entity=prove(record,live); + live = mediaReadback ?? (await read()); + assertIdentity(); + const entity = prove(record, live); // Media helper returns exact ownership but does not persist the shared site index. - assertTransition(beforeMutation,live,op,entity); - if(entity) record.after=entity;else record.value=live.options[op.entityId]; - if(op.kind==='option')for(const completed of journal!.steps){if(completed.after?.kind==='page'){const actual=live.entities.find(item=>item.id===completed.after!.id);if(actual)completed.after=structuredClone(actual);}} - step.status='completed';journal!.currentStep={...step};await save();await mark('started',entity?{key,id:entity.id}:undefined);journal!.observed=live;await save(); - } catch(error) { if(attempted){step.status='outcome_unknown';journal!.currentStep={...step};journal!.status='outcome_unknown';await save();try{await mark('outcome_unknown');}catch{/* Keep durable uncertainty even when target is unavailable. */}} else { journal!.steps.pop(); delete journal!.currentStep; await save(); await mark('started'); journal!.observed=live; await save(); }throw error; } + assertTransition(beforeMutation, live, op, entity); + if (entity) record.after = entity;else record.value = live.options[op.entityId]; + if (op.kind === 'option') for (const completed of journal!.steps) { + if (completed.after?.kind === 'page') { + const actual = live.entities.find(item => item.id === completed.after!.id); + if (actual) completed.after = structuredClone(actual); + } + } + step.status = 'completed'; + journal!.currentStep = { + ...step + }; + await save(); + await mark('started', entity ? { + key, + id: entity.id + } : undefined); + journal!.observed = live; + await save(); + } catch (error) { + if (attempted) { + step.status = 'outcome_unknown'; + journal!.currentStep = { + ...step + }; + journal!.status = 'outcome_unknown'; + await save(); + try { + await mark('outcome_unknown'); + } catch {/* Keep durable uncertainty even when target is unavailable. */} + } else { + journal!.steps.pop(); + delete journal!.currentStep; + await save(); + await mark('started'); + journal!.observed = live; + await save(); + } + throw error; + } }; - let attachments=observedAttachmentBindings(input,live); - let patternBindings=Object.fromEntries(live.entities.filter(entity=>entity.kind==='pattern'&&entity.ownership.some(owner=>owner.siteId===plan.siteId&&owner.lastAppliedHash===managedStateHash(entity.state))).map(entity=>[entity.ownership[0]!.entityId,entity.id])); - const compileOptions=()=>({attachmentBindings:attachments,patternBindings,themeAssetBaseUrl:live.theme.assetBaseUrl,strict:options.strict}); - const theme=await compileThemeDocuments(input.source,compileOptions()); - if(!theme.ok||!same(theme.artifact.inventory,live.theme.inventory)) throw new Error('theme dependencies must be bound and explicitly deployed before database apply'); - for(const op of plan.operations.filter(op=>op.kind==='attachment')) await perform(op); - attachments=observedAttachmentBindings(input,live); - for(const id of syncedPatternOrder(input.source.sourceIntent)) { - const op=plan.operations.find(op=>op.kind==='pattern'&&op.entityId===id);if(!op)throw new Error('missing planned pattern'); - const result=await compileSiteSyncedPattern(input.source,id,compileOptions()); - const compiled=result.ok?result.pattern:undefined; - if(!compiled?.ok||compiled.content===undefined||compiled.pendingUses.length||compiled.pendingBindings.length) throw new Error('pattern dependency finalization failed'); - const desired={title:compiled.title,content:compiled.content,status:'publish'}; - const existing=op.id?live.entities.find(entity=>entity.id===op.id):undefined; - const {body:_body,pendingBindings:_pendingBindings,pendingPatternUses:_pendingPatternUses,requiresFinalization:_requiresFinalization,...finalizedOp}=op; - await perform({...finalizedOp,desired,action:op.action==='retain'&&!same(existing?.state,desired)?'update':op.action}); - const entity=live.entities.find(entity=>entity.kind==='pattern'&&entity.ownership.some(owner=>owner.siteId===plan.siteId&&owner.entityId===id));if(!entity)throw new Error('pattern readback absent');patternBindings[id]=entity.id; + let attachments = observedAttachmentBindings(input, live); + let patternBindings = Object.fromEntries(live.entities.filter(entity => entity.kind === 'pattern' && entity.ownership.some(owner => owner.siteId === plan.siteId && owner.lastAppliedHash === managedStateHash(entity.state))).map(entity => [entity.ownership[0]!.entityId, entity.id])); + const compileOptions = () => ({ + attachmentBindings: attachments, + patternBindings, + themeAssetBaseUrl: live.theme.assetBaseUrl, + strict: options.strict + }); + const theme = await compileThemeDocuments(input.source, compileOptions()); + if (!theme.ok || !same(theme.artifact.inventory, live.theme.inventory)) throw new Error('theme dependencies must be bound and explicitly deployed before database apply'); + for (const op of plan.operations.filter(op => op.kind === 'attachment')) await perform(op); + attachments = observedAttachmentBindings(input, live); + const patternCompiler = createSiteSyncedPatternCompiler(input.source); + if (!patternCompiler.ok) throw new Error(patternCompiler.diagnostics.map(item => item.reason).join('\n')); + for (const id of syncedPatternOrder(input.source.sourceIntent)) { + const op = plan.operations.find(op => op.kind === 'pattern' && op.entityId === id); + if (!op) throw new Error('missing planned pattern'); + const result = await patternCompiler.compile(id, compileOptions()); + const compiled = result.ok ? result.pattern : undefined; + if (!compiled?.ok || compiled.content === undefined || compiled.pendingUses.length || compiled.pendingBindings.length) throw new Error('pattern dependency finalization failed'); + const desired = { + title: compiled.title, + content: compiled.content, + status: 'publish' + }; + const existing = op.id ? live.entities.find(entity => entity.id === op.id) : undefined; + const { + body: _body, + pendingBindings: _pendingBindings, + pendingPatternUses: _pendingPatternUses, + requiresFinalization: _requiresFinalization, + ...finalizedOp + } = op; + await perform({ + ...finalizedOp, + desired, + action: op.action === 'retain' && !same(existing?.state, desired) ? 'update' : op.action + }); + const entity = live.entities.find(entity => entity.kind === 'pattern' && entity.ownership.some(owner => owner.siteId === plan.siteId && owner.entityId === id)); + if (!entity) throw new Error('pattern readback absent'); + patternBindings[id] = entity.id; + } + const compiled = await compileSite(input.source, compileOptions()); + if (compiled.ok) journal.diagnostics = JSON.parse(JSON.stringify([...plan.diagnostics, ...compiled.site.diagnostics])); + if (!compiled.ok || !same(compiled.site.inventory, live.theme.inventory)) throw new Error('complete bound site gate or installed theme byte equality failed; dependency effects remain journaled'); + const ids = new Map(live.entities.filter(entity => entity.kind === 'page' && entity.ownership.some(owner => owner.siteId === plan.siteId)).map(entity => [entity.ownership[0]!.entityId, entity.id])); + for (const op of plan.operations.filter(op => op.kind === 'page' && !op.desired?.orphaned)) { + const page = compiled.site.pages.find(page => page.id === op.entityId); + if (!page) throw new Error('compiled page absent'); + const parent = page.parentId ? ids.get(page.parentId) : 0; + if (parent === undefined) throw new Error('page parent dependency absent'); + const desired = { + title: page.title, + slug: page.route === '/' ? page.id : page.route.split('/').at(-1)!, + parentId: parent, + template: page.template, + status: page.status, + content: page.content + }; + const existing = op.id ? live.entities.find(entity => entity.id === op.id) : undefined; + const { + body: _body, + pendingBindings: _pendingBindings, + pendingPatternUses: _pendingPatternUses, + requiresFinalization: _requiresFinalization, + ...finalizedOp + } = op; + await perform({ + ...finalizedOp, + desired, + action: op.action === 'retain' && !same(existing?.state, desired) ? 'update' : op.action + }); + const entity = live.entities.find(entity => entity.kind === 'page' && entity.ownership.some(owner => owner.siteId === plan.siteId && owner.entityId === page.id)); + if (!entity) throw new Error('page readback absent'); + ids.set(page.id, entity.id); } - const compiled=await compileSite(input.source,compileOptions()); - if(compiled.ok)journal.diagnostics=JSON.parse(JSON.stringify([...plan.diagnostics,...compiled.site.diagnostics])); - if(!compiled.ok||!same(compiled.site.inventory,live.theme.inventory)) throw new Error('complete bound site gate or installed theme byte equality failed; dependency effects remain journaled'); - const ids=new Map(live.entities.filter(entity=>entity.kind==='page'&&entity.ownership.some(owner=>owner.siteId===plan.siteId)).map(entity=>[entity.ownership[0]!.entityId,entity.id])); - for(const op of plan.operations.filter(op=>op.kind==='page'&&!op.desired?.orphaned)) { - const page=compiled.site.pages.find(page=>page.id===op.entityId);if(!page)throw new Error('compiled page absent'); - const parent=page.parentId?ids.get(page.parentId):0;if(parent===undefined)throw new Error('page parent dependency absent'); - const desired={title:page.title,slug:page.route==='/'?page.id:page.route.split('/').at(-1)!,parentId:parent,template:page.template,status:page.status,content:page.content}; - const existing=op.id?live.entities.find(entity=>entity.id===op.id):undefined; - const {body:_body,pendingBindings:_pendingBindings,pendingPatternUses:_pendingPatternUses,requiresFinalization:_requiresFinalization,...finalizedOp}=op; - await perform({...finalizedOp,desired,action:op.action==='retain'&&!same(existing?.state,desired)?'update':op.action}); - const entity=live.entities.find(entity=>entity.kind==='page'&&entity.ownership.some(owner=>owner.siteId===plan.siteId&&owner.entityId===page.id));if(!entity)throw new Error('page readback absent');ids.set(page.id,entity.id); + for (const op of plan.operations.filter(op => op.kind === 'option')) { + let value = op.desired?.value; + if (value && typeof value === 'object' && 'pendingPageId' in value) value = ids.get(String(value.pendingPageId)); + if (value === undefined) throw new Error('option page binding absent'); + await perform({ + ...op, + desired: { + value + } + }); } - for(const op of plan.operations.filter(op=>op.kind==='option')) { - let value=op.desired?.value;if(value&&typeof value==='object'&&'pendingPageId' in value)value=ids.get(String(value.pendingPageId));if(value===undefined)throw new Error('option page binding absent');await perform({...op,desired:{value}}); + live = await read(); + assertIdentity(); + if (!same(comparable(live), comparable(journal.observed))) throw new Error('live state changed before completion; prepare a fresh plan'); + const finalSource = await readSitePlanInput(options.source, options.assetRoot); + if (!siteSourceIdentityMatches(finalSource.source.sourceIntent, plan.sourceIdentity) || !same(finalSource.source.assets.map(({ + id, + path, + sha256 + }) => ({ + id, + path, + sha256 + })), plan.assets)) throw new Error('source bytes changed before completion'); + for (const record of journal.steps) prove(record, live); + for (const page of compiled.site.pages) { + const entity = live.entities.find(entity => entity.id === ids.get(page.id)); + if (!entity || entity.route !== page.route) throw new Error('actual page route differs from confirmed route'); } - live=await read();assertIdentity(); - if(!same(comparable(live),comparable(journal.observed)))throw new Error('live state changed before completion; prepare a fresh plan'); - const finalSource=await readSitePlanInput(options.source,options.assetRoot); - if(!siteSourceIdentityMatches(finalSource.source.sourceIntent,plan.sourceIdentity)||!same(finalSource.source.assets.map(({id,path,sha256})=>({id,path,sha256})),plan.assets))throw new Error('source bytes changed before completion'); - for(const record of journal.steps)prove(record,live); - for(const page of compiled.site.pages){const entity=live.entities.find(entity=>entity.id===ids.get(page.id));if(!entity||entity.route!==page.route)throw new Error('actual page route differs from confirmed route');} - delete journal.currentStep;await save();await mark('completed');journal.status='completed';journal.observed=live;await save();return journal; - } catch(error) { - if(!journal) throw error; - if(journal.status!=='outcome_unknown')journal.status='partial';journal.error=error instanceof Error?error.message:'apply failed';await durableWrite(journalPath,journal);return journal; + delete journal.currentStep; + await save(); + await mark('completed'); + journal.status = 'completed'; + journal.observed = live; + await save(); + return journal; + } catch (error) { + if (!journal) throw error; + if (journal.status !== 'outcome_unknown') journal.status = 'partial'; + journal.error = error instanceof Error ? error.message : 'apply failed'; + await durableWrite(journalPath, journal); + return journal; } finally { - try { if((await call({action:'unlock'})).status!=='complete') throw new Error('lock release refused'); if(journal){journal.lockRelease='released';await durableWrite(journalPath,journal);} } - catch { if(journal){journal.lockRelease='unconfirmed';if(journal.status==='completed')journal.status='partial';journal.error='token-owned lock release is unconfirmed; explicit operator reconciliation is required';await durableWrite(journalPath,journal);} } + try { + if ((await call({ + action: 'unlock' + })).status !== 'complete') throw new Error('lock release refused'); + if (journal) { + journal.lockRelease = 'released'; + await durableWrite(journalPath, journal); + } + } catch { + if (journal) { + journal.lockRelease = 'unconfirmed'; + if (journal.status === 'completed') journal.status = 'partial'; + journal.error = 'token-owned lock release is unconfirmed; explicit operator reconciliation is required'; + await durableWrite(journalPath, journal); + } + } } } -async function safePath(file:string,allowMissing=false):Promise{let cursor=path.parse(file).root;for(const part of file.slice(cursor.length).split(path.sep).filter(Boolean)){cursor=path.join(cursor,part);try{if((await lstat(cursor)).isSymbolicLink())throw new Error('journal path cannot contain symlinks');}catch(error){if(allowMissing&&cursor===file&&(error as NodeJS.ErrnoException).code==='ENOENT')return;throw error;}}} -async function durableWrite(file:string,value:SiteApplyReceipt):Promise{ - await safePath(file,true);const temp=`${file}.${randomUUID()}.tmp`;const handle=await open(temp,'wx',0o600); - try{await handle.writeFile(`${JSON.stringify(value,null,2)}\n`);await handle.sync();}finally{await handle.close();} - await rename(temp,file);const directory=await open(path.dirname(file),'r');try{await directory.sync();}finally{await directory.close();} +async function safePath(file: string, allowMissing = false): Promise { + let cursor = path.parse(file).root; + for (const part of file.slice(cursor.length).split(path.sep).filter(Boolean)) { + cursor = path.join(cursor, part); + try { + if ((await lstat(cursor)).isSymbolicLink()) throw new Error('journal path cannot contain symlinks'); + } catch (error) { + if (allowMissing && cursor === file && (error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + } +} +async function durableWrite(file: string, value: SiteApplyReceipt): Promise { + await safePath(file, true); + const temp = `${file}.${randomUUID()}.tmp`; + const handle = await open(temp, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temp, file); + const directory = await open(path.dirname(file), 'r'); + try { + await directory.sync(); + } finally { + await directory.close(); + } } diff --git a/src/site/plan.ts b/src/site/plan.ts index 22d992b..717ef2a 100644 --- a/src/site/plan.ts +++ b/src/site/plan.ts @@ -1,4 +1,4 @@ -import { compileThemeDocuments, compileSitePageDocuments, compileSiteSyncedPatterns } from './compile.js'; +import { compileThemeDocuments, compileSitePageDocuments } from './compile.js'; import { readSiteSnapshot, type SiteWpCliOptions, type SiteReadTransport } from './wpcli.js'; import { lstat } from 'node:fs/promises'; import path from 'node:path'; @@ -10,30 +10,37 @@ import type { SiteEntity, SitePlan, SitePlanOperation, SiteSnapshot } from './pl import { attachmentVersionKey } from './media.js'; import { siteRecordIndexesEntity } from './ownership.js'; import type { CompiledSyncedPattern } from './patterns.js'; - -export function managedStateHash(state: Record): string { return hashCanonicalJson(state); } -export function entityFingerprint(entity: SiteEntity): string { return hashCanonicalJson(entity); } +export function managedStateHash(state: Record): string { + return hashCanonicalJson(state); +} +export function entityFingerprint(entity: SiteEntity): string { + return hashCanonicalJson(entity); +} /** Pure reconciliation. It never adopts a numeric entity by title, route, or filename. */ export function reconcileSitePlan(source: PreparedSiteSource, theme: ThemeArtifact, pages: CompiledSitePage[], snapshot: SiteSnapshot, bindings: Record = {}, previewDiagnostics: import('../types.js').ReportItem[] = [], compiledPatterns: CompiledSyncedPattern[] = [], patternBindings: Record = {}): SitePlan { const conflicts: SiteInputDiagnostic[] = []; // Reports contain optional fields set to undefined; retain their JSON representation in plans. - const diagnostics = JSON.parse(JSON.stringify([...theme.diagnostics, ...pages.flatMap(page=>page.diagnostics), ...previewDiagnostics])) as import('../types.js').ReportItem[]; + const diagnostics = JSON.parse(JSON.stringify([...theme.diagnostics, ...pages.flatMap(page => page.diagnostics), ...previewDiagnostics])) as import('../types.js').ReportItem[]; const operations: SitePlanOperation[] = []; - const issue = (path: string, code: string, message: string): void => { conflicts.push({ path, code, message }); }; + const issue = (path: string, code: string, message: string): void => { + conflicts.push({ + path, + code, + message + }); + }; const intent = source.sourceIntent; if (!/^7\.1(?:\.|$)/.test(snapshot.runtime.wordpress)) issue('/target/runtime', 'runtime-mismatch', 'WordPress 7.1 is required'); if (snapshot.theme.slug !== intent.theme.slug) issue('/target/theme', 'theme-mismatch', 'activate the explicitly deployed intended theme before planning'); - if (hashCanonicalJson([...snapshot.theme.inventory].sort((a,b)=>a.path.localeCompare(b.path))) !== hashCanonicalJson([...theme.inventory].sort((a,b)=>a.path.localeCompare(b.path)))) issue('/target/theme/inventory', 'theme-bytes-mismatch', 'installed theme files differ from the independently compiled theme artifact'); + if (hashCanonicalJson([...snapshot.theme.inventory].sort((a, b) => a.path.localeCompare(b.path))) !== hashCanonicalJson([...theme.inventory].sort((a, b) => a.path.localeCompare(b.path)))) issue('/target/theme/inventory', 'theme-bytes-mismatch', 'installed theme files differ from the independently compiled theme artifact'); const capabilities = ['edit_pages', 'edit_others_pages', 'manage_options', 'edit_theme_options']; const patternDefinitions = Object.keys(intent.syncedPatterns?.definitions ?? {}); if (patternDefinitions.length > 0) { capabilities.push('edit_posts', 'edit_others_posts'); for (const id of patternDefinitions) { - const existing = snapshot.entities.find((entity) => entity.kind === 'pattern' && entity.ownership.some((owner) => - owner.siteId === intent.siteId && owner.kind === 'pattern' && owner.entityId === id)); - if (existing?.state.status === 'publish') capabilities.push('edit_published_posts'); - else capabilities.push('publish_posts', 'publish_blocks'); + const existing = snapshot.entities.find(entity => entity.kind === 'pattern' && entity.ownership.some(owner => owner.siteId === intent.siteId && owner.kind === 'pattern' && owner.entityId === id)); + if (existing?.state.status === 'publish') capabilities.push('edit_published_posts');else capabilities.push('publish_posts', 'publish_blocks'); } } if (intent.pages.some(page => page.status === 'publish')) capabilities.push('publish_pages'); @@ -41,16 +48,24 @@ export function reconcileSitePlan(source: PreparedSiteSource, theme: ThemeArtifa if (!Number.isInteger(snapshot.principal.userId) || snapshot.principal.userId < 1) issue('/target/principal', 'unauthenticated-principal', 'select an authenticated WordPress principal with --wp-user or WP-CLI configuration'); for (const capability of new Set(capabilities)) if (snapshot.principal.capabilities[capability] !== true) issue('/target/principal', 'missing-capability', `selected principal requires ${capability}`); if (snapshot.siteRecord?.operation && snapshot.siteRecord.operation.status !== 'completed') issue('/target/operations', 'outstanding-operation', 'reconcile the outstanding started or unknown operation before producing a fresh plan'); - if (snapshot.siteRecord && (snapshot.siteRecord.version !== 1 || snapshot.siteRecord.siteId !== intent.siteId)) issue('/target/siteRecord', 'invalid-site-record', 'site operation record identity is inconsistent'); for (const override of snapshot.overrides) if (override.theme.includes(snapshot.theme.slug)) issue(`/target/overrides/${override.id}`, 'theme-shadow', 'database template or part shadows the installed theme; resolve it explicitly'); - const trees = [ - ...Object.entries(intent.templates).map(([id, value]) => ({ id: `template:${id}`, body: value.body })), - ...Object.entries(intent.parts).map(([id, value]) => ({ id: `part:${id}`, body: value.body })), - ...Object.entries(intent.patterns).map(([id, value]) => ({ id: `pattern:${id}`, body: value.body })), - ...Object.entries(intent.syncedPatterns?.definitions ?? {}).map(([id, value]) => ({ id: `synced-pattern:${id}`, body: value.body })), - ...intent.pages.map((page) => ({ id: `page:${page.id}`, body: page.body })), - ]; + const trees = [...Object.entries(intent.templates).map(([id, value]) => ({ + id: `template:${id}`, + body: value.body + })), ...Object.entries(intent.parts).map(([id, value]) => ({ + id: `part:${id}`, + body: value.body + })), ...Object.entries(intent.patterns).map(([id, value]) => ({ + id: `pattern:${id}`, + body: value.body + })), ...Object.entries(intent.syncedPatterns?.definitions ?? {}).map(([id, value]) => ({ + id: `synced-pattern:${id}`, + body: value.body + })), ...intent.pages.map(page => ({ + id: `page:${page.id}`, + body: page.body + }))]; const visit = (nodes: import('../types.js').IntentNode[], documentId: string, parent: number[] = []): void => { for (const [index, node] of nodes.entries()) { const at = [...parent, index]; @@ -70,91 +85,130 @@ export function reconcileSitePlan(source: PreparedSiteSource, theme: ThemeArtifa const ownership = entity.ownership[0]; if (!ownership || ownership.siteId !== intent.siteId) continue; const baselineMatches = ownership.lastAppliedHash === managedStateHash(entity.state); - if (ownership.version !== 1 || ownership.kind !== entity.kind || !ownership.entityId || !ownership.lastAppliedHash - || (entity.kind !== 'attachment' && ownership.assetSha256 !== undefined) - || (ownership.assetSha256 !== undefined && !/^[a-f0-9]{64}$/.test(ownership.assetSha256))) issue(`/target/entities/${entity.id}`, 'invalid-ownership', 'ownership metadata is incomplete or inconsistent'); - else if (!baselineMatches) issue(`/target/entities/${entity.id}`, 'managed-state-changed', 'current managed fields differ from the complete last-applied baseline'); + if (ownership.version !== 1 || ownership.kind !== entity.kind || !ownership.entityId || !ownership.lastAppliedHash || entity.kind !== 'attachment' && ownership.assetSha256 !== undefined || ownership.assetSha256 !== undefined && !/^[a-f0-9]{64}$/.test(ownership.assetSha256)) issue(`/target/entities/${entity.id}`, 'invalid-ownership', 'ownership metadata is incomplete or inconsistent');else if (!baselineMatches) issue(`/target/entities/${entity.id}`, 'managed-state-changed', 'current managed fields differ from the complete last-applied baseline'); if (entity.kind === 'attachment') { if (entity.postStatus !== 'inherit') issue(`/target/entities/${entity.id}`, 'unavailable-owned-attachment', 'only inherited attachments are available for exact owned media reuse'); const sha256 = ownership.assetSha256 ?? (baselineMatches && typeof entity.state.sha256 === 'string' ? entity.state.sha256 : undefined); - if (!sha256 || !/^[a-f0-9]{64}$/.test(sha256) || (ownership.assetSha256 !== undefined && entity.state.sha256 !== sha256)) { + if (!sha256 || !/^[a-f0-9]{64}$/.test(sha256) || ownership.assetSha256 !== undefined && entity.state.sha256 !== sha256) { issue(`/target/entities/${entity.id}`, 'invalid-attachment-version', 'owned attachment has no verified original-byte version'); continue; } const key = attachmentVersionKey(ownership.entityId, sha256); - if (owned.has(key)) issue(`/target/entities/${entity.id}`, 'duplicate-ownership', `duplicate owned attachment version ${key}`); - else owned.set(key, entity); + if (owned.has(key)) issue(`/target/entities/${entity.id}`, 'duplicate-ownership', `duplicate owned attachment version ${key}`);else owned.set(key, entity); } else { const key = `${ownership.kind}:${ownership.entityId}`; - if (owned.has(key)) issue(`/target/entities/${entity.id}`, 'duplicate-ownership', `duplicate logical ownership key ${key}`); - else owned.set(key, entity); + if (owned.has(key)) issue(`/target/entities/${entity.id}`, 'duplicate-ownership', `duplicate logical ownership key ${key}`);else owned.set(key, entity); } } - for (const [key, entity] of owned) { if (!siteRecordIndexesEntity(snapshot, intent.siteId, entity)) issue(`/target/entities/${entity.id}`, 'ownership-record-missing', `owned entity ${key} has no matching site-record ID`); } for (const [key, id] of Object.entries(snapshot.siteRecord?.entities ?? {})) { const legacyMatch = /^attachment:([^@]+)$/.exec(key); - const entity = owned.get(key) ?? (legacyMatch ? snapshot.entities.find(item => item.kind === 'attachment' && item.id === id - && item.ownership.some(meta => meta.siteId === intent.siteId && meta.entityId === legacyMatch[1] && meta.assetSha256 === undefined - && meta.lastAppliedHash === managedStateHash(item.state))) : undefined); + const entity = owned.get(key) ?? (legacyMatch ? snapshot.entities.find(item => item.kind === 'attachment' && item.id === id && item.ownership.some(meta => meta.siteId === intent.siteId && meta.entityId === legacyMatch[1] && meta.assetSha256 === undefined && meta.lastAppliedHash === managedStateHash(item.state))) : undefined); if (!entity || entity.id !== id) issue(`/target/entities/${id}`, 'missing-owned-id', `previously owned entity ${key} is missing or its metadata changed`); } const desiredKeys = new Set(); // Attachments precede pages; new bindings remain logical pending references. - for (const asset of source.assets.filter(asset => asset.role === 'attachment').sort((a,b) => a.id.localeCompare(b.id))) { - const key = attachmentVersionKey(asset.id, asset.sha256); desiredKeys.add(key); + for (const asset of source.assets.filter(asset => asset.role === 'attachment').sort((a, b) => a.id.localeCompare(b.id))) { + const key = attachmentVersionKey(asset.id, asset.sha256); + desiredKeys.add(key); const existing = owned.get(key); const external = !existing && bindings[asset.id]; - const boundEntity = external ? snapshot.entities.find(entity=>entity.id===external.id && entity.kind==='attachment') : undefined; - if (external && (!boundEntity || boundEntity.state.sha256 !== asset.sha256 || boundEntity.attachment?.url !== external.url - || !boundEntity.ownership.some(meta=>meta.siteId===intent.siteId && meta.entityId===asset.id - && (meta.assetSha256 ?? (meta.lastAppliedHash === managedStateHash(boundEntity.state) ? boundEntity.state.sha256 : undefined)) === asset.sha256))) { + const boundEntity = external ? snapshot.entities.find(entity => entity.id === external.id && entity.kind === 'attachment') : undefined; + if (external && (!boundEntity || boundEntity.state.sha256 !== asset.sha256 || boundEntity.attachment?.url !== external.url || !boundEntity.ownership.some(meta => meta.siteId === intent.siteId && meta.entityId === asset.id && (meta.assetSha256 ?? (meta.lastAppliedHash === managedStateHash(boundEntity.state) ? boundEntity.state.sha256 : undefined)) === asset.sha256))) { issue(`/assets/${asset.id}`, 'binding-target-mismatch', 'attachment binding does not match exact owned destination identity and bytes'); } - const desired = { sha256: asset.sha256, assetSha256: asset.sha256 }; - operations.push(existing - ? { action: 'retain', kind: 'attachment', entityId: asset.id, id: existing.id, desired, beforeFingerprint: entityFingerprint(existing) } - : external ? { action: 'retain', kind: 'attachment', entityId: asset.id, id: external.id, desired, externalBinding: true, ...(boundEntity ? { beforeFingerprint: entityFingerprint(boundEntity) } : {}) } - : { action: 'create', kind: 'attachment', entityId: asset.id, desired }); + const desired = { + sha256: asset.sha256, + assetSha256: asset.sha256 + }; + operations.push(existing ? { + action: 'retain', + kind: 'attachment', + entityId: asset.id, + id: existing.id, + desired, + beforeFingerprint: entityFingerprint(existing) + } : external ? { + action: 'retain', + kind: 'attachment', + entityId: asset.id, + id: external.id, + desired, + externalBinding: true, + ...(boundEntity ? { + beforeFingerprint: entityFingerprint(boundEntity) + } : {}) + } : { + action: 'create', + kind: 'attachment', + entityId: asset.id, + desired + }); } for (const pattern of compiledPatterns) { - const key = `pattern:${pattern.id}`; desiredKeys.add(key); + const key = `pattern:${pattern.id}`; + desiredKeys.add(key); const existing = owned.get(key); const ready = pattern.ok && pattern.content !== undefined && pattern.pendingUses.length === 0 && pattern.pendingBindings.length === 0; - const desired: Record = { title: pattern.title, ...(ready ? { content: pattern.content } : {}), status: 'publish' }; + const desired: Record = { + title: pattern.title, + ...(ready ? { + content: pattern.content + } : {}), + status: 'publish' + }; const same = existing && ready && hashCanonicalJson(existing.state) === hashCanonicalJson(desired); - operations.push({ action: existing ? (same ? 'retain' : 'update') : 'create', kind: 'pattern', entityId: pattern.id, - ...(existing ? { id: existing.id, beforeFingerprint: entityFingerprint(existing) } : {}), desired, - ...(!ready ? { requiresFinalization: true, body: pattern.body, ...(pattern.pendingUses.length ? { pendingPatternUses: pattern.pendingUses } : {}), ...(pattern.pendingBindings.length ? { pendingBindings: pattern.pendingBindings } : {}) } : {}), + operations.push({ + action: existing ? same ? 'retain' : 'update' : 'create', + kind: 'pattern', + entityId: pattern.id, + ...(existing ? { + id: existing.id, + beforeFingerprint: entityFingerprint(existing) + } : {}), + desired, + ...(!ready ? { + requiresFinalization: true, + body: pattern.body, + ...(pattern.pendingUses.length ? { + pendingPatternUses: pattern.pendingUses + } : {}), + ...(pattern.pendingBindings.length ? { + pendingBindings: pattern.pendingBindings + } : {}) + } : {}) }); for (const diagnostic of pattern.diagnostics) { const serialized = JSON.stringify(diagnostic); - if (!diagnostics.some((item) => JSON.stringify(item) === serialized)) diagnostics.push(diagnostic); + if (!diagnostics.some(item => JSON.stringify(item) === serialized)) diagnostics.push(diagnostic); if (diagnostic.status === 'invalid') issue(`/syncedPatterns/definitions/${pattern.id}`, diagnostic.code ?? 'synced-pattern-invalid', diagnostic.reason); } } - const pageIds = new Map([...owned.entries()].filter(([key]) => key.startsWith('page:')).map(([key,entity]) => [key.slice(5), entity.id])); - const compiled = new Map(pages.map(page => [page.id,page])); - const orderedPages = [...intent.pages].sort((a,b) => depth(a.id) - depth(b.id) || a.id.localeCompare(b.id)); - function depth(id: string): number { const page = intent.pages.find(item=>item.id===id); return page?.parentId ? 1 + depth(page.parentId) : 0; } + const pageIds = new Map([...owned.entries()].filter(([key]) => key.startsWith('page:')).map(([key, entity]) => [key.slice(5), entity.id])); + const compiled = new Map(pages.map(page => [page.id, page])); + const orderedPages = [...intent.pages].sort((a, b) => depth(a.id) - depth(b.id) || a.id.localeCompare(b.id)); + function depth(id: string): number { + const page = intent.pages.find(item => item.id === id); + return page?.parentId ? 1 + depth(page.parentId) : 0; + } for (const page of orderedPages) { - const key = `page:${page.id}`; desiredKeys.add(key); + const key = `page:${page.id}`; + desiredKeys.add(key); const parentRoute = page.parentId ? intent.pages.find(candidate => candidate.id === page.parentId)?.route : '/'; - const routePrefix = page.route === '/' ? '/' : (page.route.slice(0, page.route.lastIndexOf('/')) || '/'); + const routePrefix = page.route === '/' ? '/' : page.route.slice(0, page.route.lastIndexOf('/')) || '/'; if (routePrefix !== parentRoute) issue(`/pages/${page.id}/route`, 'route-parent-mismatch', 'route prefix must match the explicitly declared parent route; inferred parents are unsupported'); const existing = owned.get(key); if (page.route !== '/') { - const resolved = snapshot.routing.routes.find(route=>route.route===page.route); - if (!resolved || resolved.kind==='unsupported') issue(`/pages/${page.id}/route`, 'unsupported-route', resolved?.reason ?? 'route has no native resolution snapshot'); - else if (resolved.kind==='reserved' || (resolved.kind==='singular' && resolved.postId!==existing?.id)) issue(`/pages/${page.id}/route`, 'route-conflict', 'route resolves to a reserved endpoint or an existing unowned post/entity'); + const resolved = snapshot.routing.routes.find(route => route.route === page.route); + if (!resolved || resolved.kind === 'unsupported') issue(`/pages/${page.id}/route`, 'unsupported-route', resolved?.reason ?? 'route has no native resolution snapshot');else if (resolved.kind === 'reserved' || resolved.kind === 'singular' && resolved.postId !== existing?.id) issue(`/pages/${page.id}/route`, 'route-conflict', 'route resolves to a reserved endpoint or an existing unowned post/entity'); } const occupied = snapshot.entities.find(entity => entity.kind === 'page' && normalizeRoute(entity.route) === normalizeRoute(page.route) && entity.id !== existing?.id); if (occupied) issue(`/pages/${page.id}/route`, 'route-conflict', `route is occupied by page ID ${occupied.id}; no adoption is permitted`); const pendingBindings = intent.assetUses.filter(use => { if (use.documentId !== key || bindings[use.assetId]) return false; - const asset = source.assets.find(candidate=>candidate.id===use.assetId && candidate.role==='attachment'); + const asset = source.assets.find(candidate => candidate.id === use.assetId && candidate.role === 'attachment'); return Boolean(asset && !owned.has(attachmentVersionKey(asset.id, asset.sha256))); }); const output = compiled.get(page.id); @@ -162,35 +216,102 @@ export function reconcileSitePlan(source: PreparedSiteSource, theme: ThemeArtifa if (!output && pendingBindings.length === 0 && pendingPatternUses.length === 0) issue(`/pages/${page.id}/body`, 'uncompiled-page', 'page has no finalized content and no declared pending bindings'); const pendingParent = page.parentId && !pageIds.has(page.parentId); const desired: Record = { - title: page.title, slug: page.route === '/' ? page.id : page.route.split('/').at(-1)!, - parentId: page.parentId ? (pageIds.get(page.parentId) ?? { pendingPageId: page.parentId }) : 0, - template: page.template, status: page.status ?? 'draft', - ...(output && pendingBindings.length === 0 && pendingPatternUses.length === 0 ? { content: output.content } : {}), + title: page.title, + slug: page.route === '/' ? page.id : page.route.split('/').at(-1)!, + parentId: page.parentId ? pageIds.get(page.parentId) ?? { + pendingPageId: page.parentId + } : 0, + template: page.template, + status: page.status ?? 'draft', + ...(output && pendingBindings.length === 0 && pendingPatternUses.length === 0 ? { + content: output.content + } : {}) }; const needsFinalization = pendingBindings.length > 0 || pendingPatternUses.length > 0 || Boolean(pendingParent); const same = existing && !needsFinalization && hashCanonicalJson(existing.state) === hashCanonicalJson(desired); - operations.push({ action: existing ? (same ? 'retain' : 'update') : 'create', kind: 'page', entityId: page.id, - ...(existing ? { id: existing.id, beforeFingerprint: entityFingerprint(existing) } : {}), desired, - ...(needsFinalization ? { requiresFinalization: true } : {}), - ...(pendingBindings.length ? { pendingBindings, body: page.body } : {}), - ...(pendingPatternUses.length ? { pendingPatternUses, body: page.body } : {}), + operations.push({ + action: existing ? same ? 'retain' : 'update' : 'create', + kind: 'page', + entityId: page.id, + ...(existing ? { + id: existing.id, + beforeFingerprint: entityFingerprint(existing) + } : {}), + desired, + ...(needsFinalization ? { + requiresFinalization: true + } : {}), + ...(pendingBindings.length ? { + pendingBindings, + body: page.body + } : {}), + ...(pendingPatternUses.length ? { + pendingPatternUses, + body: page.body + } : {}) }); } - for (const [key, entity] of [...owned.entries()].sort(([a],[b])=>a.localeCompare(b))) { - if (!desiredKeys.has(key)) operations.push({ action: 'retain', kind: entity.kind, entityId: entity.ownership[0]!.entityId, id: entity.id, beforeFingerprint: entityFingerprint(entity), desired: { orphaned: true } }); + for (const [key, entity] of [...owned.entries()].sort(([a], [b]) => a.localeCompare(b))) { + if (!desiredKeys.has(key)) operations.push({ + action: 'retain', + kind: entity.kind, + entityId: entity.ownership[0]!.entityId, + id: entity.id, + beforeFingerprint: entityFingerprint(entity), + desired: { + orphaned: true + } + }); } - const homeId = pageIds.get(intent.homePageId) ?? { pendingPageId: intent.homePageId }; - for (const [name,value] of Object.entries({ show_on_front: 'page', page_on_front: homeId })) { + const homeId = pageIds.get(intent.homePageId) ?? { + pendingPageId: intent.homePageId + }; + for (const [name, value] of Object.entries({ + show_on_front: 'page', + page_on_front: homeId + })) { const baseline = snapshot.siteRecord?.options[name]; if (baseline && hashCanonicalJson(baseline.lastAppliedValue) !== hashCanonicalJson(snapshot.options[name])) issue(`/target/options/${name}`, 'managed-option-changed', 'managed option changed since last apply'); - if (hashCanonicalJson(snapshot.options[name]) !== hashCanonicalJson(value)) operations.push({ action: 'set-option', kind: 'option', entityId: name, beforeFingerprint: hashCanonicalJson(snapshot.options[name]), desired: { value, previousValue: snapshot.options[name] } }); + if (hashCanonicalJson(snapshot.options[name]) !== hashCanonicalJson(value)) operations.push({ + action: 'set-option', + kind: 'option', + entityId: name, + beforeFingerprint: hashCanonicalJson(snapshot.options[name]), + desired: { + value, + previousValue: snapshot.options[name] + } + }); } - const body = { version: 1 as const, siteId: intent.siteId, sourceIdentity: source.sourceIdentity, artifactIdentity: theme.artifactIdentity, - assets: source.assets.map(({id,path,sha256})=>({id,path,sha256})), snapshot, snapshotFingerprint: hashCanonicalJson(snapshot), operations, conflicts, diagnostics, executable: conflicts.length === 0 }; - return { ...body, confirmationHash: hashCanonicalJson(body) }; + const body = { + version: 1 as const, + siteId: intent.siteId, + sourceIdentity: source.sourceIdentity, + artifactIdentity: theme.artifactIdentity, + assets: source.assets.map(({ + id, + path, + sha256 + }) => ({ + id, + path, + sha256 + })), + snapshot, + snapshotFingerprint: hashCanonicalJson(snapshot), + operations, + conflicts, + diagnostics, + executable: conflicts.length === 0 + }; + return { + ...body, + confirmationHash: hashCanonicalJson(body) + }; +} +function normalizeRoute(route: string | null): string { + return (route ?? '').replace(/\/$/, '').toLowerCase(); } -function normalizeRoute(route: string | null): string { return (route ?? '').replace(/\/$/, '').toLowerCase(); } - export interface SitePlanInput { source: PreparedSiteSource; retainedBindings: Record; @@ -209,54 +330,89 @@ export async function readSitePlanInput(inputPath: string, assetRoot?: string): const file = await readStableRegularFile(input, 'site intent changed while reading'); const prepared = await prepareSiteSource(JSON.parse(file.content.toString('utf8')), assetRoot); if (!prepared.ok) throw new Error(prepared.diagnostics.map(item => `${item.path}: ${item.message}`).join('\n')); - return { source: prepared.source, retainedBindings: {} }; + return { + source: prepared.source, + retainedBindings: {} + }; } if (!info.isDirectory()) throw new Error('site plan input must be a regular source file or bundle directory'); const read = async (relative: string): Promise => { if (!relative || relative.includes('\\') || relative.includes('\0') || relative.split('/').some(component => !component || component === '.' || component === '..') || path.isAbsolute(relative)) throw new Error('unsafe retained bundle path'); - const file = path.join(input, ...relative.split('/')); await assertSafePath(file); + const file = path.join(input, ...relative.split('/')); + await assertSafePath(file); return (await readStableRegularFile(file, 'retained bundle file changed while reading')).content; }; const manifestBytes = await read('site.manifest.json'); const manifest = JSON.parse(manifestBytes.toString('utf8')) as { - formatVersion: number; siteId: string; sourceIdentity: string; - artifactIdentity?: string; sourceIntentState?: string; - sourceAssets: Array<{id:string;path:string;sha256:string;size:number;kind:string;role:string}>; - bundleFiles: Array<{path:string;sha256:string;size:number}>; - targetBindings: Record; themeAssetBaseUrl?: string; + formatVersion: number; + siteId: string; + sourceIdentity: string; + artifactIdentity?: string; + sourceIntentState?: string; + sourceAssets: Array<{ + id: string; + path: string; + sha256: string; + size: number; + kind: string; + role: string; + }>; + bundleFiles: Array<{ + path: string; + sha256: string; + size: number; + }>; + targetBindings: Record; + themeAssetBaseUrl?: string; }; - if (manifest.formatVersion !== 1 || !Array.isArray(manifest.bundleFiles) || !Array.isArray(manifest.sourceAssets) || !manifest.targetBindings - || (manifest.sourceIntentState !== undefined && manifest.sourceIntentState !== 'unreconciled')) throw new Error('invalid retained site manifest'); + if (manifest.formatVersion !== 1 || !Array.isArray(manifest.bundleFiles) || !Array.isArray(manifest.sourceAssets) || !manifest.targetBindings || manifest.sourceIntentState !== undefined && manifest.sourceIntentState !== 'unreconciled') throw new Error('invalid retained site manifest'); const paths = new Set(); for (const entry of manifest.bundleFiles) { - if (paths.has(entry.path)) throw new Error('duplicate retained bundle path'); paths.add(entry.path); + if (paths.has(entry.path)) throw new Error('duplicate retained bundle path'); + paths.add(entry.path); const bytes = await read(entry.path); if (bytes.byteLength !== entry.size || hashBytes(bytes) !== entry.sha256) throw new Error(`retained bundle bytes differ for ${entry.path}`); } if (!paths.has('site.intent.json')) throw new Error('retained manifest omits site intent'); const retainedIntent = JSON.parse((await read('site.intent.json')).toString('utf8')); // Empty declarations require no asset reads; writers need not emit an empty directory. - const retainedAssetRoot = Array.isArray(retainedIntent.assets) && retainedIntent.assets.length === 0 ? input : path.join(input,'source/assets'); + const retainedAssetRoot = Array.isArray(retainedIntent.assets) && retainedIntent.assets.length === 0 ? input : path.join(input, 'source/assets'); const prepared = await prepareSiteSource(retainedIntent, retainedAssetRoot); - if (!prepared.ok) throw new Error(prepared.diagnostics.map(item=>`${item.path}: ${item.message}`).join('\n')); + if (!prepared.ok) throw new Error(prepared.diagnostics.map(item => `${item.path}: ${item.message}`).join('\n')); const source = prepared.source; if (source.sourceIntent.siteId !== manifest.siteId || !siteSourceIdentityMatches(source.sourceIntent, manifest.sourceIdentity)) throw new Error('retained source identity differs from bundle manifest'); // Keep persisted identity exact: accepting old insertion-order hashes must not rewrite confirmations. source.sourceIdentity = manifest.sourceIdentity; - const assets = source.assets.map(asset=>({id:asset.id,path:asset.path,kind:asset.kind,role:asset.role,sha256:asset.sha256,size:asset.bytes.byteLength})); + const assets = source.assets.map(asset => ({ + id: asset.id, + path: asset.path, + kind: asset.kind, + role: asset.role, + sha256: asset.sha256, + size: asset.bytes.byteLength + })); if (hashCanonicalJson(assets) !== hashCanonicalJson(manifest.sourceAssets)) throw new Error('retained source asset inventory differs from bundle manifest'); - if (source.assets.some(asset=>!paths.has(`source/assets/${asset.path}`))) throw new Error('retained manifest omits declared source asset bytes'); + if (source.assets.some(asset => !paths.has(`source/assets/${asset.path}`))) throw new Error('retained manifest omits declared source asset bytes'); if (!(await read('site.manifest.json')).equals(manifestBytes)) throw new Error('retained manifest changed while reading bundle'); - return { source, retainedBindings: manifest.targetBindings, ...(manifest.themeAssetBaseUrl !== undefined ? {retainedThemeAssetBaseUrl:manifest.themeAssetBaseUrl} : {}), - ...(manifest.sourceIntentState === 'unreconciled' ? { sourceIntentState: 'unreconciled' as const } : {}), - ...(typeof manifest.artifactIdentity === 'string' ? { artifactIdentity: manifest.artifactIdentity } : {}) }; + return { + source, + retainedBindings: manifest.targetBindings, + ...(manifest.themeAssetBaseUrl !== undefined ? { + retainedThemeAssetBaseUrl: manifest.themeAssetBaseUrl + } : {}), + ...(manifest.sourceIntentState === 'unreconciled' ? { + sourceIntentState: 'unreconciled' as const + } : {}), + ...(typeof manifest.artifactIdentity === 'string' ? { + artifactIdentity: manifest.artifactIdentity + } : {}) + }; } - async function assertSafePath(file: string): Promise { const root = path.parse(file).root; let current = root; for (const component of file.slice(root.length).split(path.sep).filter(Boolean)) { - current = path.join(current,component); + current = path.join(current, component); if ((await lstat(current)).isSymbolicLink()) throw new Error('site input path cannot contain symlinks'); } } @@ -265,36 +421,51 @@ async function assertSafePath(file: string): Promise { export function observedAttachmentBindings(input: SitePlanInput, snapshot: SiteSnapshot): Record { if (input.retainedThemeAssetBaseUrl !== undefined && input.retainedThemeAssetBaseUrl !== snapshot.theme.assetBaseUrl) throw new Error('retained theme asset base URL belongs to a different target'); const result: Record = {}; - for (const asset of input.source.assets.filter(item=>item.role === 'attachment')) { + for (const asset of input.source.assets.filter(item => item.role === 'attachment')) { const retained = input.retainedBindings[asset.id]; - const ownedVersions = snapshot.entities.filter(entity=>entity.kind === 'attachment' && entity.ownership.some(meta=>meta.siteId===input.source.sourceIntent.siteId && meta.entityId===asset.id)); - const candidates = ownedVersions.filter(entity=>entity.ownership.some(meta=>meta.siteId===input.source.sourceIntent.siteId && meta.entityId===asset.id - && (meta.assetSha256 ?? (meta.lastAppliedHash === managedStateHash(entity.state) ? entity.state.sha256 : undefined)) === asset.sha256)); + const ownedVersions = snapshot.entities.filter(entity => entity.kind === 'attachment' && entity.ownership.some(meta => meta.siteId === input.source.sourceIntent.siteId && meta.entityId === asset.id)); + const candidates = ownedVersions.filter(entity => entity.ownership.some(meta => meta.siteId === input.source.sourceIntent.siteId && meta.entityId === asset.id && (meta.assetSha256 ?? (meta.lastAppliedHash === managedStateHash(entity.state) ? entity.state.sha256 : undefined)) === asset.sha256)); if (candidates.length > 1) throw new Error(`duplicate owned attachment version for ${asset.id}`); - const observed = retained ? snapshot.entities.find(entity=>entity.kind==='attachment' && entity.id===retained.id) : candidates[0]; - if (!observed) { if (retained) throw new Error(`retained attachment binding ${asset.id} is absent on the observed target`); continue; } - const exactOwner = observed.ownership.some(meta=>meta.siteId===input.source.sourceIntent.siteId && meta.entityId===asset.id - && (meta.assetSha256 ?? (meta.lastAppliedHash === managedStateHash(observed.state) ? observed.state.sha256 : undefined)) === asset.sha256); - if (observed.postStatus !== 'inherit' || !exactOwner || observed.state.sha256 !== asset.sha256 || typeof observed.attachment?.url !== 'string' || (retained && observed.attachment?.url !== retained.url)) throw new Error(`attachment binding ${asset.id} is unavailable or differs from exact owned destination bytes or URL`); + const observed = retained ? snapshot.entities.find(entity => entity.kind === 'attachment' && entity.id === retained.id) : candidates[0]; + if (!observed) { + if (retained) throw new Error(`retained attachment binding ${asset.id} is absent on the observed target`); + continue; + } + const exactOwner = observed.ownership.some(meta => meta.siteId === input.source.sourceIntent.siteId && meta.entityId === asset.id && (meta.assetSha256 ?? (meta.lastAppliedHash === managedStateHash(observed.state) ? observed.state.sha256 : undefined)) === asset.sha256); + if (observed.postStatus !== 'inherit' || !exactOwner || observed.state.sha256 !== asset.sha256 || typeof observed.attachment?.url !== 'string' || retained && observed.attachment?.url !== retained.url) throw new Error(`attachment binding ${asset.id} is unavailable or differs from exact owned destination bytes or URL`); const url = new URL(observed.attachment!.url!); - if (!['http:','https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('attachment URL contains unsupported identity or secret fields'); - result[asset.id] = { id: observed.id, url: observed.attachment!.url!, ...(observed.attachment!.linkUrl ? { attachmentPageUrl: observed.attachment!.linkUrl } : {}), - ...(observed.attachment!.width && observed.attachment!.height ? { observedSize: { width: observed.attachment!.width, height: observed.attachment!.height } } : {}), - ...(observed.attachment!.sizes ? { imageSizes: observed.attachment!.sizes } : {}) }; + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('attachment URL contains unsupported identity or secret fields'); + result[asset.id] = { + id: observed.id, + url: observed.attachment!.url!, + ...(observed.attachment!.linkUrl ? { + attachmentPageUrl: observed.attachment!.linkUrl + } : {}), + ...(observed.attachment!.width && observed.attachment!.height ? { + observedSize: { + width: observed.attachment!.width, + height: observed.attachment!.height + } + } : {}), + ...(observed.attachment!.sizes ? { + imageSizes: observed.attachment!.sizes + } : {}) + }; } - for (const id of Object.keys(input.retainedBindings)) if (!input.source.assets.some(asset=>asset.id===id && asset.role==='attachment')) throw new Error(`retained binding ${id} has no declared attachment asset`); + for (const id of Object.keys(input.retainedBindings)) if (!input.source.assets.some(asset => asset.id === id && asset.role === 'attachment')) throw new Error(`retained binding ${id} has no declared attachment asset`); return result; } - -export interface SitePlanOptions extends SiteWpCliOptions { assetRoot?: string; strict?: boolean } +export interface SitePlanOptions extends SiteWpCliOptions { + assetRoot?: string; + strict?: boolean; +} /** Source-first orchestration reads WordPress once and never publishes or mutates it. */ export async function planSite(inputPath: string, options: SitePlanOptions, transport?: SiteReadTransport): Promise { const input = await readSitePlanInput(inputPath, options.assetRoot); - const observed = await readSiteSnapshot(options, input.source.sourceIntent.siteId, transport, input.source.sourceIntent.pages.map(page=>page.route), referencedMediaIds(input.source.sourceIntent)); + const observed = await readSiteSnapshot(options, input.source.sourceIntent.siteId, transport, input.source.sourceIntent.pages.map(page => page.route), referencedMediaIds(input.source.sourceIntent)); return planPreparedSite(input, observed, options); } - function referencedMediaIds(intent: PreparedSiteSource['sourceIntent']): number[] { const ids = new Set(); const visit = (nodes: import('../types.js').IntentNode[]): void => { @@ -315,51 +486,75 @@ export async function planPreparedSite(input: SitePlanInput, observed: SiteSnaps siteId: input.source.sourceIntent.siteId, sourceIdentity: input.source.sourceIdentity, artifactIdentity: input.artifactIdentity ?? '', - assets: input.source.assets.map(asset => ({ id: asset.id, path: asset.path, sha256: asset.sha256 })), + assets: input.source.assets.map(asset => ({ + id: asset.id, + path: asset.path, + sha256: asset.sha256 + })), snapshot: observed, snapshotFingerprint: hashCanonicalJson(observed), operations: [], - conflicts: [{ path: '/source', code: 'source-intent-out-of-date', message: 'This generated bundle contains adopted theme markup while its original SiteIntent remains unchanged; reconcile the source intent before planning destination writes.' }], + conflicts: [{ + path: '/source', + code: 'source-intent-out-of-date', + message: 'This generated bundle contains adopted theme markup while its original SiteIntent remains unchanged; reconcile the source intent before planning destination writes.' + }], diagnostics: [], - executable: false, + executable: false + }; + return { + ...body, + confirmationHash: hashCanonicalJson(body) }; - return { ...body, confirmationHash: hashCanonicalJson(body) }; } const bindings = observedAttachmentBindings(input, observed); const patternBindings = observedPatternBindings(input.source.sourceIntent.siteId, observed); - const compileOptions = { attachmentBindings: bindings, patternBindings, themeAssetBaseUrl: observed.theme.assetBaseUrl, strict: options.strict }; + const compileOptions = { + attachmentBindings: bindings, + patternBindings, + themeAssetBaseUrl: observed.theme.assetBaseUrl, + strict: options.strict + }; const theme = await compileThemeDocuments(input.source, compileOptions); - if (!theme.ok) throw new Error(theme.diagnostics.map(item=>`${item.source?.path ?? 'source'}: ${item.code ?? item.status}: ${item.reason}`).join('\n')); + if (!theme.ok) throw new Error(theme.diagnostics.map(item => `${item.source?.path ?? 'source'}: ${item.code ?? item.status}: ${item.reason}`).join('\n')); // Check every explicit media ID against this exact destination before invoking the native gate. const intent = input.source.sourceIntent; - const trees = [ - ...Object.entries(intent.templates).map(([id,value])=>({id:`template:${id}`,body:value.body})), - ...Object.entries(intent.parts).map(([id,value])=>({id:`part:${id}`,body:value.body})), - ...Object.entries(intent.patterns).map(([id,value])=>({id:`pattern:${id}`,body:value.body})), - ...Object.entries(intent.syncedPatterns?.definitions ?? {}).map(([id,value])=>({id:`synced-pattern:${id}`,body:value.body})), - ...intent.pages.map(page=>({id:`page:${page.id}`,body:page.body})), - ]; + const trees = [...Object.entries(intent.templates).map(([id, value]) => ({ + id: `template:${id}`, + body: value.body + })), ...Object.entries(intent.parts).map(([id, value]) => ({ + id: `part:${id}`, + body: value.body + })), ...Object.entries(intent.patterns).map(([id, value]) => ({ + id: `pattern:${id}`, + body: value.body + })), ...Object.entries(intent.syncedPatterns?.definitions ?? {}).map(([id, value]) => ({ + id: `synced-pattern:${id}`, + body: value.body + })), ...intent.pages.map(page => ({ + id: `page:${page.id}`, + body: page.body + }))]; const check = (nodes: import('../types.js').IntentNode[], documentId: string, parent: number[]): void => { - nodes.forEach((node,index)=>{ - const at = [...parent,index]; - if ((node.block==='core/image' || node.block==='core/cover') && typeof node.attrs?.id === 'number') { - const declared = intent.assetUses.some(use=>use.documentId===documentId && use.nodePath.join('.')===at.join('.') && intent.assets.some(asset=>asset.id===use.assetId && asset.role==='attachment')); - const entity = observed.entities.find(item=>item.kind==='attachment' && item.id===node.attrs!.id); + nodes.forEach((node, index) => { + const at = [...parent, index]; + if ((node.block === 'core/image' || node.block === 'core/cover') && typeof node.attrs?.id === 'number') { + const declared = intent.assetUses.some(use => use.documentId === documentId && use.nodePath.join('.') === at.join('.') && intent.assets.some(asset => asset.id === use.assetId && asset.role === 'attachment')); + const entity = observed.entities.find(item => item.kind === 'attachment' && item.id === node.attrs!.id); const url = node.url ?? node.attrs.url; // Zero on an explicit asset-use field is caller source, never a destination binding. - if (!(node.attrs.id === 0 && declared) && (!entity || (typeof url === 'string' && url !== entity.attachment?.url))) throw new Error('inline media ID or URL does not match the observed destination'); + if (!(node.attrs.id === 0 && declared) && (!entity || typeof url === 'string' && url !== entity.attachment?.url)) throw new Error('inline media ID or URL does not match the observed destination'); } - if (node.block==='core/block' && !intent.syncedPatterns?.uses.some(use=>use.documentId===documentId && use.nodePath.join('.')===at.join('.'))) throw new Error('undeclared core/block reference is not a supported synced pattern use'); - if (node.children) check(node.children,documentId,at); + if (node.block === 'core/block' && !intent.syncedPatterns?.uses.some(use => use.documentId === documentId && use.nodePath.join('.') === at.join('.'))) throw new Error('undeclared core/block reference is not a supported synced pattern use'); + if (node.children) check(node.children, documentId, at); }); }; - for (const document of trees) check(document.body.blocks,document.id,[]); - const compiledPatterns = await compileSiteSyncedPatterns(input.source, compileOptions); + for (const document of trees) check(document.body.blocks, document.id, []); + const compiledPatterns = theme.artifact.syncedPatterns; const inspected = await compileSitePageDocuments(input.source, compileOptions); - if (!inspected.ok) throw new Error(inspected.diagnostics.filter(item=>item.status==='invalid').map(item=>`${item.source?.path ?? 'source'}: ${item.code ?? item.status}: ${item.reason}`).join('\n')); + if (!inspected.ok) throw new Error(inspected.diagnostics.filter(item => item.status === 'invalid').map(item => `${item.source?.path ?? 'source'}: ${item.code ?? item.status}: ${item.reason}`).join('\n')); return reconcileSitePlan(input.source, theme.artifact, inspected.pages, observed, bindings, inspected.diagnostics, compiledPatterns, patternBindings); } - function observedPatternBindings(siteId: string, snapshot: SiteSnapshot): Record { const grouped = new Map(); for (const entity of snapshot.entities) { diff --git a/src/site/wpcli.ts b/src/site/wpcli.ts index 19eeccc..5d8085f 100644 --- a/src/site/wpcli.ts +++ b/src/site/wpcli.ts @@ -2,14 +2,18 @@ import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { promisify } from 'node:util'; import type { SiteSnapshot } from './plan-types.js'; - export const SITE_OWNERSHIP_META = '_block_runner_site'; /** WordPress options are blog-local; ownership namespaces share this mutation lock. */ export const SITE_APPLY_LOCK_OPTION = '_block_runner_site_apply_lock'; export function siteRecordOption(siteId: string): string { return `_block_runner_site_${createHash('sha256').update(siteId).digest('hex')}`; } -export interface SiteWpCliOptions { wpPath: string; wpUrl?: string; wpBinary?: string; wpUser?: string } +export interface SiteWpCliOptions { + wpPath: string; + wpUrl?: string; + wpBinary?: string; + wpUser?: string; +} export type SiteReadTransport = (executable: string, args: string[]) => Promise; export interface SiteTemplateObservation { id: string; @@ -22,14 +26,25 @@ export interface SiteTemplateObservation { export interface SiteTemplateSnapshot { target: SiteSnapshot['target']; theme: string; - principal: { userId: number; userLogin: string; canReadEditContext: boolean }; - runtime: { wordpress: string }; + principal: { + userId: number; + userLogin: string; + canReadEditContext: boolean; + }; + runtime: { + wordpress: string; + }; entities: SiteTemplateObservation[]; } const run = promisify(execFile); const localRead: SiteReadTransport = async (executable, args) => { try { - const { stdout } = await run(executable, args, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); + const { + stdout + } = await run(executable, args, { + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024 + }); return stdout; } catch { // Child stderr can contain credentials from third-party bootstrap code. @@ -45,12 +60,16 @@ export async function readSiteSnapshot(options: SiteWpCliOptions, siteId: string for (const value of [options.wpUrl, options.wpUser, options.wpBinary]) if (value?.includes('\0')) throw new Error('WP-CLI arguments cannot contain NUL bytes'); if (options.wpUrl) args.push(`--url=${options.wpUrl}`); if (options.wpUser) args.push(`--user=${options.wpUser}`); - if (desiredRoutes.some(route=>!route.startsWith('/') || route.includes('\0') || route.includes('?') || route.includes('#'))) throw new Error('invalid desired route'); + if (desiredRoutes.some(route => !route.startsWith('/') || route.includes('\0') || route.includes('?') || route.includes('#'))) throw new Error('invalid desired route'); if (mediaHashIds.some(id => !Number.isSafeInteger(id) || id < 1)) throw new Error('invalid referenced media ID'); args.push('eval', snapshotScript(siteId, desiredRoutes, mediaHashIds)); const output = await transport(options.wpBinary ?? 'wp', args); let snapshot: SiteSnapshot; - try { snapshot = JSON.parse(output) as SiteSnapshot; } catch { throw new Error('WP-CLI returned an invalid snapshot'); } + try { + snapshot = JSON.parse(output) as SiteSnapshot; + } catch { + throw new Error('WP-CLI returned an invalid snapshot'); + } validateSnapshot(snapshot); return snapshot; } @@ -66,18 +85,17 @@ export async function readSiteTemplateSnapshot(options: SiteWpCliOptions, expect args.push('eval', templateSnapshotScript(expectedTheme)); const output = await transport(options.wpBinary ?? 'wp', args); let snapshot: SiteTemplateSnapshot; - try { snapshot = JSON.parse(output) as SiteTemplateSnapshot; } catch { throw new Error('WP-CLI returned an invalid template snapshot'); } - if (!snapshot || !snapshot.target || !Number.isInteger(snapshot.target.blogId) || snapshot.target.blogId < 1 - || snapshot.theme !== expectedTheme || !snapshot.principal || !Number.isInteger(snapshot.principal.userId) || snapshot.principal.userId < 1 || typeof snapshot.principal.userLogin !== 'string' || !snapshot.principal.userLogin - || !snapshot.runtime || !Array.isArray(snapshot.entities)) throw new Error('WP-CLI returned an incomplete template snapshot'); + try { + snapshot = JSON.parse(output) as SiteTemplateSnapshot; + } catch { + throw new Error('WP-CLI returned an invalid template snapshot'); + } + if (!snapshot || !snapshot.target || !Number.isInteger(snapshot.target.blogId) || snapshot.target.blogId < 1 || snapshot.theme !== expectedTheme || !snapshot.principal || !Number.isInteger(snapshot.principal.userId) || snapshot.principal.userId < 1 || typeof snapshot.principal.userLogin !== 'string' || !snapshot.principal.userLogin || !snapshot.runtime || !Array.isArray(snapshot.entities)) throw new Error('WP-CLI returned an incomplete template snapshot'); if (!snapshot.principal.canReadEditContext) throw new Error('selected WordPress principal lacks edit_posts for edit-context template inspection'); const ids = new Set(); for (const entity of snapshot.entities) { const identity = entity && `${entity.type}:${entity.id}`; - if (!entity || typeof entity.id !== 'string' || ids.has(identity) || entity.theme !== expectedTheme - || !['wp_template', 'wp_template_part'].includes(entity.type) || !['theme', 'custom', 'plugin'].includes(entity.source) - || !Number.isInteger(entity.wpId) || entity.wpId < 0 || typeof entity.raw !== 'string' - || (entity.source === 'custom' && entity.wpId < 1) || (entity.source !== 'custom' && entity.wpId !== 0)) { + if (!entity || typeof entity.id !== 'string' || ids.has(identity) || entity.theme !== expectedTheme || !['wp_template', 'wp_template_part'].includes(entity.type) || !['theme', 'custom', 'plugin'].includes(entity.source) || !Number.isInteger(entity.wpId) || entity.wpId < 0 || typeof entity.raw !== 'string' || entity.source === 'custom' && entity.wpId < 1 || entity.source !== 'custom' && entity.wpId !== 0) { throw new Error('WP-CLI returned an invalid or conflicting edit-context template entity'); } ids.add(identity); @@ -88,7 +106,6 @@ export async function readSiteTemplateSnapshot(options: SiteWpCliOptions, expect } return snapshot; } - function templateSnapshotScript(expectedTheme: string): string { const theme = JSON.stringify(expectedTheme); return ` @@ -136,7 +153,6 @@ echo wp_json_encode(array('target'=>array('homeUrl'=>home_url(),'siteUrl'=>site_ 'principal'=>array('userId'=>get_current_user_id(),'userLogin'=>wp_get_current_user()->user_login,'canReadEditContext'=>current_user_can('edit_posts')), 'runtime'=>array('wordpress'=>get_bloginfo('version')),'entities'=>$entities));`; } - function validateSnapshot(value: SiteSnapshot): void { if (!value || !value.target || !Number.isInteger(value.target.blogId) || value.target.blogId < 1 || !value.runtime || !value.routing || !Array.isArray(value.routing.routes) || !value.theme || !Array.isArray(value.theme.inventory) || !Array.isArray(value.entities) || !Array.isArray(value.overrides) || !Array.isArray(value.navigation) || !value.options || !value.principal || !value.principal.capabilities) { throw new Error('WP-CLI returned an incomplete snapshot'); @@ -164,10 +180,9 @@ function validateSnapshot(value: SiteSnapshot): void { } if (!Number.isInteger(value.principal.userId) || value.principal.userId < 1) throw new Error('Select an authenticated WordPress principal with --wp-user or WP-CLI configuration (observed user ID 0)'); } - function snapshotScript(siteId: string, desiredRoutes: string[], mediaHashIds: readonly number[]): string { const option = JSON.stringify(siteRecordOption(siteId)); - const routes = Buffer.from(JSON.stringify(desiredRoutes.filter(route=>route!=='/'))).toString('base64'); + const routes = Buffer.from(JSON.stringify(desiredRoutes.filter(route => route !== '/'))).toString('base64'); const mediaIds = Buffer.from(JSON.stringify([...new Set(mediaHashIds)])).toString('base64'); return ` // This process terminates after inspection; restoring core cron would spawn a write at shutdown. From 37863f79a445dd78f265f0dc4ed7728d8d907344 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:09:01 +0200 Subject: [PATCH 17/38] fix(site): keep unresolved pattern media out of binding hashes --- dev/test/site-patterns.test.ts | 17 +++++++++++++---- src/site/compile.ts | 3 ++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/dev/test/site-patterns.test.ts b/dev/test/site-patterns.test.ts index 4853022..583486a 100644 --- a/dev/test/site-patterns.test.ts +++ b/dev/test/site-patterns.test.ts @@ -6,9 +6,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { validateSiteIntent } from '../../src/site/input.js'; -import { compileSite, compileSitePageDocuments, compileSiteSyncedPattern, compileSiteSyncedPatterns, createSiteSyncedPatternCompiler } from '../../src/site/compile.js'; +import { compileSite, compileThemeDocuments, compileSitePageDocuments, compileSiteSyncedPattern, compileSiteSyncedPatterns, createSiteSyncedPatternCompiler } from '../../src/site/compile.js'; import type { PreparedSiteSource, SiteIntent, ThemeArtifact } from '../../src/site/types.js'; -import { managedStateHash, reconcileSitePlan } from '../../src/site/plan.js'; +import { managedStateHash, planPreparedSite, reconcileSitePlan } from '../../src/site/plan.js'; import type { SiteEntity, SiteSnapshot } from '../../src/site/plan-types.js'; const fixtureRoot = fileURLToPath(new URL('./fixtures/site-intent', import.meta.url)); @@ -234,14 +234,23 @@ describe('site synced pattern contract and compiler', () => { const intent = await base(); intent.syncedPatterns = declaration(); intent.syncedPatterns.definitions.card!.body = { blocks: [{ block: 'core/image', url: '', attrs: { id: 0 } }] }; - intent.assetUses = [{ documentId: 'synced-pattern:card', nodePath: [0], attribute: 'attrs.id', assetId: 'home-hero' }]; + const patternUse = { documentId: 'synced-pattern:card', nodePath: [0], attribute: 'attrs.id', assetId: 'home-hero' } as const; + intent.assetUses.push({ ...patternUse, nodePath: [0] }); const { prepareSiteSource } = await import('../../src/site/input.js'); const prepared = await prepareSiteSource(intent, fixtureRoot); expect(prepared.ok).toBe(true); if (!prepared.ok) return; const pending = await compileSiteSyncedPatterns(prepared.source); - expect(pending[0]).toMatchObject({ pendingBindings: intent.assetUses }); + expect(pending[0]).toMatchObject({ pendingBindings: [patternUse] }); expect(pending[0]).not.toHaveProperty('content'); + const theme = await compileThemeDocuments(prepared.source, { themeAssetBaseUrl: 'https://example.test/theme' }); + expect(theme.ok, JSON.stringify(theme.diagnostics)).toBe(true); + if (!theme.ok) return; + expect(theme.artifact.targetBindings).toEqual({}); + const snapshot: SiteSnapshot = { target: { homeUrl: 'https://example.test', siteUrl: 'https://example.test/wp', blogId: 1 }, runtime: { wordpress: '7.1', php: '8.4' }, principal: { userId: 1, capabilities: { edit_pages: true, edit_others_pages: true, edit_posts: true, edit_others_posts: true, publish_posts: true, publish_blocks: true, publish_pages: true, manage_options: true, edit_theme_options: true, upload_files: true } }, theme: { slug: intent.theme.slug, assetBaseUrl: 'https://example.test/theme', inventory: theme.artifact.inventory }, routing: { permalinkStructure: '/%postname%/', rewriteRulesHash: 'rules', routes: intent.pages.filter(page => page.route !== '/').map(page => ({ route: page.route, kind: 'available', matchedRule: 'native', queryFingerprint: 'native' })) }, entities: [], overrides: [], navigation: [], options: { show_on_front: 'posts', page_on_front: 0 }, siteRecord: null }; + const firstInstall = await planPreparedSite({ source: prepared.source, retainedBindings: {} }, snapshot); + expect(firstInstall.executable, JSON.stringify(firstInstall.conflicts)).toBe(true); + expect(firstInstall.operations.find(operation => operation.kind === 'pattern')).toMatchObject({ action: 'create', requiresFinalization: true, pendingBindings: [patternUse] }); const bound = await compileSiteSyncedPatterns(prepared.source, { attachmentBindings: { 'home-hero': { id: 73, url: 'https://example.test/hero.png' } } }); expect(bound[0]!.content).toContain('"id":73'); const single = await compileSiteSyncedPattern(prepared.source, 'card', { attachmentBindings: { 'home-hero': { id: 73, url: 'https://example.test/hero.png' } } }); diff --git a/src/site/compile.ts b/src/site/compile.ts index d333458..7657335 100644 --- a/src/site/compile.ts +++ b/src/site/compile.ts @@ -644,7 +644,8 @@ function usedBindings( ): Record { const ids = [...new Set(intent.assetUses.filter((use) => !documentIds || documentIds.has(use.documentId)) .map((use) => use.assetId).filter((id) => assetRole(intent, id) === 'attachment'))].sort(); - return Object.fromEntries(ids.map((id) => [id, structuredClone(bindings[id]!)])); + // Missing bindings remain explicit pending uses, never undefined entries in a target fingerprint. + return Object.fromEntries(ids.filter((id) => bindings[id] !== undefined).map((id) => [id, structuredClone(bindings[id]!)])); } function assetRole(intent: SiteIntent, id: string): SiteIntent['assets'][number]['role'] | undefined { From a7b3ffa8c40eca1c14c5b0c9f4dd014d2bd0c695 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:30:11 +0200 Subject: [PATCH 18/38] fix(site): preserve floating draft dates on update --- dev/test/site-apply-wpcli.test.ts | 4 +++- src/site/apply-wpcli.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dev/test/site-apply-wpcli.test.ts b/dev/test/site-apply-wpcli.test.ts index bf9f8f2..3c71b33 100644 --- a/dev/test/site-apply-wpcli.test.ts +++ b/dev/test/site-apply-wpcli.test.ts @@ -27,12 +27,13 @@ class TestWpdb {public $options='options';function esc_like($s){return addcslash if(isset($flags['wrongToken']))$options[$d['lock']]='foreign';if(isset($flags['wrongStep']))$options[$d['option']]['operation']['currentStep']['entityId']='foreign'; $p=(object)array('ID'=>22,'post_type'=>'page','post_content'=>$d['state']['content'],'post_title'=>$d['state']['title'],'post_status'=>$d['state']['status'],'post_name'=>'home','post_parent'=>0,'post_author'=>31,'post_excerpt'=>'Human excerpt','post_date'=>'2025-02-03 04:05:06','post_date_gmt'=>'2025-02-03 03:05:06','menu_order'=>19); if(isset($flags['humanStatus']))$p->post_status='private'; +if(isset($flags['floatingDraft']))$p->post_date_gmt='0000-00-00 00:00:00'; $meta=array(22=>array('_wp_page_template'=>'index','_block_runner_site'=>array($d['owner']))); function home_url(){return 'https://example.test';}function site_url(){return home_url();}function get_current_blog_id(){global $flags;return $flags['blogId']??1;}function get_current_user_id(){return 7;}function current_user_can($c){return true;}function get_stylesheet(){return 'sample';}function get_stylesheet_directory(){global $d;return $d['root'];}function wp_json_encode($v){return json_encode($v);} function get_option($k,$default=false){global $options;return $options[$k]??$default;}function add_option($k,$v,$unused='',$autoload=false){global $options;if(array_key_exists($k,$options))return false;$options[$k]=$v;return true;}function delete_option($k){global $options;unset($options[$k]);return true;}function update_option($k,$v,$autoload=false){global $options,$writes;$writes++;$options[$k]=$v;return true;} function get_post($id){global $p;return $id===22?$p:null;}function get_posts($args){global $p,$flags;return isset($flags['occupied'])?array($p):array();}function get_post_meta($id,$k,$single){global $meta;return $single?($meta[$id][$k]??''):($meta[$id][$k]??array());} function wp_slash($v){return $v;}function is_wp_error($v){return false;}function wp_insert_post($v,$errors){global $p,$writes,$flags;$writes++;$p=(object)array('ID'=>22,'post_type'=>$v['post_type'],'post_content'=>$v['post_content'],'post_title'=>$v['post_title'],'post_status'=>$v['post_status'],'post_name'=>$v['post_name'],'post_parent'=>$v['post_parent']);return 22;} -function wp_update_post($v,$errors){global $p,$writes;$writes++;foreach(array('post_type','post_title','post_content','post_status','post_name','post_parent') as $key)if(array_key_exists($key,$v))$p->$key=$v[$key];if(!array_key_exists('page_template',$v)||$v['page_template']!=='')throw new Exception('inherited page template validation');return (int)$v['ID'];} +function wp_update_post($v,$errors){global $p,$writes;$writes++;if($p->post_status==='draft'&&$p->post_date_gmt==='0000-00-00 00:00:00'&&empty($v['edit_date']))$p->post_date='2030-01-01 00:00:00';foreach(array('post_type','post_title','post_content','post_status','post_name','post_parent') as $key)if(array_key_exists($key,$v))$p->$key=$v[$key];if(!array_key_exists('page_template',$v)||$v['page_template']!=='')throw new Exception('inherited page template validation');return (int)$v['ID'];} function update_post_meta($id,$k,$v){global $meta,$flags;if(isset($flags['ownershipCrash'])&&$k==='_block_runner_site')throw new Exception('crash');$meta[$id][$k]=$k==='_block_runner_site'?array($v):$v;return true;} function add_post_meta($id,$k,$v,$unique){global $meta,$flags;if(isset($flags['ownershipCrash']))throw new Exception('crash');$meta[$id][$k]=array($v);return true;} `; @@ -57,6 +58,7 @@ describe('fixed PHP mutation boundary executed with bounded WordPress stubs',()= }); it('rejects a forged unowned update at the fixed PHP boundary with zero mutations',async()=>{const unowned=structuredClone(before);unowned.entities[0]!.ownership=[];const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'Forged'}},before:unowned,stateHash:managedStateHash({...state,title:'Forged'})});expect(r.response.status).toBe('refused');expect(r.evidence.writes).toBe(0);}); it('writes exact managed fields and ownership without shell evaluation',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})});expect(r.response).toEqual({status:'complete',id:22});expect(r.evidence.post).toMatchObject({post_title:'New',post_author:31,post_excerpt:'Human excerpt',post_date:'2025-02-03 04:05:06',post_date_gmt:'2025-02-03 03:05:06',menu_order:19});expect(r.argsSeen.slice(0,3)).toEqual(['--path=/wp path;$(false)','--user=7','eval']);}); + it('preserves a draft date when its publish date is still floating',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})},{floatingDraft:true});expect(r.response).toEqual({status:'complete',id:22});expect(r.evidence.post).toMatchObject({post_date:'2025-02-03 04:05:06',post_date_gmt:'0000-00-00 00:00:00'});}); it('does not transmit unrelated content or snapshot inventories with a selected mutation',async()=>{ const request:SiteApplyRequest={action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})}; const capture=async(value:SiteApplyRequest)=>{ diff --git a/src/site/apply-wpcli.ts b/src/site/apply-wpcli.ts index 1dd90d2..c1d5aea 100644 --- a/src/site/apply-wpcli.ts +++ b/src/site/apply-wpcli.ts @@ -176,7 +176,7 @@ $result=(function() use($e,$r,&$attempted,$same,$state) { } } $data=array('post_type'=>$type,'post_title'=>$desired['title'],'post_content'=>$desired['content'],'post_status'=>$desired['status']); - if($id) $data['ID']=$id; + if($id) { $data['ID']=$id; $data['edit_date']=true; } // Template metadata is assigned below. wp_update_post otherwise inherits and revalidates the old template after writing. if($kind==='page') { $data['post_name']=$desired['slug']; $data['post_parent']=$desired['parentId']; $data['page_template']=''; } $attempted=true; $written=$id ? wp_update_post(wp_slash($data),true) : wp_insert_post(wp_slash($data),true); if(is_wp_error($written)||!$written) throw new Exception('save'); $id=(int)$written; From d148ec18a0d9b5d02a92464d7fc2bccd71f7fba2 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:33:10 +0200 Subject: [PATCH 19/38] refactor: use canonical prepared site identity --- dev/test/site-contract.test.ts | 13 +++++-------- dev/test/site-patterns.test.ts | 4 ---- dev/test/site-write.test.ts | 12 +++++------- src/site/assets.ts | 6 +++--- src/site/compile.ts | 9 ++++++--- src/site/input.ts | 16 ++++------------ 6 files changed, 23 insertions(+), 37 deletions(-) diff --git a/dev/test/site-contract.test.ts b/dev/test/site-contract.test.ts index e48660f..7c1375a 100644 --- a/dev/test/site-contract.test.ts +++ b/dev/test/site-contract.test.ts @@ -1,10 +1,10 @@ -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; import { cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, truncate, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { prepareSiteSource, siteSourceIdentityMatches, validateSiteIntent } from '../../src/site/input.js'; +import { hashSiteSourceIdentity, prepareSiteSource, validateSiteIntent } from '../../src/site/input.js'; import { verifyPreparedSiteAssets } from '../../src/site/assets.js'; const fixtureRoot = fileURLToPath(new URL('./fixtures/site-intent', import.meta.url)); @@ -65,15 +65,12 @@ describe('site intent contract', () => { if (baseline.ok && reversed.ok) expect(reversed.source.sourceIdentity).toBe(baseline.source.sourceIdentity); }); - it('accepts a legacy insertion-order source identity and verifies its source bytes unchanged', async () => { + it('uses the canonical source identity and verifies its source bytes unchanged', async () => { const prepared = await prepareSiteSource(await loadIntent(), fixtureRoot); expect(prepared.ok).toBe(true); if (!prepared.ok) return; - const legacyIdentity = createHash('sha256').update(JSON.stringify(prepared.source.sourceIntent)).digest('hex'); - const legacySource = { ...prepared.source, sourceIdentity: legacyIdentity }; - expect(siteSourceIdentityMatches(legacySource.sourceIntent, legacyIdentity)).toBe(true); - await expect(verifyPreparedSiteAssets(legacySource)).resolves.toBeUndefined(); - expect(siteSourceIdentityMatches(legacySource.sourceIntent, 'sha256-not-current')).toBe(false); + expect(prepared.source.sourceIdentity).toBe(hashSiteSourceIdentity(prepared.source.sourceIntent)); + await expect(verifyPreparedSiteAssets(prepared.source)).resolves.toBeUndefined(); }); it('reports malformed native tree nodes and children without throwing during graph preparation', async () => { diff --git a/dev/test/site-patterns.test.ts b/dev/test/site-patterns.test.ts index 583486a..902b16c 100644 --- a/dev/test/site-patterns.test.ts +++ b/dev/test/site-patterns.test.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; @@ -266,8 +265,5 @@ describe('site synced pattern contract and compiler', () => { expect(missing).toMatchObject({ ok: false, diagnostics: [expect.objectContaining({ code: 'unknown-pattern-id' })] }); const invalidSource = await compileSiteSyncedPattern({ ...prepared.source, sourceIdentity: 'stale' }, 'card'); expect(invalidSource).toMatchObject({ ok: false, diagnostics: [expect.objectContaining({ code: 'prepared-source-identity-mismatch' })] }); - const legacyIdentity = createHash('sha256').update(JSON.stringify(prepared.source.sourceIntent)).digest('hex'); - const legacy = await compileSiteSyncedPattern({ ...prepared.source, sourceIdentity: legacyIdentity }, 'card', { attachmentBindings: { 'home-hero': { id: 73, url: 'https://example.test/hero.png' } } }); - expect(legacy).toMatchObject({ ok: true, pattern: { id: 'card', content: expect.stringContaining('"id":73') } }); }); }); diff --git a/dev/test/site-write.test.ts b/dev/test/site-write.test.ts index d6a5112..cad1c50 100644 --- a/dev/test/site-write.test.ts +++ b/dev/test/site-write.test.ts @@ -117,15 +117,13 @@ describe('site bundle publication', () => { } }); - it('publishes a previously prepared source without rewriting its legacy source identity', async () => { + it('refuses a non-canonical source identity before publishing', async () => { const { root, source } = await setup(); try { - const legacyIdentity = createHash('sha256').update(JSON.stringify(source.sourceIntent)).digest('hex'); - const legacySource = { ...source, sourceIdentity: legacyIdentity }; - const output = path.join(root, 'legacy-bundle'); - await writeCompiledSite(output, legacySource, artifact(legacySource)); - const manifest = JSON.parse(await readFile(path.join(output, 'site.manifest.json'), 'utf8')) as { sourceIdentity: string }; - expect(manifest.sourceIdentity).toBe(legacyIdentity); + const nonCanonicalSource = { ...source, sourceIdentity: createHash('sha256').update(JSON.stringify(source.sourceIntent)).digest('hex') }; + const output = path.join(root, 'non-canonical-bundle'); + await expect(writeCompiledSite(output, nonCanonicalSource, artifact(source))).rejects.toThrow('prepared source identity does not match'); + await expect(lstat(output)).rejects.toThrow(); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/src/site/assets.ts b/src/site/assets.ts index ed5b324..4b29665 100644 --- a/src/site/assets.ts +++ b/src/site/assets.ts @@ -130,11 +130,11 @@ export function buildSiteThemeAssets(source: PreparedSiteSource): SiteThemeAsset /** Re-read the declared source through the same no-symlink boundary and reject changed bytes. */ export async function verifyPreparedSiteAssets(source: PreparedSiteSource): Promise { - const { prepareSiteSource, siteSourceIdentityMatches } = await import('./input.js'); + const { hashSiteSourceIdentity, prepareSiteSource } = await import('./input.js'); const refreshed = await prepareSiteSource(source.sourceIntent, source.assetRoot); if (!refreshed.ok) throw new Error(`site source assets changed or became unsafe: ${refreshed.diagnostics.map(({ path: at, message }) => `${at}: ${message}`).join('; ')}`); - if (!siteSourceIdentityMatches(source.sourceIntent, source.sourceIdentity) - || !siteSourceIdentityMatches(refreshed.source.sourceIntent, source.sourceIdentity)) throw new Error('site intent changed after source preparation'); + if (hashSiteSourceIdentity(source.sourceIntent) !== source.sourceIdentity + || refreshed.source.sourceIdentity !== source.sourceIdentity) throw new Error('site intent changed after source preparation'); const current = new Map(refreshed.source.assets.map((asset) => [asset.id, asset])); if (current.size !== source.assets.length || source.assets.some((asset) => current.get(asset.id)?.sha256 !== asset.sha256)) { throw new Error('site source assets changed after source preparation'); diff --git a/src/site/compile.ts b/src/site/compile.ts index 7657335..02b1b56 100644 --- a/src/site/compile.ts +++ b/src/site/compile.ts @@ -11,7 +11,7 @@ import { buildSiteThemeAssets } from './assets.js'; import { bindNativeImage, declaredEmbedAspectRatio, validateNativeImageSettings } from './media.js'; import { patternOverrideContent } from '../authoring/pattern-overrides.js'; import { compileSyncedPatterns, type CompiledSyncedPattern } from './patterns.js'; -import { siteSourceIdentityMatches, validateSiteIntent } from './input.js'; +import { hashSiteSourceIdentity, validateSiteIntent } from './input.js'; import type { CompiledSite, CompiledSitePage, @@ -48,7 +48,10 @@ export type CompileSiteSyncedPatternResult = /** Recompute compiler provenance from the retained source and current artifact payload. */ export function calculateSiteArtifactIdentity(source: PreparedSiteSource, artifact: CompiledSite | ThemeArtifact): string { - if (!siteSourceIdentityMatches(source.sourceIntent, source.sourceIdentity)) throw new Error('prepared source identity does not match its site intent'); + const sourceDiagnostics: Array<{ path: string; code: string; message: string }> = []; + const intent = validateSiteIntent(source?.sourceIntent, sourceDiagnostics); + if (!intent) throw new Error(sourceDiagnostics.map((item) => `${item.path}: ${item.message}`).join('; ')); + if (hashSiteSourceIdentity(intent) !== source.sourceIdentity) throw new Error('prepared source identity does not match its canonical site intent'); const targetBindingFingerprint = hashTargetContext(artifact.targetBindings, artifact.themeAssetBaseUrl); if (artifact.targetBindingFingerprint !== targetBindingFingerprint) throw new Error('compiled artifact target bindings do not match their fingerprint'); const themeFiles = artifact.themeFiles.map((file) => { @@ -588,7 +591,7 @@ function validatePreparedSource(source: PreparedSiteSource): { ok: true; intent: if (!intent) { return { ok: false, diagnostics: inputDiagnostics.map((entry) => ({ status: 'invalid', reason: entry.message, source: { path: entry.path }, code: entry.code })) }; } - if (!siteSourceIdentityMatches(intent, source.sourceIdentity)) { + if (hashSiteSourceIdentity(intent) !== source.sourceIdentity) { return { ok: false, diagnostics: [invalid('site', 'prepared-source-identity-mismatch', 'prepared source identity does not match its canonical site intent')] }; } if (!Array.isArray(source?.assets)) { diff --git a/src/site/input.ts b/src/site/input.ts index b5b7177..5226865 100644 --- a/src/site/input.ts +++ b/src/site/input.ts @@ -64,7 +64,6 @@ export async function prepareSiteSource(input: unknown, assetRoot: string): Prom } if (diagnostics.length > 0) return { ok: false, diagnostics }; - const intentJson = canonicalJson(validated); return { ok: true, diagnostics: [], @@ -72,7 +71,7 @@ export async function prepareSiteSource(input: unknown, assetRoot: string): Prom sourceIntent: validated, assetRoot: canonicalRoot, assets: assets.sort((a, b) => a.path.localeCompare(b.path)), - sourceIdentity: createHash('sha256').update(intentJson).digest('hex'), + sourceIdentity: hashSiteSourceIdentity(validated), unresolvedAssetUses: validated.assetUses.filter((use) => { const asset = validated.assets.find((candidate) => candidate.id === use.assetId); return asset?.role === 'attachment'; @@ -81,16 +80,9 @@ export async function prepareSiteSource(input: unknown, assetRoot: string): Prom }; } -/** Match both current canonical and previously persisted insertion-order source identities. */ -export function siteSourceIdentityMatches(sourceIntent: unknown, identity: unknown): identity is string { - if (typeof identity !== 'string') return false; - const diagnostics: SiteInputDiagnostic[] = []; - const validated = validateSiteIntent(sourceIntent, diagnostics); - if (!validated || diagnostics.length > 0) return false; - const canonicalIdentity = createHash('sha256').update(canonicalJson(validated)).digest('hex'); - if (identity === canonicalIdentity) return true; - const legacyIdentity = createHash('sha256').update(JSON.stringify(validated)).digest('hex'); - return identity === legacyIdentity; +/** Hash an already validated intent without repeating envelope or graph validation. */ +export function hashSiteSourceIdentity(sourceIntent: SiteIntent): string { + return createHash('sha256').update(canonicalJson(sourceIntent)).digest('hex'); } /** Graph and envelope checks are intentionally local to this contract, not a general schema engine. */ From 8ab18aa8b2dd764e93fc48dd02a22e1f0f310572 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:33:32 +0200 Subject: [PATCH 20/38] refactor: require versioned attachment ownership --- dev/test/site-media-import.test.ts | 9 +++------ dev/test/site-media.test.ts | 17 ++++++++++++++--- src/site/media-import.ts | 19 +++++++++---------- src/site/media.ts | 5 ++--- src/site/ownership.ts | 7 ++----- 5 files changed, 30 insertions(+), 27 deletions(-) diff --git a/dev/test/site-media-import.test.ts b/dev/test/site-media-import.test.ts index 9c3186d..faa2cfe 100644 --- a/dev/test/site-media-import.test.ts +++ b/dev/test/site-media-import.test.ts @@ -173,16 +173,13 @@ describe('private declared attachment import primitive', () => { expect(payload).not.toBeNull(); expect(JSON.parse(Buffer.from(payload![1]!, 'base64').toString('utf8'))).toMatchObject({ assetSha256: hash, relativePath: 'media/hero.png' }); expect(script).not.toContain(Buffer.from(await readFile(path.join(ready.root, 'media/hero.png'))).toString('base64')); - const legacyExpression = script.match(/\$legacyHash\s*=\s*([^;]+);/)?.[1]; const ownershipExpression = script.match(/'lastAppliedHash'=>(.+?), 'assetSha256'/)?.[1]; - expect(legacyExpression).toBeDefined(); expect(ownershipExpression).toBeDefined(); + expect(script).not.toContain('legacyHash'); const php = `function wp_json_encode($value) { return json_encode($value); } -$expected = array('assetSha256'=>'${hash}'); $state = array('sha256'=>'${hash}'); -$legacyHash = ${legacyExpression}; $ownershipHash = ${ownershipExpression}; -echo json_encode(array($legacyHash, $ownershipHash));`; +echo json_encode($ownershipHash);`; let hashOutput: string; try { ({ stdout: hashOutput } = await run('php', ['-r', php], { encoding: 'utf8' })); @@ -190,7 +187,7 @@ echo json_encode(array($legacyHash, $ownershipHash));`; if ((error as NodeJS.ErrnoException).code === 'ENOENT') return skip(); throw error; } - expect(JSON.parse(hashOutput)).toEqual([managedStateHash({ sha256: hash }), managedStateHash({ sha256: hash })]); + expect(JSON.parse(hashOutput)).toBe(managedStateHash({ sha256: hash })); expect(binding).toMatchObject({ id: 44, url: 'https://example.test/uploads/hero-3.png', observedSize: { width: 1200, height: 800 }, imageSizes: { full: { url: 'https://example.test/uploads/hero-3.png', width: 1200, height: 800 } } }); }); diff --git a/dev/test/site-media.test.ts b/dev/test/site-media.test.ts index 24eeba1..2b7e6b4 100644 --- a/dev/test/site-media.test.ts +++ b/dev/test/site-media.test.ts @@ -57,11 +57,10 @@ describe('owned attachment versions', () => { expect(result.operations.find(operation => operation.kind === 'attachment')).toMatchObject({ action: 'retain', id: 21, entityId: 'hero' }); }); - it('plans a new version while retaining the old attachment and legacy indexed ID', () => { + it('plans a new version while retaining the old qualified attachment index', () => { const previous = attachment(21, oldHash); - delete previous.ownership[0]!.assetSha256; const observed = snapshot([previous]); - observed.siteRecord!.entities = { 'attachment:hero': 21 }; + observed.siteRecord!.entities = { [attachmentVersionKey('hero', oldHash)]: 21 }; const input = source(nextHash); const result = reconcileSitePlan(input, theme(input), compiledPages, observed); expect(result.executable).toBe(true); @@ -71,6 +70,18 @@ describe('owned attachment versions', () => { ])); }); + it('refuses attachment ownership without its source hash or a qualified index', () => { + const missingHash = attachment(21, oldHash); + delete missingHash.ownership[0]!.assetSha256; + expect(reconcileSitePlan(source(oldHash), theme(source(oldHash)), compiledPages, snapshot([missingHash])).executable).toBe(false); + + const unqualifiedIndex = snapshot([attachment(22, oldHash)]); + unqualifiedIndex.siteRecord!.entities = { 'attachment:hero': 22 }; + const result = reconcileSitePlan(source(oldHash), theme(source(oldHash)), compiledPages, unqualifiedIndex); + expect(result.executable).toBe(false); + expect(result.conflicts.map(item => item.code)).toContain('ownership-record-missing'); + }); + it('blocks duplicate versions and human changes to any owned version', () => { const duplicate = attachment(22, oldHash); expect(reconcileSitePlan(source(oldHash), theme(source(oldHash)), compiledPages, snapshot([attachment(21, oldHash), duplicate])).conflicts.map(item => item.code)).toContain('duplicate-ownership'); diff --git a/src/site/media-import.ts b/src/site/media-import.ts index 8c835ac..502f3c1 100644 --- a/src/site/media-import.ts +++ b/src/site/media-import.ts @@ -74,10 +74,14 @@ async function importAttachment(options: ImportPlannedSiteMediaOptions, verified throw new Error('live theme inventory or principal differs from the confirmed plan'); } const input: SitePlanInput = { source, retainedBindings: {} }; - if (before.entities.some(entity => entity.kind === 'attachment' && entity.ownership.some(meta => - meta.siteId === plan.siteId && meta.entityId === assetId && - (meta.assetSha256 === selected.asset.sha256 || (meta.assetSha256 === undefined && meta.lastAppliedHash === managedStateHash(entity.state) && entity.state.sha256 === selected.asset.sha256))))) { - throw new Error('the exact owned attachment version already exists; reconcile the started operation instead of importing again'); + for (const entity of before.entities) { + if (entity.kind !== 'attachment') continue; + const owner = entity.ownership.find(meta => meta.siteId === plan.siteId && meta.entityId === assetId); + if (!owner) continue; + if (!/^[a-f0-9]{64}$/.test(owner.assetSha256 ?? '')) throw new Error('owned attachment metadata has no source version hash'); + if (owner.assetSha256 === selected.asset.sha256) { + throw new Error('the exact owned attachment version already exists; reconcile the started operation instead of importing again'); + } } if (before.siteRecord?.entities[selected.key] !== undefined) throw new Error('site record already indexes the exact attachment version'); @@ -259,13 +263,8 @@ $result = (function() use ($expected, &$attempted) { foreach (get_posts(array('post_type'=>'attachment', 'post_status'=>array('inherit','private','trash'), 'numberposts'=>-1, 'orderby'=>'ID', 'order'=>'ASC', 'suppress_filters'=>true)) as $existing) { $owned = get_post_meta($existing->ID, '${SITE_OWNERSHIP_META}', true); if (!is_array($owned) || ($owned['siteId'] ?? null) !== $expected['siteId'] || ($owned['entityId'] ?? null) !== $expected['entityId']) continue; + if (!isset($owned['assetSha256']) || !is_string($owned['assetSha256']) || !preg_match('/^[a-f0-9]{64}$/', $owned['assetSha256'])) return array('status'=>'refused'); if (($owned['assetSha256'] ?? null) === $expected['assetSha256']) return array('status'=>'refused'); - if (!isset($owned['assetSha256'])) { - $existingOriginal = wp_get_original_image_path($existing->ID); if (!$existingOriginal) $existingOriginal = get_attached_file($existing->ID); - $existingHash = $existingOriginal && is_file($existingOriginal) && !is_link($existingOriginal) ? hash_file('sha256', $existingOriginal) : null; - $legacyHash = 'sha256:' . hash('sha256', wp_json_encode(array('sha256'=>$expected['assetSha256']))); - if ($existingHash === $expected['assetSha256'] && ($owned['lastAppliedHash'] ?? null) === $legacyHash) return array('status'=>'refused'); - } } $attempted = true; diff --git a/src/site/media.ts b/src/site/media.ts index c6a8041..70005ed 100644 --- a/src/site/media.ts +++ b/src/site/media.ts @@ -36,12 +36,11 @@ export function verifiedOwnedAttachment( const owner = entity.ownership[0]!; if (owner.version !== 1 || owner.siteId !== siteId || owner.entityId !== entityId || owner.kind !== 'attachment' || !/^sha256:[a-f0-9]{64}$/.test(owner.lastAppliedHash) || owner.lastAppliedHash !== hashCanonicalJson(entity.state)) return false; - const ownerSha = owner.assetSha256 ?? (typeof entity.state.sha256 === 'string' ? entity.state.sha256 : undefined); + const ownerSha = owner.assetSha256; if (ownerSha !== originalSha256 || entity.state.sha256 !== originalSha256) return false; const versionClaims = snapshot.entities.flatMap(candidate => candidate.kind !== 'attachment' ? [] : candidate.ownership .filter(claim => claim.siteId === siteId && claim.entityId === entityId - && (claim.assetSha256 === originalSha256 - || (claim.assetSha256 === undefined && claim.lastAppliedHash === hashCanonicalJson(candidate.state) && candidate.state.sha256 === originalSha256))) + && claim.assetSha256 === originalSha256) .map(claim => ({ candidate, claim }))); if (versionClaims.length !== 1 || versionClaims[0]!.candidate.id !== wpId) return false; return siteRecordIndexesEntity(snapshot, siteId, entity); diff --git a/src/site/ownership.ts b/src/site/ownership.ts index 79799ee..5118608 100644 --- a/src/site/ownership.ts +++ b/src/site/ownership.ts @@ -6,10 +6,7 @@ export function siteRecordIndexesEntity(snapshot: SiteSnapshot, siteId: string, const owner = entity.ownership.length === 1 ? entity.ownership[0] : undefined; if (!record || record.version !== 1 || record.siteId !== siteId || !owner || owner.siteId !== siteId || owner.kind !== entity.kind) return false; if (entity.kind !== 'attachment') return record.entities[`${owner.kind}:${owner.entityId}`] === entity.id; - const sha256 = owner.assetSha256 ?? entity.state.sha256; + const sha256 = owner.assetSha256; if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(sha256)) return false; - const versionId = record.entities[`attachment:${owner.entityId}@${sha256}`]; - const legacyId = owner.assetSha256 === undefined ? record.entities[`attachment:${owner.entityId}`] : undefined; - if (versionId !== undefined && legacyId !== undefined && versionId !== legacyId) return false; - return versionId === entity.id || legacyId === entity.id; + return record.entities[`attachment:${owner.entityId}@${sha256}`] === entity.id; } From 8485bf3d2efe371202dfa8b77fbca56492527a53 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:35:36 +0200 Subject: [PATCH 21/38] fix: verify media import asset root identity --- dev/test/site-media-import.test.ts | 8 ++++++++ src/site/media-import.ts | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/dev/test/site-media-import.test.ts b/dev/test/site-media-import.test.ts index faa2cfe..7703a63 100644 --- a/dev/test/site-media-import.test.ts +++ b/dev/test/site-media-import.test.ts @@ -188,6 +188,14 @@ echo json_encode($ownershipHash);`; throw error; } expect(JSON.parse(hashOutput)).toBe(managedStateHash({ sha256: hash })); + const rootGuard = script.match(/(\$rootInput = \$expected\['assetRoot'\];[\s\S]*?if \(!\$root \|\| !is_dir\(\$root\) \|\| \$resolvedRoot !== \$approvedRoot\) return array\('status'=>'refused'\);)/)?.[1]; + expect(rootGuard).toBeDefined(); + const aliasRoot = path.join(ready.root, 'asset-root-alias'); + await symlink(ready.root, aliasRoot); + const rootCheckPhp = `$check = function($expected) { ${rootGuard} return array('status'=>'approved'); }; +echo json_encode(array($check(array('assetRoot'=>$argv[1]))['status'], $check(array('assetRoot'=>$argv[2]))['status']));`; + const { stdout: rootCheckOutput } = await run('php', ['-r', rootCheckPhp, ready.root, aliasRoot], { encoding: 'utf8' }); + expect(JSON.parse(rootCheckOutput)).toEqual(['approved', 'refused']); expect(binding).toMatchObject({ id: 44, url: 'https://example.test/uploads/hero-3.png', observedSize: { width: 1200, height: 800 }, imageSizes: { full: { url: 'https://example.test/uploads/hero-3.png', width: 1200, height: 800 } } }); }); diff --git a/src/site/media-import.ts b/src/site/media-import.ts index 502f3c1..14fca6d 100644 --- a/src/site/media-import.ts +++ b/src/site/media-import.ts @@ -248,8 +248,11 @@ $result = (function() use ($expected, &$attempted) { || ($step['kind'] ?? null) !== 'attachment' || ($step['entityId'] ?? null) !== $expected['entityId'] || ($step['assetSha256'] ?? null) !== $expected['assetSha256'] || ($step['status'] ?? null) !== 'started') return array('status'=>'refused'); - $root = realpath($expected['assetRoot']); - if (!$root || !is_dir($root)) return array('status'=>'refused'); + $rootInput = $expected['assetRoot']; + $approvedRoot = rtrim($rootInput, DIRECTORY_SEPARATOR); if ($approvedRoot === '') $approvedRoot = DIRECTORY_SEPARATOR; + $root = realpath($rootInput); + $resolvedRoot = $root ? rtrim($root, DIRECTORY_SEPARATOR) : ''; if ($resolvedRoot === '') $resolvedRoot = DIRECTORY_SEPARATOR; + if (!$root || !is_dir($root) || $resolvedRoot !== $approvedRoot) return array('status'=>'refused'); $cursor = $root; foreach (explode('/', $expected['relativePath']) as $part) { $cursor = $cursor . DIRECTORY_SEPARATOR . $part; From 0874d2321a4b1c0b2435d2df247edd1168a7a812 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:37:29 +0200 Subject: [PATCH 22/38] perf(site): verify captured source documents before effects --- dev/test/site-apply-wpcli.test.ts | 7 ++-- dev/test/site-apply.test.ts | 8 ----- dev/test/site-plan.test.ts | 38 ++++++++++++++++++--- docs/reference.md | 11 +++--- src/publication.ts | 18 ++++------ src/site/apply-wpcli.ts | 5 --- src/site/apply.ts | 21 +++--------- src/site/drift.ts | 11 +++--- src/site/plan.ts | 56 +++++++++++++++++++++---------- 9 files changed, 98 insertions(+), 77 deletions(-) diff --git a/dev/test/site-apply-wpcli.test.ts b/dev/test/site-apply-wpcli.test.ts index 3c71b33..99997d2 100644 --- a/dev/test/site-apply-wpcli.test.ts +++ b/dev/test/site-apply-wpcli.test.ts @@ -22,8 +22,8 @@ async function execute(request:SiteApplyRequest,flags:Record={}, const stub=` $d=json_decode(base64_decode('${data}'),true);$flags=$d['flags'];$writes=0; $options=array($d['option']=>array('version'=>1,'siteId'=>'sample','entities'=>array(),'options'=>array(),'operation'=>array('token'=>'token','planHash'=>'confirmed','status'=>'started','currentStep'=>$d['step'],'stepEvidence'=>$d['evidence'])),$d['lock']=>'token','show_on_front'=>'posts','page_on_front'=>'0'); -if(isset($flags['noLock']))unset($options[$d['lock']]);if(isset($flags['legacyLock']))$options[$d['option'].'_lock']='legacy'; -class TestWpdb {public $options='options';function esc_like($s){return addcslashes($s,'_%');}function prepare($sql,$pattern){return $sql;}function get_col($sql){global $options;return array_keys($options);}}$wpdb=new TestWpdb(); +if(isset($flags['noLock']))unset($options[$d['lock']]); + if(isset($flags['wrongToken']))$options[$d['lock']]='foreign';if(isset($flags['wrongStep']))$options[$d['option']]['operation']['currentStep']['entityId']='foreign'; $p=(object)array('ID'=>22,'post_type'=>'page','post_content'=>$d['state']['content'],'post_title'=>$d['state']['title'],'post_status'=>$d['state']['status'],'post_name'=>'home','post_parent'=>0,'post_author'=>31,'post_excerpt'=>'Human excerpt','post_date'=>'2025-02-03 04:05:06','post_date_gmt'=>'2025-02-03 03:05:06','menu_order'=>19); if(isset($flags['humanStatus']))$p->post_status='private'; @@ -44,10 +44,9 @@ function add_post_meta($id,$k,$v,$unique){global $meta,$flags;if(isset($flags['o describe('fixed PHP mutation boundary executed with bounded WordPress stubs',()=>{ it('sanitizes thrown transport stderr without attaching raw output or a cause',async()=>{let error:unknown;try{await siteApplyWpCli({wpPath:'/wp'},boundary,{action:'lock'},async()=>{throw Object.assign(new Error('bootstrap SENTINEL_PRIVATE_WP_CREDENTIAL'),{stderr:'SENTINEL_PRIVATE_WP_CREDENTIAL'});});}catch(value){error=value;}expect(error).toBeInstanceOf(Error);expect((error as Error).message).toContain('site apply transport failed');expect(String(error)).not.toContain('SENTINEL_PRIVATE_WP_CREDENTIAL');expect(JSON.stringify(error)).not.toContain('SENTINEL_PRIVATE_WP_CREDENTIAL');}); it.each(['SENTINEL_PRIVATE_WP_CREDENTIAL','null'])('sanitizes invalid boundary output %s',async(output)=>{await expect(siteApplyWpCli({wpPath:'/wp'},boundary,{action:'lock'},async()=>output)).rejects.toThrow('invalid boundary readback');}); - it('locks the blog across namespaces, refuses the same token and legacy locks, and releases only its owner',async()=>{ + it('locks the blog across namespaces, refuses the same token, and releases only its owner',async()=>{ expect((await execute({action:'lock'})).response.status).toBe('refused'); expect((await execute({action:'lock'},{},{...boundary,siteId:'other',token:'other-token'})).response.status).toBe('refused'); - expect((await execute({action:'lock'},{noLock:true,legacyLock:true})).response.status).toBe('refused'); expect((await execute({action:'lock'},{noLock:true})).response.status).toBe('complete'); expect((await execute({action:'lock'},{noLock:true,blogId:2},{...boundary,target:{...boundary.target,blogId:2}})).response.status).toBe('complete'); expect((await execute({action:'unlock'},{wrongToken:true})).response.status).toBe('refused'); diff --git a/dev/test/site-apply.test.ts b/dev/test/site-apply.test.ts index df91a82..a8e7684 100644 --- a/dev/test/site-apply.test.ts +++ b/dev/test/site-apply.test.ts @@ -2,7 +2,6 @@ import { mkdtemp, realpath, writeFile, readFile, copyFile, chmod } from 'node:fs import path from 'node:path'; import os from 'node:os'; import { hashCanonicalJson } from '../../src/proof/receipt.js'; -import { hashBytes } from '../../src/publication.js'; import { describe, it, expect } from 'vitest'; import { applySite, type SiteApplyOptions } from '../../src/site/apply.js'; import { readSitePlanInput, reconcileSitePlan, planPreparedSite, managedStateHash } from '../../src/site/plan.js'; @@ -70,13 +69,6 @@ describe('confirmed database apply',()=>{ }}); expect(result.status).toBe('partial');expect(result.error).toContain('live state changed during marker persistence');expect(f.count()).toBe(0); }); - it('keeps previously confirmed insertion-order source identities exact during semantic reconstruction',async()=>{ - const f=await fixture();const input=await readSitePlanInput(f.source,f.root); - input.source.sourceIdentity=hashBytes(Buffer.from(JSON.stringify(input.source.sourceIntent))); - const plan=await planPreparedSite(input,structuredClone(f.live));const confirmed=plan.confirmationHash; - const result=await applySite(plan,{...f.options,confirm:confirmed},f.transports); - expect(result.status).toBe('completed');expect(result.planHash).toBe(confirmed);expect(plan.sourceIdentity).toBe(input.source.sourceIdentity);expect(f.count()).toBe(3); - }); it.each(['none','live','source'] as const)('checks retained items together and refuses late %s changes',async(change)=>{ const f=await fixture();expect((await applySite(f.plan,f.options,f.transports)).status).toBe('completed'); const input=await readSitePlanInput(f.source,f.root);const plan=await planPreparedSite(input,structuredClone(f.live)); diff --git a/dev/test/site-plan.test.ts b/dev/test/site-plan.test.ts index 0d56043..00e0b97 100644 --- a/dev/test/site-plan.test.ts +++ b/dev/test/site-plan.test.ts @@ -3,14 +3,14 @@ import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; import { compileThemeDocuments } from '../../src/site/compile.js'; import { writeCompiledSite } from '../../src/site/write.js'; -import { mkdtemp, mkdir, writeFile, symlink, realpath, readFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, symlink, realpath, readFile, rename, copyFile } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { hashBytes } from '../../src/publication.js'; import { prepareSiteSource } from '../../src/site/input.js'; import { describe, expect, it } from 'vitest'; import { readSiteSnapshot, siteRecordOption } from '../../src/site/wpcli.js'; -import { managedStateHash, reconcileSitePlan, readSitePlanInput, observedAttachmentBindings, planSite } from '../../src/site/plan.js'; +import { managedStateHash, reconcileSitePlan, readSitePlanInput, observedAttachmentBindings, planSite, assertSitePlanDocumentsUnchanged } from '../../src/site/plan.js'; import type { SiteSnapshot, SiteEntity } from '../../src/site/plan-types.js'; import type { PreparedSiteSource, ThemeArtifact, CompiledSitePage } from '../../src/site/types.js'; @@ -112,15 +112,45 @@ describe('source and retained bundle boundaries',()=>{ const input=source().sourceIntent;input.templates.index!.body.blocks=[{block:'core/template-part',attrs:{slug:'missing'}}];await writeFile(file,JSON.stringify(input)); await expect(readSitePlanInput(file,root)).rejects.toThrow('unknown template part'); }); + it.each(['same-length edit', 'replacement'])('detects a source %s from its captured file identity', async (change) => { + const root = await mkdtemp(path.join(await realpath(os.tmpdir()), 'site-plan-identity-')); + const file = path.join(root, 'intent.json'); + const bytes = JSON.stringify(source().sourceIntent); + await writeFile(file, bytes); + const input = await readSitePlanInput(file, root); + await assertSitePlanDocumentsUnchanged(input); + if (change === 'replacement') { + const replacement = path.join(root, 'replacement.json'); + await writeFile(replacement, bytes); + await rename(replacement, file); + } else await writeFile(file, bytes.replace('Hello', 'World')); + await expect(assertSitePlanDocumentsUnchanged(input)).rejects.toThrow('source bytes changed'); + }); + it('checks source documents without rereading unused assets, then detects asset drift on full verification', async () => { + const root = await mkdtemp(path.join(await realpath(os.tmpdir()), 'site-plan-asset-scan-')); + const file = path.join(root, 'intent.json'); + const image = path.join(root, 'unused.png'); + await copyFile(new URL('./fixtures/site-intent/media/hero.png', import.meta.url), image); + const intent = source().sourceIntent; + intent.assets = [{ id: 'unused', path: 'unused.png', kind: 'image', role: 'attachment' }]; + await writeFile(file, JSON.stringify(intent)); + const input = await readSitePlanInput(file, root); + await writeFile(image, Buffer.from('changed image')); + await expect(assertSitePlanDocumentsUnchanged(input)).resolves.toBeUndefined(); + const refreshed = await readSitePlanInput(file, root); + expect(refreshed.source.assets[0]!.sha256).not.toBe(input.source.assets[0]!.sha256); + }); it('verifies retained source bytes/inventory and rejects corruption and symlink input',async()=>{ const root=await mkdtemp(path.join(await realpath(os.tmpdir()),'site-plan-bundle-'));const assetRoot=root; const input=source().sourceIntent;const bytes=Buffer.from(JSON.stringify(input));await writeFile(path.join(root,'site.intent.json'),bytes); const prepared=await prepareSiteSource(input,assetRoot);if(!prepared.ok)throw new Error('fixture invalid'); const manifest={formatVersion:1,siteId:input.siteId,sourceIdentity:prepared.source.sourceIdentity,sourceAssets:[],targetBindings:{},bundleFiles:[{path:'site.intent.json',sha256:hashBytes(bytes),size:bytes.length}]};await writeFile(path.join(root,'site.manifest.json'),JSON.stringify(manifest)); - expect((await readSitePlanInput(root)).source.sourceIdentity).toBe(prepared.source.sourceIdentity); + const captured = await readSitePlanInput(root); + expect(captured.source.sourceIdentity).toBe(prepared.source.sourceIdentity); manifest.sourceIdentity=hashBytes(Buffer.from(JSON.stringify(prepared.source.sourceIntent))); await writeFile(path.join(root,'site.manifest.json'),JSON.stringify(manifest)); - expect((await readSitePlanInput(root)).source.sourceIdentity).toBe(manifest.sourceIdentity); + await expect(assertSitePlanDocumentsUnchanged(captured)).rejects.toThrow('source bytes changed'); + await expect(readSitePlanInput(root)).rejects.toThrow('retained source identity differs'); await writeFile(path.join(root,'site.intent.json'),Buffer.from('corrupt'));await expect(readSitePlanInput(root)).rejects.toThrow('retained bundle bytes differ'); const linked=path.join(root,'linked');await symlink(root,linked);await expect(readSitePlanInput(linked)).rejects.toThrow('symlinks'); }); diff --git a/docs/reference.md b/docs/reference.md index b09719d..3b2cc28 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -279,15 +279,18 @@ Use the CLI in shell scripts, pre-commit hooks or CI. ### Interrupted site apply +Apply verifies the complete local source and asset inventory at the start and end. +Before each effect it checks captured source-document identities; image imports also +verify the exact approved file bytes before uploading. Other asset changes can leave +approved earlier effects completed, but prevent a completed receipt. + New site plans hash media files owned by Block Runner or explicitly referenced by the source. Other attachments retain their native metadata and numeric IDs for -collision checks. Apply preserves any additional media-byte authority recorded in -an older confirmation. +collision checks. Explicitly referenced media remain byte-checked during apply. `site apply` holds one lock per WordPress blog, shared by every Block Runner site identity. A held lock is refused even when its token matches the journal; a token -cannot prove that an earlier WP-CLI or PHP process has stopped. Older namespace -locks also block new applies. +cannot prove that an earlier WP-CLI or PHP process has stopped. After an interruption, preserve the confirmed plan, journal and WordPress operation marker. First establish that every executor and its WP-CLI/PHP descendants have diff --git a/src/publication.ts b/src/publication.ts index 2575b9f..cf04e50 100644 --- a/src/publication.ts +++ b/src/publication.ts @@ -7,6 +7,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { link, lstat, open, readFile, readlink, rename, unlink } from 'node:fs/promises'; import path from 'node:path'; +import type { BigIntStats } from 'node:fs'; export interface PublicationFileIdentity { dev: string; @@ -52,13 +53,10 @@ export function sameFileIdentity(left: PublicationFileIdentity, right: Publicati } export async function regularFileIdentity(file: string, error: string, includeContent = false): Promise { - const stats = await lstat(file); - if (stats.isSymbolicLink() || !stats.isFile()) throw new Error(error); - const withNs = stats as typeof stats & { mtimeNs?: bigint; ctimeNs?: bigint }; + const stats = await lstat(file, { bigint: true }); + if (!stats.isFile()) throw new Error(error); return { - dev: String(stats.dev), ino: String(stats.ino), mode: Number(stats.mode), size: String(stats.size), - mtimeNs: String(withNs.mtimeNs ?? BigInt(Math.round(Number(stats.mtimeMs) * 1_000_000))), - ctimeNs: String(withNs.ctimeNs ?? BigInt(Math.round(Number(stats.ctimeMs) * 1_000_000))), + ...identityFromStats(stats), ...(includeContent ? { sha256: hashBytes(await readFile(file)) } : {}), }; } @@ -73,7 +71,7 @@ export async function readStableRegularFile(file: string, error: string, maximum } else { const handle = await open(file, 'r'); try { - const opened = await handle.stat(); + const opened = await handle.stat({ bigint: true }); if (!opened.isFile() || !sameFileIdentity(before, identityFromStats(opened))) throw new Error(error); const chunks: Buffer[] = []; let total = 0; @@ -95,12 +93,10 @@ export async function readStableRegularFile(file: string, error: string, maximum return { content, identity: after }; } -function identityFromStats(stats: Awaited>): PublicationFileIdentity { - const withNs = stats as typeof stats & { mtimeNs?: bigint; ctimeNs?: bigint }; +function identityFromStats(stats: BigIntStats): PublicationFileIdentity { return { dev: String(stats.dev), ino: String(stats.ino), mode: Number(stats.mode), size: String(stats.size), - mtimeNs: String(withNs.mtimeNs ?? BigInt(Math.round(Number(stats.mtimeMs) * 1_000_000))), - ctimeNs: String(withNs.ctimeNs ?? BigInt(Math.round(Number(stats.ctimeMs) * 1_000_000))), + mtimeNs: String(stats.mtimeNs), ctimeNs: String(stats.ctimeNs), }; } diff --git a/src/site/apply-wpcli.ts b/src/site/apply-wpcli.ts index c1d5aea..2bdc9a7 100644 --- a/src/site/apply-wpcli.ts +++ b/src/site/apply-wpcli.ts @@ -135,11 +135,6 @@ $result=(function() use($e,$r,&$attempted,$same,$state) { usort($inventory,function($a,$b){return strcmp($a['path'],$b['path']);}); if(!$same($inventory,$e['theme']['inventory'])) return array('status'=>'refused'); if($r['action']==='lock') { - // A previous namespace lock cannot be silently bypassed during the transition. - global $wpdb; - $pattern=$wpdb->esc_like('_block_runner_site_').'%' . $wpdb->esc_like('_lock'); - $legacyLocks=$wpdb->get_col($wpdb->prepare("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",$pattern)); - foreach($legacyLocks as $legacy) if(preg_match('/^_block_runner_site_[a-f0-9]{64}_lock$/',$legacy) && get_option($legacy,null)!==null) return array('status'=>'refused'); // A matching token identifies an operation, not one still-running process. return array('status'=>add_option($e['lock'],$e['token'],'',false)?'complete':'refused'); } diff --git a/src/site/apply.ts b/src/site/apply.ts index f771a84..c324200 100644 --- a/src/site/apply.ts +++ b/src/site/apply.ts @@ -4,10 +4,9 @@ import path from 'node:path'; import { readStableRegularFile } from '../publication.js'; import { hashCanonicalJson } from '../proof/receipt.js'; import { compileSite, createSiteSyncedPatternCompiler, compileThemeDocuments } from './compile.js'; -import { readSitePlanInput, planPreparedSite, observedAttachmentBindings, managedStateHash } from './plan.js'; +import { readSitePlanInput, planPreparedSite, observedAttachmentBindings, managedStateHash, assertSitePlanDocumentsUnchanged } from './plan.js'; import { importPlannedSiteAttachmentStep, SiteMediaImportOutcomeUnknown } from './media-import.js'; import { attachmentVersionKey } from './media.js'; -import { siteSourceIdentityMatches } from './input.js'; import { syncedPatternOrder } from './patterns.js'; import { readSiteSnapshot, type SiteWpCliOptions, type SiteReadTransport } from './wpcli.js'; import { siteApplyWpCli, type SiteApplyBoundary, type SiteApplyRequest } from './apply-wpcli.js'; @@ -108,7 +107,7 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans } // Use the confirmed routes even when they have no current numeric ID. const desiredRoutes = plan.snapshot.routing.routes.map(route => route.route); - // Retain explicit byte authority in existing confirmations, including legacy whole-library snapshots. + // Retain explicit byte authority in existing confirmations, for explicitly referenced media IDs. const mediaHashIds = plan.snapshot.entities.filter(entity => entity.kind === 'attachment' && typeof entity.state.sha256 === 'string').map(entity => entity.id); const read = () => readSiteSnapshot(options, plan.siteId, transports.read, desiredRoutes, mediaHashIds); let live = await read(); @@ -137,7 +136,7 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans } if (!plan.executable || plan.conflicts.length) throw new Error('plan is not executable'); const input = await readSitePlanInput(options.source, options.assetRoot); - if (!siteSourceIdentityMatches(input.source.sourceIntent, plan.sourceIdentity) || input.source.sourceIntent.siteId !== plan.siteId || !same(input.source.assets.map(({ + if (input.source.sourceIdentity !== plan.sourceIdentity || input.source.sourceIntent.siteId !== plan.siteId || !same(input.source.assets.map(({ id, path, sha256 @@ -146,7 +145,6 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans path, sha256 })), plan.assets)) throw new Error('source or asset bytes changed after confirmation'); - input.source.sourceIdentity = plan.sourceIdentity; // Reconstruct the semantic plan from retained source and its confirmed baseline, never trusting edited operation claims. const semantic = await planPreparedSite(input, plan.snapshot, options); if (semantic.confirmationHash !== plan.confirmationHash) throw new Error('confirmed plan operations do not match source and baseline reconciliation'); @@ -308,16 +306,7 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans // Retained operations have no effects. Initial and final full observations bind them. if (op.action === 'retain') return; // mark() performs the fresh full read and comparison before the effect, after journal persistence. - const refreshed = await readSitePlanInput(options.source, options.assetRoot); - if (!siteSourceIdentityMatches(refreshed.source.sourceIntent, plan.sourceIdentity) || !same(refreshed.source.assets.map(({ - id, - path, - sha256 - }) => ({ - id, - path, - sha256 - })), plan.assets)) throw new Error('source bytes changed immediately before mutation'); + await assertSitePlanDocumentsUnchanged(input); if (op.action === 'update') { const entity = live.entities.find(entity => entity.id === op.id); const owner = entity?.ownership.length === 1 ? entity.ownership[0] : undefined; @@ -520,7 +509,7 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans assertIdentity(); if (!same(comparable(live), comparable(journal.observed))) throw new Error('live state changed before completion; prepare a fresh plan'); const finalSource = await readSitePlanInput(options.source, options.assetRoot); - if (!siteSourceIdentityMatches(finalSource.source.sourceIntent, plan.sourceIdentity) || !same(finalSource.source.assets.map(({ + if (finalSource.source.sourceIdentity !== plan.sourceIdentity || !same(finalSource.source.assets.map(({ id, path, sha256 diff --git a/src/site/drift.ts b/src/site/drift.ts index 67d54e7..52d8d7f 100644 --- a/src/site/drift.ts +++ b/src/site/drift.ts @@ -207,7 +207,7 @@ function inspectManagedEntities(snapshot: SiteSnapshot, siteId: string, desiredP if (owner.siteId !== siteId) continue; if (entity.kind === 'attachment') { const baselineMatches = managedStateHash(entity.state) === owner.lastAppliedHash; - const originalSha = owner.assetSha256 ?? (baselineMatches && typeof entity.state.sha256 === 'string' ? entity.state.sha256 : undefined); + const originalSha = owner.assetSha256; const valid = owner.version === 1 && owner.kind === 'attachment' && Boolean(owner.entityId) && /^sha256:[a-f0-9]{64}$/.test(owner.lastAppliedHash) && typeof originalSha === 'string' && /^[a-f0-9]{64}$/.test(originalSha); @@ -259,12 +259,9 @@ function inspectManagedEntities(snapshot: SiteSnapshot, siteId: string, desiredP for (const [key, id] of Object.entries(previous)) { if (key.startsWith('attachment:')) { const version = /^attachment:([^@]+)@([a-f0-9]{64})$/.exec(key); - const legacy = /^attachment:([^@]+)$/.exec(key); - const entity = snapshot.entities.find(item => item.id === id && item.kind === 'attachment' && item.ownership.some(owner => { - if (owner.siteId !== siteId || owner.kind !== 'attachment' || owner.entityId !== (version?.[1] ?? legacy?.[1])) return false; - if (version) return (owner.assetSha256 ?? (owner.lastAppliedHash === managedStateHash(item.state) ? item.state.sha256 : undefined)) === version[2]; - return Boolean(legacy && owner.assetSha256 === undefined && typeof item.state.sha256 === 'string' && /^[a-f0-9]{64}$/.test(item.state.sha256)); - })); + const entity = version && snapshot.entities.find(item => item.id === id && item.kind === 'attachment' && item.ownership.some(owner => + owner.siteId === siteId && owner.kind === 'attachment' && owner.entityId === version[1] && owner.assetSha256 === version[2] + )); if (!entity) add({ kind: 'attachment', status: 'missing', entityId: key, wpId: id, code: 'missing-owned-attachment', message: 'A previously managed exact attachment version is absent or its numeric ID/ownership identity changed.' }); continue; } diff --git a/src/site/plan.ts b/src/site/plan.ts index 717ef2a..8fd6a9f 100644 --- a/src/site/plan.ts +++ b/src/site/plan.ts @@ -2,8 +2,8 @@ import { compileThemeDocuments, compileSitePageDocuments } from './compile.js'; import { readSiteSnapshot, type SiteWpCliOptions, type SiteReadTransport } from './wpcli.js'; import { lstat } from 'node:fs/promises'; import path from 'node:path'; -import { hashBytes, readStableRegularFile } from '../publication.js'; -import { prepareSiteSource, siteSourceIdentityMatches } from './input.js'; +import { hashBytes, readStableRegularFile, regularFileIdentity, sameFileIdentity, type PublicationFileIdentity } from '../publication.js'; +import { prepareSiteSource } from './input.js'; import { hashCanonicalJson } from '../proof/receipt.js'; import type { PreparedSiteSource, CompiledSitePage, SiteInputDiagnostic, ThemeArtifact, SiteAttachmentBinding } from './types.js'; import type { SiteEntity, SitePlan, SitePlanOperation, SiteSnapshot } from './plan-types.js'; @@ -88,8 +88,8 @@ export function reconcileSitePlan(source: PreparedSiteSource, theme: ThemeArtifa if (ownership.version !== 1 || ownership.kind !== entity.kind || !ownership.entityId || !ownership.lastAppliedHash || entity.kind !== 'attachment' && ownership.assetSha256 !== undefined || ownership.assetSha256 !== undefined && !/^[a-f0-9]{64}$/.test(ownership.assetSha256)) issue(`/target/entities/${entity.id}`, 'invalid-ownership', 'ownership metadata is incomplete or inconsistent');else if (!baselineMatches) issue(`/target/entities/${entity.id}`, 'managed-state-changed', 'current managed fields differ from the complete last-applied baseline'); if (entity.kind === 'attachment') { if (entity.postStatus !== 'inherit') issue(`/target/entities/${entity.id}`, 'unavailable-owned-attachment', 'only inherited attachments are available for exact owned media reuse'); - const sha256 = ownership.assetSha256 ?? (baselineMatches && typeof entity.state.sha256 === 'string' ? entity.state.sha256 : undefined); - if (!sha256 || !/^[a-f0-9]{64}$/.test(sha256) || ownership.assetSha256 !== undefined && entity.state.sha256 !== sha256) { + const sha256 = ownership.assetSha256; + if (!sha256 || !/^[a-f0-9]{64}$/.test(sha256) || entity.state.sha256 !== sha256) { issue(`/target/entities/${entity.id}`, 'invalid-attachment-version', 'owned attachment has no verified original-byte version'); continue; } @@ -104,8 +104,7 @@ export function reconcileSitePlan(source: PreparedSiteSource, theme: ThemeArtifa if (!siteRecordIndexesEntity(snapshot, intent.siteId, entity)) issue(`/target/entities/${entity.id}`, 'ownership-record-missing', `owned entity ${key} has no matching site-record ID`); } for (const [key, id] of Object.entries(snapshot.siteRecord?.entities ?? {})) { - const legacyMatch = /^attachment:([^@]+)$/.exec(key); - const entity = owned.get(key) ?? (legacyMatch ? snapshot.entities.find(item => item.kind === 'attachment' && item.id === id && item.ownership.some(meta => meta.siteId === intent.siteId && meta.entityId === legacyMatch[1] && meta.assetSha256 === undefined && meta.lastAppliedHash === managedStateHash(item.state))) : undefined); + const entity = owned.get(key); if (!entity || entity.id !== id) issue(`/target/entities/${id}`, 'missing-owned-id', `previously owned entity ${key} is missing or its metadata changed`); } const desiredKeys = new Set(); @@ -116,7 +115,7 @@ export function reconcileSitePlan(source: PreparedSiteSource, theme: ThemeArtifa const existing = owned.get(key); const external = !existing && bindings[asset.id]; const boundEntity = external ? snapshot.entities.find(entity => entity.id === external.id && entity.kind === 'attachment') : undefined; - if (external && (!boundEntity || boundEntity.state.sha256 !== asset.sha256 || boundEntity.attachment?.url !== external.url || !boundEntity.ownership.some(meta => meta.siteId === intent.siteId && meta.entityId === asset.id && (meta.assetSha256 ?? (meta.lastAppliedHash === managedStateHash(boundEntity.state) ? boundEntity.state.sha256 : undefined)) === asset.sha256))) { + if (external && (!boundEntity || boundEntity.state.sha256 !== asset.sha256 || boundEntity.attachment?.url !== external.url || !boundEntity.ownership.some(meta => meta.siteId === intent.siteId && meta.entityId === asset.id && meta.assetSha256 === asset.sha256))) { issue(`/assets/${asset.id}`, 'binding-target-mismatch', 'attachment binding does not match exact owned destination identity and bytes'); } const desired = { @@ -320,6 +319,21 @@ export interface SitePlanInput { artifactIdentity?: string; } +// Ephemeral identities come from the reads that supplied this invocation's approved bytes. +const sourceDocuments = new WeakMap>(); + +/** Check source documents before an effect without rereading every declared asset. */ +export async function assertSitePlanDocumentsUnchanged(input: SitePlanInput): Promise { + const documents = sourceDocuments.get(input); + if (!documents) throw new Error('site input has no captured source document identities'); + for (const [file, identity] of documents) { + await assertSafePath(file); + if (!sameFileIdentity(identity, await regularFileIdentity(file, 'source bytes changed immediately before mutation'))) { + throw new Error('source bytes changed immediately before mutation'); + } + } +} + /** Read either an explicit source file or the retained source of a stable compiled bundle. */ export async function readSitePlanInput(inputPath: string, assetRoot?: string): Promise { const input = path.resolve(inputPath); @@ -330,17 +344,23 @@ export async function readSitePlanInput(inputPath: string, assetRoot?: string): const file = await readStableRegularFile(input, 'site intent changed while reading'); const prepared = await prepareSiteSource(JSON.parse(file.content.toString('utf8')), assetRoot); if (!prepared.ok) throw new Error(prepared.diagnostics.map(item => `${item.path}: ${item.message}`).join('\n')); - return { - source: prepared.source, - retainedBindings: {} - }; + const result: SitePlanInput = { source: prepared.source, retainedBindings: {} }; + sourceDocuments.set(result, new Map([[input, file.identity]])); + return result; } if (!info.isDirectory()) throw new Error('site plan input must be a regular source file or bundle directory'); + const documents = new Map(); const read = async (relative: string): Promise => { if (!relative || relative.includes('\\') || relative.includes('\0') || relative.split('/').some(component => !component || component === '.' || component === '..') || path.isAbsolute(relative)) throw new Error('unsafe retained bundle path'); const file = path.join(input, ...relative.split('/')); await assertSafePath(file); - return (await readStableRegularFile(file, 'retained bundle file changed while reading')).content; + const observed = await readStableRegularFile(file, 'retained bundle file changed while reading'); + if (relative === 'site.intent.json' || relative === 'site.manifest.json') { + const previous = documents.get(file); + if (previous && !sameFileIdentity(previous, observed.identity)) throw new Error('retained source document changed while reading bundle'); + documents.set(file, observed.identity); + } + return observed.content; }; const manifestBytes = await read('site.manifest.json'); const manifest = JSON.parse(manifestBytes.toString('utf8')) as { @@ -380,9 +400,7 @@ export async function readSitePlanInput(inputPath: string, assetRoot?: string): const prepared = await prepareSiteSource(retainedIntent, retainedAssetRoot); if (!prepared.ok) throw new Error(prepared.diagnostics.map(item => `${item.path}: ${item.message}`).join('\n')); const source = prepared.source; - if (source.sourceIntent.siteId !== manifest.siteId || !siteSourceIdentityMatches(source.sourceIntent, manifest.sourceIdentity)) throw new Error('retained source identity differs from bundle manifest'); - // Keep persisted identity exact: accepting old insertion-order hashes must not rewrite confirmations. - source.sourceIdentity = manifest.sourceIdentity; + if (source.sourceIntent.siteId !== manifest.siteId || source.sourceIdentity !== manifest.sourceIdentity) throw new Error('retained source identity differs from bundle manifest'); const assets = source.assets.map(asset => ({ id: asset.id, path: asset.path, @@ -394,7 +412,7 @@ export async function readSitePlanInput(inputPath: string, assetRoot?: string): if (hashCanonicalJson(assets) !== hashCanonicalJson(manifest.sourceAssets)) throw new Error('retained source asset inventory differs from bundle manifest'); if (source.assets.some(asset => !paths.has(`source/assets/${asset.path}`))) throw new Error('retained manifest omits declared source asset bytes'); if (!(await read('site.manifest.json')).equals(manifestBytes)) throw new Error('retained manifest changed while reading bundle'); - return { + const result: SitePlanInput = { source, retainedBindings: manifest.targetBindings, ...(manifest.themeAssetBaseUrl !== undefined ? { @@ -407,6 +425,8 @@ export async function readSitePlanInput(inputPath: string, assetRoot?: string): artifactIdentity: manifest.artifactIdentity } : {}) }; + sourceDocuments.set(result, documents); + return result; } async function assertSafePath(file: string): Promise { const root = path.parse(file).root; @@ -424,14 +444,14 @@ export function observedAttachmentBindings(input: SitePlanInput, snapshot: SiteS for (const asset of input.source.assets.filter(item => item.role === 'attachment')) { const retained = input.retainedBindings[asset.id]; const ownedVersions = snapshot.entities.filter(entity => entity.kind === 'attachment' && entity.ownership.some(meta => meta.siteId === input.source.sourceIntent.siteId && meta.entityId === asset.id)); - const candidates = ownedVersions.filter(entity => entity.ownership.some(meta => meta.siteId === input.source.sourceIntent.siteId && meta.entityId === asset.id && (meta.assetSha256 ?? (meta.lastAppliedHash === managedStateHash(entity.state) ? entity.state.sha256 : undefined)) === asset.sha256)); + const candidates = ownedVersions.filter(entity => entity.ownership.some(meta => meta.siteId === input.source.sourceIntent.siteId && meta.entityId === asset.id && meta.assetSha256 === asset.sha256)); if (candidates.length > 1) throw new Error(`duplicate owned attachment version for ${asset.id}`); const observed = retained ? snapshot.entities.find(entity => entity.kind === 'attachment' && entity.id === retained.id) : candidates[0]; if (!observed) { if (retained) throw new Error(`retained attachment binding ${asset.id} is absent on the observed target`); continue; } - const exactOwner = observed.ownership.some(meta => meta.siteId === input.source.sourceIntent.siteId && meta.entityId === asset.id && (meta.assetSha256 ?? (meta.lastAppliedHash === managedStateHash(observed.state) ? observed.state.sha256 : undefined)) === asset.sha256); + const exactOwner = observed.ownership.some(meta => meta.siteId === input.source.sourceIntent.siteId && meta.entityId === asset.id && meta.assetSha256 === asset.sha256); if (observed.postStatus !== 'inherit' || !exactOwner || observed.state.sha256 !== asset.sha256 || typeof observed.attachment?.url !== 'string' || retained && observed.attachment?.url !== retained.url) throw new Error(`attachment binding ${asset.id} is unavailable or differs from exact owned destination bytes or URL`); const url = new URL(observed.attachment!.url!); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('attachment URL contains unsupported identity or secret fields'); From 80d1f611a776df5dd731a2fa8f16c360fce138bf Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:39:38 +0200 Subject: [PATCH 23/38] refactor(proof): remove fixture-only CLI constraints --- dev/test/proof-site.test.ts | 6 ++++-- src/cli.ts | 6 +----- src/proof/runner.ts | 2 +- src/proof/site.ts | 6 +++--- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/dev/test/proof-site.test.ts b/dev/test/proof-site.test.ts index 2c3b5a7..d405b80 100644 --- a/dev/test/proof-site.test.ts +++ b/dev/test/proof-site.test.ts @@ -14,10 +14,11 @@ import { evaluateProofProfile, PROOF_PROFILES } from '../../src/proof/profiles.j import type { ProofCommandResult } from '../../src/proof/runner.js'; const fixture = fileURLToPath(new URL('./fixtures/site-intent', import.meta.url)); -async function bundle(published = true) { +async function bundle(published = true, pageCount = 5) { const root = await mkdtemp(path.join(await realpath(tmpdir()), 'block-runner-proof-site-')); await cp(fixture, path.join(root, 'source'), { recursive: true }); const intent = JSON.parse(await readFile(path.join(root, 'source/site.intent.json'), 'utf8')); + if (pageCount !== 5) intent.pages = intent.pages.slice(0, pageCount); if (published) for (const page of intent.pages) page.status = 'publish'; const result = await prepareSiteSource(intent, path.join(root, 'source')); if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); @@ -49,8 +50,9 @@ describe('controlled site proof', () => { it('pins the explicit published variant without promoting the ordinary draft fixture', async () => { const draft = await bundle(false); await expect(prepareSiteProof(draft.directory, draft.evidence)).rejects.toThrow('draft statuses are never promoted'); - const control = await bundle(); + const control = await bundle(true, 2); const prepared = await prepareSiteProof(control.directory, control.evidence); + expect(prepared.source.sourceIntent.pages).toHaveLength(2); expect(prepared.theme.slug).toBe('sample-studio'); expect(prepared.bundleHash).toMatch(/^sha256:/); expect(prepared.source.sourceIntent.pages.every((page) => page.status === 'publish')).toBe(true); diff --git a/src/cli.ts b/src/cli.ts index 90e242f..8a930b2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -124,7 +124,6 @@ interface ProofCliOptions { wpEnvConfig?: string; theme?: string; site?: string; - siteNegativeControl?: 'missing-content' | 'broken-asset' | 'wrong-target' | 'accordion-mismatch' | 'broken-mobile'; run?: boolean; keepEnvironment?: boolean; json?: boolean; @@ -624,15 +623,13 @@ program .option('--receipt-dir ', 'directory for immutable evidence and receipts (default: proof-receipts)') .option('--wp-env-config ', 'wp-env configuration (default: proof/wp-env.json)') .option('--theme ', 'prove local theme templates and parts through a real Site Editor save') - .option('--site ', 'prove an explicitly published five-page site fixture in an owned sandbox') + .option('--site ', 'prove an explicitly published site bundle in an owned sandbox') .option('--out ', 'alias for --receipt-dir') - .addOption(new Option('--site-negative-control ', 'seed a controlled fixture failure; site proof only').choices(['missing-content', 'broken-asset', 'wrong-target', 'accordion-mismatch', 'broken-mobile'])) .option('--no-run', 'only produce a blocked receipt; do not start Docker or Playwright') .option('--keep-environment', 'leave wp-env running after the proof') .option('--json', 'emit the complete receipt result as JSON') .action(async (pluginZip: string | undefined, options: ProofCliOptions) => { const profile = options.profile ?? 'full'; - if (options.siteNegativeControl && !options.site) program.error('error: site negative controls require --site'); if (options.site && (options.theme || pluginZip)) program.error('error: --site is exclusive with --theme and plugin ZIP'); if (options.theme && pluginZip) program.error('error: proof accepts either a plugin ZIP or --theme, not both'); if (!options.site && !options.theme && !pluginZip) program.error('error: proof needs a plugin ZIP or --theme '); @@ -652,7 +649,6 @@ program pluginZip, theme: options.theme, site: options.site, - siteNegativeControl: options.siteNegativeControl, fixture, artifact, markup, diff --git a/src/proof/runner.ts b/src/proof/runner.ts index e344bee..5109a90 100644 --- a/src/proof/runner.ts +++ b/src/proof/runner.ts @@ -376,7 +376,7 @@ export interface ProofRunOptions { pluginZip?: string; /** Local block theme to prove through Site Editor template/part saves. */ theme?: string; - /** Controlled five-page site bundle; fixture deployment only. */ + /** Explicitly published site bundle; owned sandbox deployment only. */ site?: string; siteNegativeControl?: 'missing-content' | 'broken-asset' | 'wrong-target' | 'accordion-mismatch' | 'broken-mobile'; /** Confirmed artifact capability contract. Capability-scoped claims bind it to pluginZip. */ diff --git a/src/proof/site.ts b/src/proof/site.ts index 98a809e..71158dc 100644 --- a/src/proof/site.ts +++ b/src/proof/site.ts @@ -39,7 +39,7 @@ export async function seedAccordionHeadingMismatch(markup: string): Promise { const root = path.resolve(directory); if ((await lstat(root)).isSymbolicLink()) throw new Error('Site proof requires a direct bundle directory.'); @@ -65,8 +65,8 @@ export async function prepareSiteProof(directory: string, evidence: EvidenceStor } if (!intentBytes) throw new Error('Site bundle does not pin its source intent.'); const intent = JSON.parse(intentBytes.toString('utf8')); - if (intent.pages?.length !== 5 || intent.pages.some((page: { status?: string }) => page.status !== 'publish')) throw new Error('Site proof requires a controlled source declaring exactly five published pages; draft statuses are never promoted.'); - const prepared = await prepareSiteSource(intent, path.join(root, 'source/assets')); + if (!Array.isArray(intent.pages) || intent.pages.length === 0 || intent.pages.some((page: { status?: string }) => page.status !== 'publish')) throw new Error('Site proof requires a controlled source declaring published pages; draft statuses are never promoted.'); + const prepared = await prepareSiteSource(intent, intent.assets?.length === 0 ? root : path.join(root, 'source/assets')); if (!prepared.ok) throw new Error(JSON.stringify(prepared.diagnostics)); const theme = await prepareThemeProof(path.join(root, 'theme'), evidence); if (theme.manifest.files.some((file) => !seen.has(`theme/${file.file}`)) || prepared.source.assets.some((asset) => !seen.has(`source/assets/${asset.path}`))) throw new Error('Site bundle contains unpinned theme or source asset bytes.'); From 3cb3e5c4dcf4ec4b5d40225375aa25a0820c2fb8 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:42:36 +0200 Subject: [PATCH 24/38] fix(site): verify captured manifest before completion --- dev/test/site-apply.test.ts | 13 ++++++++++--- src/site/apply.ts | 10 ++++++---- src/site/compile.ts | 2 +- src/site/plan.ts | 4 ++-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/dev/test/site-apply.test.ts b/dev/test/site-apply.test.ts index a8e7684..b0edf4f 100644 --- a/dev/test/site-apply.test.ts +++ b/dev/test/site-apply.test.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import { hashCanonicalJson } from '../../src/proof/receipt.js'; import { describe, it, expect } from 'vitest'; import { applySite, type SiteApplyOptions } from '../../src/site/apply.js'; +import { writeCompiledSite } from '../../src/site/write.js'; import { readSitePlanInput, reconcileSitePlan, planPreparedSite, managedStateHash } from '../../src/site/plan.js'; import { compileThemeDocuments, compileSitePageDocuments, compileSiteSyncedPatterns } from '../../src/site/compile.js'; import type { SiteSnapshot } from '../../src/site/plan-types.js'; @@ -69,17 +70,23 @@ describe('confirmed database apply',()=>{ }}); expect(result.status).toBe('partial');expect(result.error).toContain('live state changed during marker persistence');expect(f.count()).toBe(0); }); - it.each(['none','live','source'] as const)('checks retained items together and refuses late %s changes',async(change)=>{ + it.each(['none','live','source','manifest'] as const)('checks retained items together and refuses late %s changes',async(change)=>{ const f=await fixture();expect((await applySite(f.plan,f.options,f.transports)).status).toBe('completed'); const input=await readSitePlanInput(f.source,f.root);const plan=await planPreparedSite(input,structuredClone(f.live)); expect(plan.operations.every(op=>op.action==='retain')).toBe(true); + let source=f.source; + if(change==='manifest'){ + const theme=await compileThemeDocuments(input.source,{themeAssetBaseUrl:f.live.theme.assetBaseUrl});if(!theme.ok)throw new Error('theme'); + source=path.join(f.root,'bundle');await writeCompiledSite(source,input.source,theme.artifact); + } let reads=0;const originalRead=f.transports.read; - const result=await applySite(plan,{...f.options,confirm:plan.confirmationHash,journal:path.join(f.root,'retained.json')},{...f.transports,read:async()=>{ + const result=await applySite(plan,{...f.options,confirm:plan.confirmationHash,source,journal:path.join(f.root,'retained.json')},{...f.transports,read:async()=>{ reads++;if(reads===4){if(change==='live')f.live.navigation.push({id:99,content:'human edit',status:'publish'});if(change==='source'){const source=JSON.parse(await readFile(f.source,'utf8'));source.pages[0].title='Human edit';await writeFile(f.source,JSON.stringify(source));}} + if(reads===4&&change==='manifest'){const file=path.join(source,'site.manifest.json');const manifest=JSON.parse(await readFile(file,'utf8'));manifest.sourceIntentState='unreconciled';await writeFile(file,JSON.stringify(manifest));} return originalRead(); }}); expect(result.status).toBe(change==='none'?'completed':'partial');expect(reads).toBe(change==='none'?5:4);expect(f.count()).toBe(3); - if(change!=='none')expect(result.error).toContain(change==='live'?'live state changed before completion':'source bytes changed before completion'); + if(change!=='none')expect(result.error).toContain(change==='live'?'live state changed before completion':'source bytes changed'); }); it('streams a large default mutation script to a short eval-file stdin invocation',async()=>{ const root=await mkdtemp(path.join(await realpath(os.tmpdir()),'site-apply-stdin-')); diff --git a/src/site/apply.ts b/src/site/apply.ts index c324200..a32ce10 100644 --- a/src/site/apply.ts +++ b/src/site/apply.ts @@ -430,12 +430,13 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans if (!theme.ok || !same(theme.artifact.inventory, live.theme.inventory)) throw new Error('theme dependencies must be bound and explicitly deployed before database apply'); for (const op of plan.operations.filter(op => op.kind === 'attachment')) await perform(op); attachments = observedAttachmentBindings(input, live); - const patternCompiler = createSiteSyncedPatternCompiler(input.source); - if (!patternCompiler.ok) throw new Error(patternCompiler.diagnostics.map(item => item.reason).join('\n')); - for (const id of syncedPatternOrder(input.source.sourceIntent)) { + const patternOrder = syncedPatternOrder(input.source.sourceIntent); + const patternCompiler = patternOrder.length ? createSiteSyncedPatternCompiler(input.source) : undefined; + if (patternCompiler && !patternCompiler.ok) throw new Error(patternCompiler.diagnostics.map(item => item.reason).join('\n')); + for (const id of patternOrder) { const op = plan.operations.find(op => op.kind === 'pattern' && op.entityId === id); if (!op) throw new Error('missing planned pattern'); - const result = await patternCompiler.compile(id, compileOptions()); + const result = await patternCompiler!.compile(id, compileOptions()); const compiled = result.ok ? result.pattern : undefined; if (!compiled?.ok || compiled.content === undefined || compiled.pendingUses.length || compiled.pendingBindings.length) throw new Error('pattern dependency finalization failed'); const desired = { @@ -518,6 +519,7 @@ export async function applySite(plan: SitePlan, options: SiteApplyOptions, trans path, sha256 })), plan.assets)) throw new Error('source bytes changed before completion'); + await assertSitePlanDocumentsUnchanged(input); for (const record of journal.steps) prove(record, live); for (const page of compiled.site.pages) { const entity = live.entities.find(entity => entity.id === ids.get(page.id)); diff --git a/src/site/compile.ts b/src/site/compile.ts index 02b1b56..49020df 100644 --- a/src/site/compile.ts +++ b/src/site/compile.ts @@ -55,7 +55,7 @@ export function calculateSiteArtifactIdentity(source: PreparedSiteSource, artifa const targetBindingFingerprint = hashTargetContext(artifact.targetBindings, artifact.themeAssetBaseUrl); if (artifact.targetBindingFingerprint !== targetBindingFingerprint) throw new Error('compiled artifact target bindings do not match their fingerprint'); const themeFiles = artifact.themeFiles.map((file) => { - const bytes = Buffer.from(file.bytes); + const bytes = file.bytes; const sha256 = createHash('sha256').update(bytes).digest('hex'); if (file.sha256 !== sha256) throw new Error(`compiled file ${file.path} no longer matches its SHA-256`); return { path: file.path, sha256, size: bytes.byteLength }; diff --git a/src/site/plan.ts b/src/site/plan.ts index 8fd6a9f..c78033c 100644 --- a/src/site/plan.ts +++ b/src/site/plan.ts @@ -328,8 +328,8 @@ export async function assertSitePlanDocumentsUnchanged(input: SitePlanInput): Pr if (!documents) throw new Error('site input has no captured source document identities'); for (const [file, identity] of documents) { await assertSafePath(file); - if (!sameFileIdentity(identity, await regularFileIdentity(file, 'source bytes changed immediately before mutation'))) { - throw new Error('source bytes changed immediately before mutation'); + if (!sameFileIdentity(identity, await regularFileIdentity(file, 'source bytes changed since preparation'))) { + throw new Error('source bytes changed since preparation'); } } } From 00c1225a1918a02458ea4a1f9e1478f4d24f04ec Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 04:51:41 +0200 Subject: [PATCH 25/38] fix(site): scope precise file identities to source guards --- dev/test/site-pull.test.ts | 7 +++---- src/publication.ts | 26 ++++++++++++++++++++------ src/site/plan.ts | 16 ++++++++++++---- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/dev/test/site-pull.test.ts b/dev/test/site-pull.test.ts index 7a6860a..d50fac2 100644 --- a/dev/test/site-pull.test.ts +++ b/dev/test/site-pull.test.ts @@ -229,16 +229,15 @@ describe('bounded site template and part pull', () => { expect(await readFile(path.join(f.bundle, 'site.manifest.json'))).toEqual(beforeManifest); }); - it('permits exact legacy indexed attachment ownership only with its complete baseline', async () => { + it('refuses attachment ownership without the required version hash and qualified index', async () => { const f = await fixture(); addOwnedAttachment(f, 811, { legacy: true }); f.setTemplates({ ...f.templates, entities: f.templates.entities.map(entity => entity.id === f.selection.entityId ? { ...entity, raw: imageRefs(811) } : entity) }); const beforeHtml = await readFile(path.join(f.bundle, f.sourcePath)); const beforeManifest = await readFile(path.join(f.bundle, 'site.manifest.json')); const preview = await previewSitePull(f.bundle, path.join(f.root, 'legacy-owned-preview'), [f.selection], f.options); - expect(preview.ok, JSON.stringify(preview.diagnostics)).toBe(true); - f.snapshot.entities[0]!.state.sha256 = 'd'.repeat(64); - await expect(confirmSitePull(f.bundle, preview.directory, preview.confirmationHash!, f.options)).rejects.toThrow('changed after preview'); + expect(preview.ok).toBe(false); + expect(preview.diagnostics).toContainEqual(expect.objectContaining({ code: 'unsupported-attachment-dependency' })); expect(await readFile(path.join(f.bundle, f.sourcePath))).toEqual(beforeHtml); expect(await readFile(path.join(f.bundle, 'site.manifest.json'))).toEqual(beforeManifest); }); diff --git a/src/publication.ts b/src/publication.ts index cf04e50..23d9f64 100644 --- a/src/publication.ts +++ b/src/publication.ts @@ -7,7 +7,6 @@ import { createHash, randomUUID } from 'node:crypto'; import { link, lstat, open, readFile, readlink, rename, unlink } from 'node:fs/promises'; import path from 'node:path'; -import type { BigIntStats } from 'node:fs'; export interface PublicationFileIdentity { dev: string; @@ -53,11 +52,24 @@ export function sameFileIdentity(left: PublicationFileIdentity, right: Publicati } export async function regularFileIdentity(file: string, error: string, includeContent = false): Promise { + const stats = await lstat(file); + if (stats.isSymbolicLink() || !stats.isFile()) throw new Error(error); + const withNs = stats as typeof stats & { mtimeNs?: bigint; ctimeNs?: bigint }; + return { + dev: String(stats.dev), ino: String(stats.ino), mode: Number(stats.mode), size: String(stats.size), + mtimeNs: String(withNs.mtimeNs ?? BigInt(Math.round(Number(stats.mtimeMs) * 1_000_000))), + ctimeNs: String(withNs.ctimeNs ?? BigInt(Math.round(Number(stats.ctimeMs) * 1_000_000))), + ...(includeContent ? { sha256: hashBytes(await readFile(file)) } : {}), + }; +} + +/** Exact metadata for invocation-local source guards; existing publication identities stay unchanged. */ +export async function preciseRegularFileIdentity(file: string, error: string): Promise { const stats = await lstat(file, { bigint: true }); if (!stats.isFile()) throw new Error(error); return { - ...identityFromStats(stats), - ...(includeContent ? { sha256: hashBytes(await readFile(file)) } : {}), + dev: String(stats.dev), ino: String(stats.ino), mode: Number(stats.mode), size: String(stats.size), + mtimeNs: String(stats.mtimeNs), ctimeNs: String(stats.ctimeNs), }; } @@ -71,7 +83,7 @@ export async function readStableRegularFile(file: string, error: string, maximum } else { const handle = await open(file, 'r'); try { - const opened = await handle.stat({ bigint: true }); + const opened = await handle.stat(); if (!opened.isFile() || !sameFileIdentity(before, identityFromStats(opened))) throw new Error(error); const chunks: Buffer[] = []; let total = 0; @@ -93,10 +105,12 @@ export async function readStableRegularFile(file: string, error: string, maximum return { content, identity: after }; } -function identityFromStats(stats: BigIntStats): PublicationFileIdentity { +function identityFromStats(stats: Awaited>): PublicationFileIdentity { + const withNs = stats as typeof stats & { mtimeNs?: bigint; ctimeNs?: bigint }; return { dev: String(stats.dev), ino: String(stats.ino), mode: Number(stats.mode), size: String(stats.size), - mtimeNs: String(stats.mtimeNs), ctimeNs: String(stats.ctimeNs), + mtimeNs: String(withNs.mtimeNs ?? BigInt(Math.round(Number(stats.mtimeMs) * 1_000_000))), + ctimeNs: String(withNs.ctimeNs ?? BigInt(Math.round(Number(stats.ctimeMs) * 1_000_000))), }; } diff --git a/src/site/plan.ts b/src/site/plan.ts index c78033c..89f73ee 100644 --- a/src/site/plan.ts +++ b/src/site/plan.ts @@ -2,7 +2,7 @@ import { compileThemeDocuments, compileSitePageDocuments } from './compile.js'; import { readSiteSnapshot, type SiteWpCliOptions, type SiteReadTransport } from './wpcli.js'; import { lstat } from 'node:fs/promises'; import path from 'node:path'; -import { hashBytes, readStableRegularFile, regularFileIdentity, sameFileIdentity, type PublicationFileIdentity } from '../publication.js'; +import { hashBytes, readStableRegularFile, preciseRegularFileIdentity, sameFileIdentity, type PublicationFileIdentity } from '../publication.js'; import { prepareSiteSource } from './input.js'; import { hashCanonicalJson } from '../proof/receipt.js'; import type { PreparedSiteSource, CompiledSitePage, SiteInputDiagnostic, ThemeArtifact, SiteAttachmentBinding } from './types.js'; @@ -328,12 +328,20 @@ export async function assertSitePlanDocumentsUnchanged(input: SitePlanInput): Pr if (!documents) throw new Error('site input has no captured source document identities'); for (const [file, identity] of documents) { await assertSafePath(file); - if (!sameFileIdentity(identity, await regularFileIdentity(file, 'source bytes changed since preparation'))) { + if (!sameFileIdentity(identity, await preciseRegularFileIdentity(file, 'source bytes changed since preparation'))) { throw new Error('source bytes changed since preparation'); } } } +async function readSourceDocument(file: string, error: string): Promise<{ content: Buffer; identity: PublicationFileIdentity }> { + const before = await preciseRegularFileIdentity(file, error); + const observed = await readStableRegularFile(file, error); + const after = await preciseRegularFileIdentity(file, error); + if (!sameFileIdentity(before, after)) throw new Error(error); + return { content: observed.content, identity: before }; +} + /** Read either an explicit source file or the retained source of a stable compiled bundle. */ export async function readSitePlanInput(inputPath: string, assetRoot?: string): Promise { const input = path.resolve(inputPath); @@ -341,7 +349,7 @@ export async function readSitePlanInput(inputPath: string, assetRoot?: string): const info = await lstat(input); if (info.isFile()) { if (!assetRoot) throw new Error('raw site intent planning requires an explicit asset root'); - const file = await readStableRegularFile(input, 'site intent changed while reading'); + const file = await readSourceDocument(input, 'site intent changed while reading'); const prepared = await prepareSiteSource(JSON.parse(file.content.toString('utf8')), assetRoot); if (!prepared.ok) throw new Error(prepared.diagnostics.map(item => `${item.path}: ${item.message}`).join('\n')); const result: SitePlanInput = { source: prepared.source, retainedBindings: {} }; @@ -354,7 +362,7 @@ export async function readSitePlanInput(inputPath: string, assetRoot?: string): if (!relative || relative.includes('\\') || relative.includes('\0') || relative.split('/').some(component => !component || component === '.' || component === '..') || path.isAbsolute(relative)) throw new Error('unsafe retained bundle path'); const file = path.join(input, ...relative.split('/')); await assertSafePath(file); - const observed = await readStableRegularFile(file, 'retained bundle file changed while reading'); + const observed = await (relative === 'site.intent.json' || relative === 'site.manifest.json' ? readSourceDocument : readStableRegularFile)(file, 'retained bundle file changed while reading'); if (relative === 'site.intent.json' || relative === 'site.manifest.json') { const previous = documents.get(file); if (previous && !sameFileIdentity(previous, observed.identity)) throw new Error('retained source document changed while reading bundle'); From 38f11bfa878f5dd216c3188824b431ee8d188060 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 07:15:52 +0200 Subject: [PATCH 26/38] fix(site): keep native publication dates on draft transitions --- dev/test/site-apply-wpcli.test.ts | 16 +++++++++++----- src/site/apply-wpcli.ts | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/dev/test/site-apply-wpcli.test.ts b/dev/test/site-apply-wpcli.test.ts index 99997d2..cd72038 100644 --- a/dev/test/site-apply-wpcli.test.ts +++ b/dev/test/site-apply-wpcli.test.ts @@ -15,7 +15,7 @@ const step={key:'page:home',kind:'page' as const,entityId:'home',status:'started const before:SiteSnapshot={target:boundary.target,runtime:{wordpress:'7.1',php:'8.4'},principal:boundary.principal,theme:boundary.theme,routing:{permalinkStructure:'/%postname%/',rewriteRulesHash:'rules',routes:[]},entities:[{id:22,kind:'page',route:'/home',state,ownership:[{version:1,siteId:'sample',entityId:'home',kind:'page',lastAppliedHash:managedStateHash(state)}]}],overrides:[],navigation:[],options:{show_on_front:'posts',page_on_front:0},siteRecord:null}; async function execute(request:SiteApplyRequest,flags:Record={},selectedBoundary:SiteApplyBoundary=boundary) { const root=await mkdtemp(path.join(await realpath(os.tmpdir()),'apply-php-')); - const data=Buffer.from(JSON.stringify({root,flags,state,owner:before.entities[0]!.ownership[0],option:siteRecordOption(selectedBoundary.siteId),lock:SITE_APPLY_LOCK_OPTION,step:request.action==='mutate'?request.step:step,evidence:request.action==='mutate'?{operation:request.operation,beforeState:request.operation.kind==='option'?request.before.options[request.operation.entityId]:request.before.entities.find(entity=>entity.id===request.operation.id)??null}:null})).toString('base64'); + const data=Buffer.from(JSON.stringify({root,flags,state,clock:new Date().toISOString().slice(0,19).replace('T',' '),owner:before.entities[0]!.ownership[0],option:siteRecordOption(selectedBoundary.siteId),lock:SITE_APPLY_LOCK_OPTION,step:request.action==='mutate'?request.step:step,evidence:request.action==='mutate'?{operation:request.operation,beforeState:request.operation.kind==='option'?request.before.options[request.operation.entityId]:request.before.entities.find(entity=>entity.id===request.operation.id)??null}:null})).toString('base64'); let output='';let argsSeen:string[]=[]; const response=await siteApplyWpCli({wpPath:'/wp path;$(false)',wpUser:'7'},selectedBoundary,request,async(_executable,args)=>{ argsSeen=args; @@ -32,14 +32,15 @@ $meta=array(22=>array('_wp_page_template'=>'index','_block_runner_site'=>array($ function home_url(){return 'https://example.test';}function site_url(){return home_url();}function get_current_blog_id(){global $flags;return $flags['blogId']??1;}function get_current_user_id(){return 7;}function current_user_can($c){return true;}function get_stylesheet(){return 'sample';}function get_stylesheet_directory(){global $d;return $d['root'];}function wp_json_encode($v){return json_encode($v);} function get_option($k,$default=false){global $options;return $options[$k]??$default;}function add_option($k,$v,$unused='',$autoload=false){global $options;if(array_key_exists($k,$options))return false;$options[$k]=$v;return true;}function delete_option($k){global $options;unset($options[$k]);return true;}function update_option($k,$v,$autoload=false){global $options,$writes;$writes++;$options[$k]=$v;return true;} function get_post($id){global $p;return $id===22?$p:null;}function get_posts($args){global $p,$flags;return isset($flags['occupied'])?array($p):array();}function get_post_meta($id,$k,$single){global $meta;return $single?($meta[$id][$k]??''):($meta[$id][$k]??array());} +function current_time($type,$gmt=false){global $d;return $d['clock'];} function wp_slash($v){return $v;}function is_wp_error($v){return false;}function wp_insert_post($v,$errors){global $p,$writes,$flags;$writes++;$p=(object)array('ID'=>22,'post_type'=>$v['post_type'],'post_content'=>$v['post_content'],'post_title'=>$v['post_title'],'post_status'=>$v['post_status'],'post_name'=>$v['post_name'],'post_parent'=>$v['post_parent']);return 22;} -function wp_update_post($v,$errors){global $p,$writes;$writes++;if($p->post_status==='draft'&&$p->post_date_gmt==='0000-00-00 00:00:00'&&empty($v['edit_date']))$p->post_date='2030-01-01 00:00:00';foreach(array('post_type','post_title','post_content','post_status','post_name','post_parent') as $key)if(array_key_exists($key,$v))$p->$key=$v[$key];if(!array_key_exists('page_template',$v)||$v['page_template']!=='')throw new Exception('inherited page template validation');return (int)$v['ID'];} +function wp_update_post($v,$errors){global $p,$writes;$writes++;if($p->post_status==='draft'&&$p->post_date_gmt==='0000-00-00 00:00:00'&&empty($v['edit_date'])){$p->post_date=current_time('mysql');if($v['post_status']==='publish')$p->post_date_gmt=current_time('mysql',true);}foreach(array('post_type','post_title','post_content','post_status','post_name','post_parent') as $key)if(array_key_exists($key,$v))$p->$key=$v[$key];if(!array_key_exists('page_template',$v)||$v['page_template']!=='')throw new Exception('inherited page template validation');return (int)$v['ID'];} function update_post_meta($id,$k,$v){global $meta,$flags;if(isset($flags['ownershipCrash'])&&$k==='_block_runner_site')throw new Exception('crash');$meta[$id][$k]=$k==='_block_runner_site'?array($v):$v;return true;} function add_post_meta($id,$k,$v,$unique){global $meta,$flags;if(isset($flags['ownershipCrash']))throw new Exception('crash');$meta[$id][$k]=array($v);return true;} `; - const {stdout}=await run('php',['-r',stub+args.at(-1)!+'\necho "\\n" . json_encode(array("writes"=>$writes,"options"=>$options,"meta"=>$meta,"post"=>$p));'],{encoding:'utf8'});const lines=stdout.split('\n');output=lines[1]!;return lines[0]!; + const {stdout}=await run('php',['-r',stub+args.at(-1)!+'\necho "\\n" . json_encode(array("writes"=>$writes,"options"=>$options,"meta"=>$meta,"post"=>$p,"clock"=>$d['clock']));'],{encoding:'utf8'});const lines=stdout.split('\n');output=lines[1]!;return lines[0]!; }); - return {response,evidence:JSON.parse(output) as {writes:number;options:Record;meta:Record;post:Record},argsSeen}; + return {response,evidence:JSON.parse(output) as {writes:number;options:Record;meta:Record;post:Record;clock:string},argsSeen}; } describe('fixed PHP mutation boundary executed with bounded WordPress stubs',()=>{ it('sanitizes thrown transport stderr without attaching raw output or a cause',async()=>{let error:unknown;try{await siteApplyWpCli({wpPath:'/wp'},boundary,{action:'lock'},async()=>{throw Object.assign(new Error('bootstrap SENTINEL_PRIVATE_WP_CREDENTIAL'),{stderr:'SENTINEL_PRIVATE_WP_CREDENTIAL'});});}catch(value){error=value;}expect(error).toBeInstanceOf(Error);expect((error as Error).message).toContain('site apply transport failed');expect(String(error)).not.toContain('SENTINEL_PRIVATE_WP_CREDENTIAL');expect(JSON.stringify(error)).not.toContain('SENTINEL_PRIVATE_WP_CREDENTIAL');}); @@ -57,7 +58,12 @@ describe('fixed PHP mutation boundary executed with bounded WordPress stubs',()= }); it('rejects a forged unowned update at the fixed PHP boundary with zero mutations',async()=>{const unowned=structuredClone(before);unowned.entities[0]!.ownership=[];const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'Forged'}},before:unowned,stateHash:managedStateHash({...state,title:'Forged'})});expect(r.response.status).toBe('refused');expect(r.evidence.writes).toBe(0);}); it('writes exact managed fields and ownership without shell evaluation',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})});expect(r.response).toEqual({status:'complete',id:22});expect(r.evidence.post).toMatchObject({post_title:'New',post_author:31,post_excerpt:'Human excerpt',post_date:'2025-02-03 04:05:06',post_date_gmt:'2025-02-03 03:05:06',menu_order:19});expect(r.argsSeen.slice(0,3)).toEqual(['--path=/wp path;$(false)','--user=7','eval']);}); - it('preserves a draft date when its publish date is still floating',async()=>{const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})},{floatingDraft:true});expect(r.response).toEqual({status:'complete',id:22});expect(r.evidence.post).toMatchObject({post_date:'2025-02-03 04:05:06',post_date_gmt:'0000-00-00 00:00:00'});}); + it.each(['draft','publish'] as const)('uses native date behavior when a floating draft is saved as %s',async(status)=>{ + const desired={...state,title:'New',status}; + const r=await execute({action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired},before,stateHash:managedStateHash(desired)},{floatingDraft:true}); + expect(r.response).toEqual({status:'complete',id:22}); + expect(r.evidence.post).toMatchObject(status==='draft'?{post_date:'2025-02-03 04:05:06',post_date_gmt:'0000-00-00 00:00:00'}:{post_date:r.evidence.clock,post_date_gmt:r.evidence.clock}); + }); it('does not transmit unrelated content or snapshot inventories with a selected mutation',async()=>{ const request:SiteApplyRequest={action:'mutate',step,operation:{kind:'page',entityId:'home',action:'update',id:22,desired:{...state,title:'New'}},before,stateHash:managedStateHash({...state,title:'New'})}; const capture=async(value:SiteApplyRequest)=>{ diff --git a/src/site/apply-wpcli.ts b/src/site/apply-wpcli.ts index 2bdc9a7..d977f8f 100644 --- a/src/site/apply-wpcli.ts +++ b/src/site/apply-wpcli.ts @@ -171,7 +171,7 @@ $result=(function() use($e,$r,&$attempted,$same,$state) { } } $data=array('post_type'=>$type,'post_title'=>$desired['title'],'post_content'=>$desired['content'],'post_status'=>$desired['status']); - if($id) { $data['ID']=$id; $data['edit_date']=true; } + if($id) { $data['ID']=$id; if($desired['status']==='draft') $data['edit_date']=true; } // Template metadata is assigned below. wp_update_post otherwise inherits and revalidates the old template after writing. if($kind==='page') { $data['post_name']=$desired['slug']; $data['post_parent']=$desired['parentId']; $data['page_template']=''; } $attempted=true; $written=$id ? wp_update_post(wp_slash($data),true) : wp_insert_post(wp_slash($data),true); if(is_wp_error($written)||!$written) throw new Exception('save'); $id=(int)$written; From cf126f5e9980473bc98c2f88159a948c856f4990 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 07:17:33 +0200 Subject: [PATCH 27/38] test(site): correct PHP clock response quoting --- dev/test/site-apply-wpcli.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/test/site-apply-wpcli.test.ts b/dev/test/site-apply-wpcli.test.ts index cd72038..ccbd4b4 100644 --- a/dev/test/site-apply-wpcli.test.ts +++ b/dev/test/site-apply-wpcli.test.ts @@ -38,7 +38,7 @@ function wp_update_post($v,$errors){global $p,$writes;$writes++;if($p->post_stat function update_post_meta($id,$k,$v){global $meta,$flags;if(isset($flags['ownershipCrash'])&&$k==='_block_runner_site')throw new Exception('crash');$meta[$id][$k]=$k==='_block_runner_site'?array($v):$v;return true;} function add_post_meta($id,$k,$v,$unique){global $meta,$flags;if(isset($flags['ownershipCrash']))throw new Exception('crash');$meta[$id][$k]=array($v);return true;} `; - const {stdout}=await run('php',['-r',stub+args.at(-1)!+'\necho "\\n" . json_encode(array("writes"=>$writes,"options"=>$options,"meta"=>$meta,"post"=>$p,"clock"=>$d['clock']));'],{encoding:'utf8'});const lines=stdout.split('\n');output=lines[1]!;return lines[0]!; + const {stdout}=await run('php',['-r',stub+args.at(-1)!+'\necho "\\n" . json_encode(array("writes"=>$writes,"options"=>$options,"meta"=>$meta,"post"=>$p,"clock"=>$d["clock"]));'],{encoding:'utf8'});const lines=stdout.split('\n');output=lines[1]!;return lines[0]!; }); return {response,evidence:JSON.parse(output) as {writes:number;options:Record;meta:Record;post:Record;clock:string},argsSeen}; } From 50be0e1634687fd87749d2c39845a4ed62f6fe38 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 07:34:07 +0200 Subject: [PATCH 28/38] docs: align Node and Wesper requirements with current source --- README.md | 2 +- docs/reference.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7bcf89e..a14496c 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ These are page-content conversion results, not measurements of registered-block | Custom HTML conversion rules | [Runnable extension example](https://github.com/humanmade/block-runner/blob/main/docs/extending.md) | | Repository checks and benchmarks | [Development guide](https://github.com/humanmade/block-runner/blob/main/docs/development.md) | -The bundled `context` command uses Wesper **0.4.1**. See the [site-context reference](https://github.com/humanmade/block-runner/blob/main/docs/reference.md#site-context-from-wesper) for what the manifest supplies and its limitations. +The bundled `context` command uses Wesper **0.5.0**. See the [site-context reference](https://github.com/humanmade/block-runner/blob/main/docs/reference.md#site-context-from-wesper) for what the manifest supplies and its limitations. For a real WordPress Site Editor save check of a local block theme, run `block-runner proof --theme --wp-env-config `. The config supplies PHP, a fixed local port and local dependency plugins; `autoPort` is refused. The proof verifies its browser target, then saves each template and part from the mounted editor state and compares a fresh REST read with the pinned source. diff --git a/docs/reference.md b/docs/reference.md index 3b2cc28..c79054f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -263,7 +263,7 @@ Use the CLI in shell scripts, pre-commit hooks or CI. ```yaml - repo: https://github.com/humanmade/block-runner - rev: v0.9.1 + rev: v0.9.10 hooks: - id: block-runner args: ['content/**/*.html'] # glob of files that contain block markup @@ -302,9 +302,9 @@ unknown effects are never retried automatically. ## Library -The library is ESM-only and requires Node.js ^20.19.0 || ^22.13.0 || >=24.0.0. This means -Node 20.19.0+ on the 20.x line, Node 22.13.0+ on the 22.x line, or Node 24.0.0+. Node 21 and -23 are intentionally unsupported. CommonJS callers should use +The library is ESM-only and requires Node.js ^22.13.0 || >=24.0.0. This means +Node 22.13.0+ on the 22.x line, or Node 24.0.0+. Node 20, 21 and 23 are +unsupported. CommonJS callers should use `await import('block-runner')` rather than `require('block-runner')`. ```js @@ -479,7 +479,7 @@ snapshot at `author.styles.context.theme.settings`; `--context` does not populat or import binding permissions. Use Wesper's validation and compatibility helpers in the calling harness when those checks are needed. -The bundled `context` command uses the pinned Wesper 0.4.1 WP-CLI collector. Its manifests +The bundled `context` command uses the pinned Wesper 0.5.0 WP-CLI collector. Its manifests provide the native preset registry consumed by the resolver above. ## Styling fidelity From f79d658f41df83fa15477dbd8d2bde8235bdc771 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 07:37:54 +0200 Subject: [PATCH 29/38] test: run benchmark and tooling tests outside npm test Tests whose subject is scripts/ tooling now run with npm run test:tools. --- package.json | 1 + vitest.config.ts | 8 ++++++++ vitest.tools.config.ts | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 vitest.tools.config.ts diff --git a/package.json b/package.json index 3c24e32..c61b13b 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "tune:t0": "tsx scripts/tune.ts --tier t0", "test": "vitest run", "test:consumer": "vitest run --config vitest.consumer.config.ts", + "test:tools": "vitest run --config vitest.tools.config.ts", "test:proof": "vitest run dev/test/proof.test.ts dev/test/proof-pattern-overrides.test.ts dev/test/proof-theme.test.ts", "test:proof:wordpress": "vitest run --config vitest.wordpress.config.ts", "test:proof:mutations": "BLOCK_RUNNER_PROOF_MUTATIONS=1 vitest run dev/test/proof-mutations.integration.test.ts", diff --git a/vitest.config.ts b/vitest.config.ts index 86d3f06..f28e540 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,14 @@ export default defineConfig({ 'dev/test/proof-gutenberg-compat.test.ts', 'dev/test/plugin.consumer.test.ts', 'dev/test/proof-native-style-adapter-builder.test.ts', + // Benchmark and repository tooling under scripts/ runs with `npm run test:tools`. + 'dev/test/authoring-benchmark.test.ts', + 'dev/test/authoring.receipts.test.ts', + 'dev/test/authoring.runner.test.ts', + 'dev/test/authoring.style-ledger.test.ts', + 'dev/test/benchmark.test.ts', + 'dev/test/engine-skill.test.ts', + 'dev/test/project-owned-blocks.test.ts', ], // Each file boots a full headless Gutenberg runtime. Parallel boots contend heavily enough // to cross the timeout together on developer machines, so serialize files for a stable gate. diff --git a/vitest.tools.config.ts b/vitest.tools.config.ts new file mode 100644 index 0000000..b714753 --- /dev/null +++ b/vitest.tools.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: [ + 'dev/test/authoring-benchmark.test.ts', + 'dev/test/authoring.receipts.test.ts', + 'dev/test/authoring.runner.test.ts', + 'dev/test/authoring.style-ledger.test.ts', + 'dev/test/benchmark.test.ts', + 'dev/test/engine-skill.test.ts', + 'dev/test/project-owned-blocks.test.ts', + ], + fileParallelism: false, + testTimeout: 30000, + }, +}); From 4762b3b19f9e087aaf11e983982c30b537c8e2c9 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 07:38:08 +0200 Subject: [PATCH 30/38] docs: cover native themes and the owned site lifecycle --- CHANGELOG.md | 11 ++ README.md | 13 +- docs/architecture.md | 36 +++++- docs/development.md | 18 ++- docs/reference.md | 38 ++++-- skills/block-runner/SKILL.md | 5 +- skills/block-runner/references/GUIDE.md | 1 + skills/block-runner/references/SITE.md | 151 ++++++++++++++++++++++++ 8 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 skills/block-runner/references/SITE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 564bacb..89ad93c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +- Add block-theme folder validation, in-place markup repair, WordPress 7.1 theme.json schema checks and local part/pattern/preset references. +- Align native attribute order with WordPress server definitions; send unknown-block warnings to stderr and preserve native Accordion icon placement. +- Compile caller-authored whole-site intent into a local block theme and page bundle, with explicit media, font and synced-pattern bindings. +- Add source/target-bound site planning, confirmed owned apply with durable journals, read-only drift and staged pull of saved template/part edits. Theme deployment and activation remain separate. +- Add mounted-editor theme save proof and controlled whole-site proof. Save persistence does not establish visual fidelity. +- Update Wesper to 0.5.0 and require Node.js ^22.13.0 || >=24.0.0, dropping Node 20 support. +- Preserve unmanaged page fields and native publication dates on apply, allow pending first-install pattern media, canonicalise site identity and reduce repeated compilation, snapshots and source scans. +- Document current theme/site routes and guarded manual interrupted-apply recovery; remove the public proof failure-seeding option and fixed five-page fixture requirement. + ## 0.9.10 — 2026-10-04 - Clarify native intent styling versus HTML/CSS conversion, with runnable native-attribute, headerless-table and sequential library examples. Verified theme layout contracts can override generic block-mapping defaults. diff --git a/README.md b/README.md index a14496c..50af8df 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Block Runner -Turn HTML into editable WordPress blocks, or generate the source for a reusable registered block. +Turn HTML into editable WordPress blocks, generate reusable block source, or compile a native block theme and site. [![npm version](https://img.shields.io/npm/v/block-runner.svg)](https://www.npmjs.com/package/block-runner) [![npm downloads](https://img.shields.io/npm/dm/block-runner.svg)](https://www.npmjs.com/package/block-runner) @@ -9,7 +9,9 @@ Turn HTML into editable WordPress blocks, or generate the source for a reusable ![Block Runner driven from Claude Code: preview the registered block, confirm, write it into the plugin](https://cdn.jsdelivr.net/gh/humanmade/block-runner@main/demo/demo.gif) -Block Runner is an open-source CLI and JavaScript library from Human Made. It converts supported HTML into native Gutenberg blocks and checks the generated markup with the Gutenberg packages used by the WordPress editor. It can also generate a static registered block: source files you build into a plugin, with native editable blocks inside. +Block Runner is an open-source CLI and JavaScript library from Human Made. It converts supported HTML into native Gutenberg blocks and checks the generated markup with the Gutenberg packages used by the WordPress editor. It can also generate a static registered block: source files you build into a plugin, with native editable blocks inside. Whole-site intent produces a block theme and page content, with separate planning and confirmed apply to WordPress. + +This README describes the current repository source. Theme-folder and whole-site workflows, Wesper 0.5.0 and the new Node floor are unreleased; npm 0.9.10 predates them. See [Unreleased changes](CHANGELOG.md#unreleased) before assuming an installed version has these capabilities. **The package makes no AI model calls.** Built-in rules can convert HTML on their own. An external agent can interpret a design and give Block Runner a structured plan to assemble or compile. The optional skill provides instructions for that agent. @@ -48,6 +50,8 @@ The JSON report includes `ok`, block counts, warnings and source locations. Revi | Build page content from an agent's block tree | CLI `assemble` | Gutenberg markup, assembled and validated | | Create a reusable named block from a design | `author` | A plan and generated block source; preview and confirm before writing | | Check or repair existing block markup | `validate` / `fix` | A report or canonicalised markup | +| Check or repair a block theme | `validate ` / `fix --write` | Folder, schema and reference checks; eligible markup repairs | +| Compile and manage a native whole site | `site compile` / `plan` / `apply` / `drift` / `pull` | Local theme bundle, reviewed plan, owned WordPress changes and staged theme edits | For a block theme, pass its directory to check each discovered template, part and pattern. Pattern PHP metadata headers are preserved and excluded from block counts; patterns with PHP in the body @@ -61,6 +65,8 @@ npx --no-install block-runner validate ./my-theme npx --no-install block-runner fix ./my-theme --write ``` +For whole sites, follow the [theme and site guide](skills/block-runner/references/SITE.md). Compile stays local; apply requires the exact reviewed confirmation and a durable journal. Deploy and activate the compiled theme separately before planning. + For straightforward HTML, start with `convert`. For a design that needs interpretation, an agent can choose the block structure and send it to CLI `assemble`. Both page-content routes finish with media resolution, theme-token handling and validation. Neither creates a registered block's source files. ### Registered-block authoring @@ -105,7 +111,7 @@ See [skill installation options](https://github.com/humanmade/block-runner/blob/ - Supported HTML becomes native blocks. Unsupported structures can remain as Custom HTML, with warnings; `--strict` fails on fallback blocks and unresolved media. - Media IDs can come from a supplied map, WP-CLI or REST. Collecting site context or using WP-CLI resolution needs a site and the selected external tooling. -- Styles can map to existing theme presets, native block attributes or supported CSS. Block Runner does not rewrite `theme.json`. +- Styles can map to existing theme presets, native block attributes or supported CSS. Conversion and registered-block authoring do not rewrite the destination's `theme.json`; site compilation emits the caller's explicit local theme configuration. - Registered-block generation produces static source. It does not generate arbitrary PHP renderers, custom field editors or JavaScript interactions. Use the original design HTML rather than scraped frontend markup. **Valid markup does not prove visual fidelity or compatibility with every site.** Inspect warnings and verify the result in its target WordPress environment. The [construction guide](https://github.com/humanmade/block-runner/blob/main/skills/block-runner/references/CONSTRUCTION-PATTERNS.md) explains other approaches when the static generator does not fit. @@ -142,6 +148,7 @@ These are page-content conversion results, not measurements of registered-block | --- | --- | | Commands, flags, exit codes and CI usage | [CLI reference](https://github.com/humanmade/block-runner/blob/main/docs/reference.md#cli) | | Reusable block delivery | [Source-to-ZIP walkthrough](https://github.com/humanmade/block-runner/blob/main/skills/block-runner/references/AUTHORING.md#shipped-notice-source-to-standalone-zip) | +| Theme folders, whole-site intent and confirmed apply | [Theme and site guide](skills/block-runner/references/SITE.md) | | WordPress proof requirements and synced patterns | [Verification profiles](https://github.com/humanmade/block-runner/blob/main/docs/reference.md#wordpress-proof-requirements) and [pattern overrides](https://github.com/humanmade/block-runner/blob/main/docs/reference.md#synced-pattern-overrides-wordpress-71) | | Library contracts and regeneration | [API reference](https://github.com/humanmade/block-runner/blob/main/docs/reference.md#library) | | Media, theme tokens and Wesper context | [Configuration](https://github.com/humanmade/block-runner/blob/main/docs/reference.md#configuration) and [media resolution](https://github.com/humanmade/block-runner/blob/main/docs/reference.md#media-resolution) | diff --git a/docs/architecture.md b/docs/architecture.md index 6e9277b..94cfa6e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -Block Runner has two outputs: Gutenberg markup for page content, and source files for a reusable registered block. Both are available through the CLI and library. The optional skill teaches an external coding agent how to use them; the package does not call a model. +Block Runner produces Gutenberg page markup, reusable registered-block source and whole-site block-theme bundles. These are available through the CLI and library. The optional skill teaches an external coding agent how to use them; the package does not call a model. Theme/site routes are current-source features, unreleased after npm 0.9.10. ## Choose a workflow @@ -10,6 +10,10 @@ Block Runner has two outputs: Gutenberg markup for page content, and source file | A JSON tree describing blocks | `realize(json)` / CLI `assemble` | Block markup and the same kind of report | | HTML for a reusable named block, with an optional proposal | `author(html, options)` / CLI `author` | A generated plan and source files in a report | | Existing Gutenberg markup | `validate(markup)` or `canonicalize(markup)` / CLI `validate` or `fix` | A validation report or canonicalised markup | +| An existing block theme | CLI `validate ` / `fix --write` | Markup, theme.json schema and local-reference checks; eligible file repairs | +| Caller-authored `SiteIntent` | `prepareSiteSource` → `compileSite` or `compileThemeDocuments` → `writeCompiledSite` | A local hashed bundle, including retained source/assets | +| Reviewed source and a deployed active theme | `planSite` → confirmed `applySite` | Owned WordPress pages, media, synced patterns and front-page options | +| Saved template/part overrides | `inspectSiteDrift` → `previewSitePull` → confirmed `confirmSitePull` | Read-only comparison, then selected local theme edits | An agent can interpret a design and supply a tree or proposal. A program or person can supply the same inputs. Installing the skill does not add a model client to the library. @@ -66,11 +70,37 @@ An error found in generated markup may have an offset that means nothing in the These checks establish markup validity against the bundled Gutenberg version. They do not establish visual fidelity, support for every installed block, or compatibility with every site. +## Whole-site compilation and owned apply + +[`src/site/input.ts`](../src/site/input.ts) prepares explicit SiteIntent and an asset +root. [`compile.ts`](../src/site/compile.ts) finalises native documents through the +existing intent pipeline and [`src/theme/`](../src/theme/) checks. Theme-only output +can retain pending database bindings; complete site output requires resolved content. +[`write.ts`](../src/site/write.ts) publishes the local bundle and hashed manifest. +Compilation never contacts WordPress or deploys a theme. + +[`plan.ts`](../src/site/plan.ts) makes one WordPress observation and binds its plan to +source/assets, target, principal, active theme and live state. After confirmation, +[`apply.ts`](../src/site/apply.ts) records steps before mutation, uses the WP-CLI/PHP +adapter and requires provable outcomes before retrying. Ownership indexes, a shared +blog lock and initial/final source/live comparisons retain the safety boundary. +Hard-stop recovery requires guarded operator reconciliation; no held lock is stolen. + +[`drift.ts`](../src/site/drift.ts) compares database template/part overrides with the +bundle. [`pull.ts`](../src/site/pull.ts) previews selected saved markup and publishes it +locally after confirmation, leaving WordPress overrides intact. It cannot reconstruct +intent: the retained input must be reconciled before further apply planning. + +The [theme and site guide](../skills/block-runner/references/SITE.md) owns command +examples and mutation boundaries. Mounted-editor save proof and desktop visual/editor +acceptance remain separate from deterministic compilation and headless validity. + ## Names used in the code | Term | Meaning and example | | --- | --- | | Intent tree | A block description, such as a `core/paragraph` node with text, consumed by the page-content assembly workflow. | +| `SiteIntent` | Caller-authored theme documents, pages, assets and explicit bindings with stable logical IDs; each body is an intent tree. | | Authoring proposal | Source-linked choices for a registered block: structure, editable fields and locks. `collectSourceEvidence` supplies the source references. | | `AuthoringPlan` / `SemanticAuthoringPlan` | The older semantic input type retained for compatibility. The deprecated `compileAuthoringPlan` adapter turns it into a generated plan. It is distinct from an HTML proposal. | | `GeneratedAuthoringPlan` | The complete public plan used for preview, confirmation and generation. Internally, `src/authoring/schema.ts` calls this type `AuthoringPlan`; the package exports it under the longer name. | @@ -87,9 +117,11 @@ These checks establish markup validity against the bundled Gutenberg version. Th | Change media or theme-token handling | [`src/convert/finalize.ts`](../src/convert/finalize.ts), [`src/media/`](../src/media/), [`src/tokens/`](../src/tokens/) | | Change a validation diagnostic | [`src/gate/`](../src/gate/); also check how [`src/report/`](../src/report/) presents it | | Change registered-block source analysis | [`src/author/`](../src/author/) | +| Change theme discovery, schema or local references | [`src/theme/`](../src/theme/) | +| Change site compilation, planning, apply, drift or pull | [`src/site/`](../src/site/) | | Change generated files or confirmed writes | [`src/authoring/`](../src/authoring/) | | Change plugin integration or runtime verification | [`src/plugin/`](../src/plugin/), [`src/proof/`](../src/proof/) | | Trace a public command or import | [`src/cli.ts`](../src/cli.ts), [`src/index-implementation.ts`](../src/index-implementation.ts) | | Change instructions used by an agent | [`skills/block-runner/`](../skills/block-runner/) | -The public entry point includes advanced compiler helpers as well as everyday operations. Start with `convert`, `realize`, `author`, `validate` or `canonicalize`; follow their callers before changing lower-level helpers or compatibility aliases. +The public entry point includes advanced compiler helpers as well as everyday operations. Start with `convert`, `realize`, `author`, `validate`, `canonicalize` or the site lifecycle exports above; follow their callers before changing lower-level helpers or compatibility aliases. diff --git a/docs/development.md b/docs/development.md index 60c9a41..b3a0ed4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,23 @@ npm run typecheck `npm run verify` remains the required repository gate. It runs the deterministic repository suite. -CI classifies both pull-request and main-push changes with [`scripts/ci-scope.mjs`](../scripts/ci-scope.mjs). Additions or modifications limited to its root-documentation/demo allowlist use the docs route; eligible shipped-skill changes use the skill route. Runtime, package, proof, workflow, release and unclassified paths use the full route. Deletions, renames and unavailable diffs cannot select a reduced route. New `docs/` files are currently unclassified, so they select full CI. +CI classifies both pull-request and main-push changes with [`scripts/ci-scope.mjs`](../scripts/ci-scope.mjs). Additions or modifications limited to `README.md`, `CHANGELOG.md`, `ERRORS.md`, `DECISIONS.md` and its demo allowlist use the docs route; eligible shipped-skill changes use the skill route. Runtime, package, proof, workflow, release and unclassified paths use the full route. Deletions, renames and unavailable diffs cannot select a reduced route. All `docs/` paths are currently unclassified, so they select full CI. + +For prose-only edits, check local links and run the focused documentation contracts: + +```sh +npx vitest run dev/test/skill.test.ts dev/test/node-support.test.ts +npm run check:private +git diff --check +``` + +Theme/site changes need their affected `dev/test/theme-*.test.ts`, +`dev/test/site-*.test.ts` or `dev/test/proof-site.test.ts` files and typecheck. Verify +native field preservation or save behaviour in a controlled WordPress runtime when +changing those boundaries; a mocked adapter is insufficient. Refer to the +[site guide](../skills/block-runner/references/SITE.md) for proof prerequisites and +manual interrupted-apply recovery. Do not treat retained historical receipts as +acceptance for changed code. Run `npm run test:consumer` separately to build the clean standalone release archive and the native style-adapter fixture; it needs npm, `unzip`, and enough time for an isolated diff --git a/docs/reference.md b/docs/reference.md index c79054f..3660fa0 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -2,6 +2,8 @@ Start with the [README](../README.md) for a first conversion, or the [architecture guide](architecture.md) for the code paths. This reference covers commands, configuration and compatibility details. The [registered-block delivery guide](../skills/block-runner/references/AUTHORING.md#shipped-notice-source-to-standalone-zip) owns the complete source-to-ZIP procedure. +This reference describes current source. Theme-folder and whole-site commands, Wesper 0.5.0 and the Node 22.13/24 floor are unreleased; the published 0.9.10 package predates them. The [theme and site guide](../skills/block-runner/references/SITE.md) owns the whole-site input and lifecycle examples. + ## Native block support boundaries Four different claims need different evidence: @@ -49,10 +51,15 @@ For a local installation, run these commands with `npx --no-install block-runner | `assemble` | An intent tree to native page-content blocks, assembled and validated by Gutenberg. | | `author preview ` | Validate and render a versioned registered-block GeneratedAuthoringPlan without writing files. | | `author write --confirm --output-dir ` | Write the reviewed compiler-owned source package bound to its SHA-256 confirmation and destination. Build and runtime proof remain separate. | -| `validate` | Check block markup against headless Gutenberg. | -| `fix` | Canonicalize near-miss block markup. | +| `validate` | Check block markup or a theme folder, including its schema and local references. | +| `fix` | Canonicalize block markup; `--write` repairs eligible theme-folder markup in place. | | `context` | Read a WordPress site into a `site.context.json` manifest (read-only). | -| `proof` | Run a real-WordPress proof profile for a built plugin ZIP and write an immutable receipt. | +| `proof` | Run a built-plugin proof profile, or select a local theme/site with `--theme` / `--site`; retain the receipt. | +| `site compile --asset-root --out ` | Compile a local theme/site bundle; `--theme-only` retains pending database bindings. | +| `site plan --wp-path --out ` | Observe WordPress and write a source/target-bound plan; raw intent also needs `--asset-root`. | +| `site apply --source --confirm --journal --wp-path ` | Apply reviewed owned changes, with durable per-step records and outcome checks. | +| `site drift --wp-path ` | Inspect saved theme overrides against local files without writes. | +| `site pull --wp-path --out ` | Stage exact selected saved theme edits; `--confirm ` publishes them locally. | | `skill` | Print or install the agent guide. | | `plugin inspect` | Read-only detection of the supported `@wordpress/scripts` plugin profile. | | `plugin preview` / `plugin write` | Preview, confirm, and integrate a generated registered-block directory; or create a standalone plugin wrapper. | @@ -151,8 +158,9 @@ every artifact as pattern-override-ready: | `full` | The exhaustive release-profile gate set. | All full-profile inputs, including pattern, visual, and manual-review evidence. | Library plus `convert`, `assemble`, `validate`, `fix`, `author`, `plugin`, `context`, -and `skill` need only Block Runner's production dependencies. WP-CLI remains an -external requirement only when selected for context, token, or media resolution. +`site compile` and `skill` need only Block Runner's production dependencies. WP-CLI +is external tooling when selected for context, token or media resolution, and for +`site plan`, `apply`, `drift` and `pull`. The browser-proof packages are optional peers with compatible caret ranges, absent from a basic installation. The proof setup below deliberately pins the known-good versions for reproducible @@ -179,6 +187,12 @@ Proof never downloads tooling or browsers or calls a model. Missing or mismatche the run before Docker starts and reports the required installation command. Runtime profiles also require a working Docker daemon. +Theme/site proofs use the same runtime/editor prerequisites. `proof --theme +--wp-env-config ` saves mounted editor content and compares fresh REST bytes. +`proof --site --wp-env-config ` exercises an owned sandbox with +explicitly published pages and header/footer parts. Neither is a visual fidelity +claim; see the [theme/site proof boundaries](../skills/block-runner/references/SITE.md#prove-the-right-result). + ```sh block-runner proof dist/acme-hero.zip --profile full \ --markup fixtures/hero.blocks.html --input fixtures/hero.source.html \ @@ -255,6 +269,9 @@ preserved root-level `GUIDE.md` after confirming the new `references/GUIDE.md` c - `2`: usage or I/O error - `3`: headless Gutenberg boot failure +`site drift` has its own exit codes: 0 clean, 1 actionable, 2 blocked, 3 inspection +failure. An apply receipt is successful only when its status is `completed`. + ## Run it anywhere Use the CLI in shell scripts, pre-commit hooks or CI. @@ -307,6 +324,12 @@ Node 22.13.0+ on the 22.x line, or Node 24.0.0+. Node 20, 21 and 23 are unsupported. CommonJS callers should use `await import('block-runner')` rather than `require('block-runner')`. +Whole-site exports include `prepareSiteSource`, `compileThemeDocuments`, `compileSite`, +`writeCompiledSite`, `planSite`, `applySite`, `inspectSiteDrift`, `previewSitePull` and +`confirmSitePull`. Use the [SiteIntent and lifecycle guide](../skills/block-runner/references/SITE.md) +and exported TypeScript option/result types; compilation, local publication and +WordPress mutation are separate calls. + ```js import { canonicalize, convert, validate } from 'block-runner'; @@ -380,8 +403,9 @@ insertions; existing saved content remains its own content record, though change A same-identity change to saved markup, registration identity, or attribute schema is refused. Use a new block identity, or add a tested WordPress deprecation/migration before replacing it. -Block Runner does not edit a site's database, templates, or posts. Synced-pattern canonical updates -are separate site operations and source changes make no sitewide propagation claim. Direct insertion, +Registered-block regeneration does not edit a site's database, templates, or posts. +The separate `site apply` workflow can update owned pages and synced patterns; +registered-block source changes make no sitewide propagation claim. Direct insertion, synced-pattern use, and plugin deactivation each need their own WordPress acceptance proof. Generated packages are static WordPress source and contain no Block Runner runtime dependency. diff --git a/skills/block-runner/SKILL.md b/skills/block-runner/SKILL.md index 3b72a3f..9a4421e 100644 --- a/skills/block-runner/SKILL.md +++ b/skills/block-runner/SKILL.md @@ -4,7 +4,8 @@ description: >- Turn WordPress page content or authored design HTML into valid, native, editable Gutenberg blocks, or plan a reusable named registered block for deterministic source generation. Use when creating WordPress page content or sections, converting authored HTML or a design-tool export into block - markup, authoring a reusable named block in an existing WordPress project, continuing an + markup, checking or compiling native block themes and whole-site intent, planning confirmed + owned site changes, inspecting or pulling saved theme edits, authoring a reusable named block in an existing WordPress project, continuing an in-scope Gutenberg component through custom PHP or editor code, understanding its block integration before generation, validating or repairing Gutenberg markup, or before writing blocks to WordPress. Do not use for general WordPress administration, @@ -28,6 +29,8 @@ self-contained page-content or markup-repair task. | Reusable, named static registered block source | [AUTHORING.md](references/AUTHORING.md) | The requested result is a static `namespace/slug` block in code. | Confirmed source at its exact retained destination; build, integration, and WordPress proof remain separate. The complete plan in [AUTHORING-PLAN.md](references/AUTHORING-PLAN.md) is an advanced route, not a prerequisite. | | Custom PHP renderer or editor behaviour in an existing project | [GUIDE.md §1.1](references/GUIDE.md) | Static generation cannot satisfy the requested component. | Continue in project-owned code; retain a useful native subtree only when it genuinely helps. Inspect [CONSTRUCTION-PATTERNS.md](references/CONSTRUCTION-PATTERNS.md) only when project facts needed for that implementation are missing. | | Supplied block markup to repair or check | [GUIDE.md §5](references/GUIDE.md) | Markup already exists and must be safe to save. | `validate` → `fix` → `validate`; do not save a hard-invalid result. | +| Block-theme folder to check or repair | [SITE.md](references/SITE.md#check-an-existing-theme) | Templates, parts, patterns and theme.json already exist. | Folder gate and eligible `fix --write` repairs; live save/visual proof remain separate. | +| Native whole-site theme and owned content | [SITE.md](references/SITE.md) | Caller-authored SiteIntent describes the theme, pages, assets and bindings. | Local compile, separate theme deployment, then reviewed plan and explicitly confirmed apply; drift/pull have their own boundaries. | If “reusable section” does not say whether it is page `post_content` or a named registered source block, ask that one artifact question before writing. Do not use this skill for unrelated WordPress diff --git a/skills/block-runner/references/GUIDE.md b/skills/block-runner/references/GUIDE.md index 65d1a57..dc47765 100644 --- a/skills/block-runner/references/GUIDE.md +++ b/skills/block-runner/references/GUIDE.md @@ -107,6 +107,7 @@ to adding a plugin merely because it is easier to generate. The handoff is descr | New content for a page or post, including explicit native styles and theme presets | **`assemble`** | An intent tree becomes native page blocks with the attributes you supply. | | Existing authored HTML/CSS that must become page or post `post_content` | **`convert`** | Rule-based interpretation of authored markup and supported CSS. Do not use frontend-scraped render output. | | Block markup you already produced, before saving it to WordPress | **`validate`** → **`fix`** → **`validate`** | Checks block markup; site references and editor behavior still need target-site verification. | +| A block-theme folder or whole-site intent | See **[SITE.md](SITE.md)** | Folder/schema/reference checks, local compilation, confirmed owned apply and staged theme pull have separate boundaries. Check installed command availability first. | Choose by the requested artifact, not merely by the input format. A supplied HTML design still uses registered-block authoring when the user wants a reusable named block in code. Conversely, diff --git a/skills/block-runner/references/SITE.md b/skills/block-runner/references/SITE.md new file mode 100644 index 0000000..61a549f --- /dev/null +++ b/skills/block-runner/references/SITE.md @@ -0,0 +1,151 @@ +# Block themes and whole sites + +These routes are in the current repository source and are unreleased. The published +0.9.10 package predates them. Use a build containing these commands and check +`block-runner site --help`; do not assume an older installed package supports them. + +## Check an existing theme + +```sh +block-runner validate ./my-theme --strict --json +block-runner fix ./my-theme --write --strict --json +block-runner validate ./my-theme --strict --json +``` + +The folder gate discovers templates, parts and static PHP patterns. It preserves +pattern metadata headers and skips bodies containing PHP rather than executing them. +It checks a present `theme.json` against the pinned WordPress 7.1 schema and checks +local part, pattern and preset references. Block-scoped presets apply to their block +type. Core-default preset categories can remain explicitly skipped. + +`fix --write` is only for theme folders. It canonicalises eligible markup files; +it does not rewrite `theme.json`. Schema errors fail the aggregate report; strict +reference failures prevent the affected file from being rewritten. Config-file `strict: true` +also applies. Review skips and warnings even when the command succeeds. + +## Compile a whole site + +Use caller-authored `SiteIntent`, with native blocks for each section and editable +descendants. A whole page or section inside `core/html` does not establish native +conversion. Existing authored HTML can inform the trees through `convert`; use +[ASSEMBLE.md](ASSEMBLE.md) for the node schema. Templates surround page content with +`core/post-content`; they must not duplicate the page body. + +Save this minimal input as `site.intent.json`. Omitted page status defaults to draft: + +```json +{ + "version": 1, + "siteId": "sample-studio", + "homePageId": "home", + "theme": { + "slug": "sample-studio", + "title": "Sample Studio", + "themeJson": { "version": 3 } + }, + "templates": { + "index": { "body": { "blocks": [{ "block": "core/post-content" }] } } + }, + "parts": {}, + "patterns": {}, + "pages": [{ + "id": "home", "route": "/", "title": "Home", "template": "index", + "body": { "blocks": [{ "block": "core/paragraph", "text": "Hello WordPress" }] } + }], + "assets": [], + "assetUses": [] +} +``` + +```sh +mkdir -p assets +block-runner site compile site.intent.json --asset-root ./assets --out ./site-bundle --strict --json +``` + +Compilation writes a new local bundle containing the block theme, page content, +retained source/assets and a hashed manifest. It passes documents through the existing +finalisation and theme gates. It does not contact WordPress. Each template/part has a +`body`; static patterns additionally have `title` and `slug`. Page IDs are stable +logical identities, separate from routes; `parentId` declares hierarchy. + +Declare every asset by `id`, safe relative `path`, `kind` and `role` (`theme-file` or +`attachment`). `assetUses` bind its `assetId` to a `documentId`, zero-based `nodePath` +and `attribute` (`url`, `attrs.url`, `attrs.src` or `attrs.id`). Local WOFF/WOFF2 fonts +need an explicit `fontLicense` redistribution decision; stylesheet `cssUrls` declare +their referenced theme files. No source CSS or asset is silently inferred as deployed. + +For database media, full compilation requires an explicit `--bindings ` map +keyed by asset ID. `--theme-only` emits deployable theme files while retaining pending +page and synced-pattern bindings. Dependencies used by theme documents must already +resolve; pending data is not a finished site artifact. `--theme-asset-base-url` supplies +the deployed base URL when needed. Inspect the JSON diagnostics. + +Optional `syncedPatterns` has `version: 1`, `definitions` keyed by logical ID +(`title`, `body`) and explicit `uses` (`documentId`, `nodePath`, `patternId`, optional +named override `content`). These are database-owned reusable patterns, distinct from +static theme PHP files. Keep their native editable structure and declared bindings. + +## Plan and apply to WordPress + +Deploy and activate the exact compiled theme independently first. Block Runner +does not deploy or activate it. Use WordPress 7.1 and an explicit WP-CLI target/user. +For first installs with pending database bindings, plan from the retained intent: + +```sh +block-runner site plan site.intent.json --asset-root ./assets \ + --wp-path /path/to/wordpress --wp-user 1 --out ./site.plan.json --strict --json + +# Review the plan, then supply its exact confirmation hash and a durable journal. +block-runner site apply site.plan.json --source site.intent.json --asset-root ./assets \ + --wp-path /path/to/wordpress --wp-user 1 --confirm --journal ./site.apply.json --strict +``` + +`plan` makes a read-only WordPress observation and writes a new local plan. Its +confirmation binds source/assets, target, principal, active theme and live state. +Use `--wp-url` for a specific multisite target; `--wp-binary` selects the WP-CLI binary. +A fully bound bundle can replace the intent as plan/apply source. + +`apply` imports declared images, creates or updates owned pages/synced patterns and +sets the static front page. It does not adopt unrelated records by title or route, +delete them, or overwrite unmanaged post fields. Pages remaining drafts keep their +dates; publishing a floating draft uses WordPress's native publication time. + +The journal records each step before mutation. A partial or unknown outcome is a +failed apply, not a completed receipt. Held blog locks are never stolen, even by the +same journal. After a hard stop, preserve the plan, journal and native operation marker; +establish that every executor has stopped before guarded operator lock reconciliation. +There is no automatic lock expiry or recovery command. Never delete a lock blindly +or retry an uncertain effect without proving its outcome. This is not a transaction +with concurrent human edits or WordPress hooks. + +## Inspect drift and pull saved theme edits + +```sh +block-runner site drift ./site-bundle --wp-path /path/to/wordpress --wp-user 1 --json +block-runner site pull ./site-bundle --wp-path /path/to/wordpress --wp-user 1 \ + --select 'part:sample-studio//footer:123' --out ./pull-preview --json +block-runner site pull ./site-bundle --wp-path /path/to/wordpress --wp-user 1 \ + --out ./pull-preview --confirm --json +``` + +Drift is read-only: exit codes are 0 clean, 1 actionable, 2 blocked and 3 inspection +failure. Pull selection is an exact `part:theme//slug:wp-id` or +`template:theme//slug:wp-id`; replace the example with an observed database override. +Preview writes only a new local staging directory. Confirmation reuses that directory, +omits `--select`, and checks fresh target/source/before bytes before updating the +selected local theme files and sealed manifest. Database overrides remain untouched. +Pull does not reverse-infer the original intent. Reconcile that retained input and +recompile before planning another apply; unreconciled bundles are blocked. + +## Prove the right result + +`proof --theme ./my-theme --wp-env-config ./wp-env.json` saves mounted Site Editor +content and compares fresh REST bytes with pinned theme files. It honours wp-env's +effective fixed development port and refuses `autoPort`; a site marker guards the +browser target. This proves save persistence for the exercised files, not visual fidelity. + +`proof --site ./site-bundle --wp-env-config ./wp-env.json` exercises a controlled +owned sandbox, with a nonempty set of explicitly published pages and declared header +and footer parts. The minimal draft example above needs those additions first. +Never use production as the proof fixture. Both routes require the optional pinned +WordPress/browser tooling; save proof and visual/editor acceptance are separate claims. From e5864bb9a340b5e0e13d3a99661d46277fad0342 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 07:38:49 +0200 Subject: [PATCH 31/38] docs: clarify theme and site proof inputs --- skills/block-runner/references/SITE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/skills/block-runner/references/SITE.md b/skills/block-runner/references/SITE.md index 61a549f..4c0317d 100644 --- a/skills/block-runner/references/SITE.md +++ b/skills/block-runner/references/SITE.md @@ -147,5 +147,8 @@ browser target. This proves save persistence for the exercised files, not visual `proof --site ./site-bundle --wp-env-config ./wp-env.json` exercises a controlled owned sandbox, with a nonempty set of explicitly published pages and declared header and footer parts. The minimal draft example above needs those additions first. -Never use production as the proof fixture. Both routes require the optional pinned -WordPress/browser tooling; save proof and visual/editor acceptance are separate claims. +Never use production as the proof fixture. Site proof requires PHP 8.4 in wp-env; +theme proof checks the theme's declared PHP requirement. Both routes require the +optional pinned WordPress/browser tooling. They pin the captured theme snapshot +automatically, so a separate plugin-style `--input` is unnecessary. Save proof and +visual/editor acceptance are separate claims. From 6ec7d29ec2bbfdaf7bfac0b337bbb94a62eca9cd Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 07:39:57 +0200 Subject: [PATCH 32/38] docs: reflect the separate repository tooling test command --- docs/development.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index b3a0ed4..b9125ba 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,7 +25,9 @@ npm run typecheck ``` `npm run verify` remains the required repository gate. It runs the deterministic repository -suite. +product suite. Benchmark and repository-tooling tests run separately with +`npm run test:tools`; use that command when changing benchmark engines, receipt +tooling or project-owned-block helpers. It is not a paid model benchmark run. CI classifies both pull-request and main-push changes with [`scripts/ci-scope.mjs`](../scripts/ci-scope.mjs). Additions or modifications limited to `README.md`, `CHANGELOG.md`, `ERRORS.md`, `DECISIONS.md` and its demo allowlist use the docs route; eligible shipped-skill changes use the skill route. Runtime, package, proof, workflow, release and unclassified paths use the full route. Deletions, renames and unavailable diffs cannot select a reduced route. All `docs/` paths are currently unclassified, so they select full CI. From 681a1c9030d5040a6c5d33aab40cd6ae8c1dbf0f Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 07:40:48 +0200 Subject: [PATCH 33/38] test: remove redundant proof and plugin tests Drops source-text and constant checks, duplicated receipt assertions and same-branch table rows; merges the lock-confirmation preview test into the profile test. --- dev/test/plugin.lock-confirmation.test.ts | 29 +------ dev/test/plugin.profile.test.ts | 22 +++++- dev/test/proof-control-setup.test.ts | 36 +-------- dev/test/proof-pattern-overrides.test.ts | 82 +------------------- dev/test/proof-release-acceptance.test.ts | 44 ++--------- dev/test/proof-visual-baselines.test.ts | 8 +- dev/test/proof.test.ts | 12 +-- dev/test/release-activation-contract.test.ts | 15 +--- 8 files changed, 34 insertions(+), 214 deletions(-) diff --git a/dev/test/plugin.lock-confirmation.test.ts b/dev/test/plugin.lock-confirmation.test.ts index b54ac1f..777a434 100644 --- a/dev/test/plugin.lock-confirmation.test.ts +++ b/dev/test/plugin.lock-confirmation.test.ts @@ -1,12 +1,10 @@ import { createHash } from 'node:crypto'; -import { mkdtemp, readFile, stat } from 'node:fs/promises'; +import { mkdtemp, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { planStandalonePluginOutput, - STANDALONE_LOCK_TEMPLATE_PACKAGE_COUNT, - STANDALONE_LOCK_TEMPLATE_VERSION, writePluginOutput, } from '../../src/plugin/profile.js'; @@ -27,31 +25,6 @@ const block = { }; describe('standalone lock confirmation', () => { - it('previews the complete versioned dependency lock without touching the destination', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'block-runner-standalone-lock-')); - const output = path.join(root, 'notice-plugin'); - const plan = await planStandalonePluginOutput(output, block); - const packageJson = JSON.parse(plan.touchedFiles.find((file) => file.relativePath === 'package.json')!.content.toString('utf8')); - const lock = JSON.parse(plan.touchedFiles.find((file) => file.relativePath === 'package-lock.json')!.content.toString('utf8')); - - await expect(stat(output)).rejects.toThrow(); - expect(lock).toMatchObject({ - lockfileVersion: 3, - packages: { - '': { - name: 'acme-notice', - engines: { node: '^20.19.0 || ^22.13.0 || >=24.0.0' }, - devDependencies: { '@wordpress/scripts': '34.2.0' }, - }, - 'node_modules/@wordpress/scripts': { version: '34.2.0' }, - 'node_modules/adm-zip': { version: '0.6.1' }, - }, - }); - expect(packageJson).toMatchObject({ engines: { node: '^20.19.0 || ^22.13.0 || >=24.0.0' } }); - expect(Object.keys(lock.packages)).toHaveLength(STANDALONE_LOCK_TEMPLATE_PACKAGE_COUNT); - expect(plan.notes).toContain(`Complete dependency lock: ${STANDALONE_LOCK_TEMPLATE_VERSION}.`); - }); - it('writes every confirmed byte, including the complete lock, without npm', async () => { const root = await mkdtemp(path.join(tmpdir(), 'block-runner-standalone-confirmation-')); const output = path.join(root, 'notice-plugin'); diff --git a/dev/test/plugin.profile.test.ts b/dev/test/plugin.profile.test.ts index b85bfc0..536c89c 100644 --- a/dev/test/plugin.profile.test.ts +++ b/dev/test/plugin.profile.test.ts @@ -12,6 +12,8 @@ import { PublicationInterruptedError, removeMatchingAllowScriptsProjection, retryPluginPublication, + STANDALONE_LOCK_TEMPLATE_PACKAGE_COUNT, + STANDALONE_LOCK_TEMPLATE_VERSION, UnsupportedPluginLayoutError, writePluginOutput, } from '../../src/plugin/profile.js'; @@ -329,11 +331,25 @@ describe('standalone plugin profile', () => { const packageJson = plan.touchedFiles.find((file) => file.relativePath === 'package.json')!.content; const lock = plan.touchedFiles.find((file) => file.relativePath === 'package-lock.json')!.content; const bootstrap = plan.touchedFiles.find((file) => file.relativePath === 'plugin.php')!.content; - expect(JSON.parse(packageJson.toString('utf8'))).toMatchObject({ devDependencies: { '@wordpress/scripts': '34.2.0' } }); - expect(JSON.parse(lock.toString('utf8'))).toMatchObject({ + expect(JSON.parse(packageJson.toString('utf8'))).toMatchObject({ + engines: { node: '^20.19.0 || ^22.13.0 || >=24.0.0' }, + devDependencies: { '@wordpress/scripts': '34.2.0' }, + }); + const parsedLock = JSON.parse(lock.toString('utf8')); + expect(parsedLock).toMatchObject({ lockfileVersion: 3, - packages: { '': { name: 'acme-notice', devDependencies: { '@wordpress/scripts': '34.2.0' } } }, + packages: { + '': { + name: 'acme-notice', + engines: { node: '^20.19.0 || ^22.13.0 || >=24.0.0' }, + devDependencies: { '@wordpress/scripts': '34.2.0' }, + }, + 'node_modules/@wordpress/scripts': { version: '34.2.0' }, + 'node_modules/adm-zip': { version: '0.6.1' }, + }, }); + expect(Object.keys(parsedLock.packages)).toHaveLength(STANDALONE_LOCK_TEMPLATE_PACKAGE_COUNT); + expect(plan.notes).toContain(`Complete dependency lock: ${STANDALONE_LOCK_TEMPLATE_VERSION}.`); expect(bootstrap.toString()).toContain('wp_register_block_metadata_collection'); expect(bootstrap.toString()).toContain('Requires at least: 7.1'); expect(bootstrap.toString()).toContain('Requires PHP: 7.4'); diff --git a/dev/test/proof-control-setup.test.ts b/dev/test/proof-control-setup.test.ts index f85f092..8cfdd54 100644 --- a/dev/test/proof-control-setup.test.ts +++ b/dev/test/proof-control-setup.test.ts @@ -1,4 +1,4 @@ -import { constants as fsConstants, readFileSync } from 'node:fs'; +import { constants as fsConstants } from 'node:fs'; import { access, chmod, mkdir, mkdtemp, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -7,27 +7,6 @@ import { runProof } from '../../src/proof/runner.js'; import { startWithPreparedStageMount } from '../../scripts/proof-control-startup.mjs'; describe('WordPress control startup and retained failures', () => { - it('keeps the wp-env mount schema and native adapter media byte contract outside WordPress uploads', () => { - const config = JSON.parse(readFileSync(new URL('../../proof/wp-env.json', import.meta.url), 'utf8')); - expect(config.mappings).toEqual({ 'wp-content/block-runner-proof': '.block-runner-proof-stage' }); - const runner = readFileSync(new URL('../../src/proof/runner.ts', import.meta.url), 'utf8'); - const helper = runner.match(/const prepareNativeStyleAdapterMedia[\s\S]*?\n };/)?.[0]; - expect(helper).toBeDefined(); - // These checks intentionally guard WordPress upload failures and retained-byte - // corruption, which a successful runtime install cannot deterministically inject. - expect(helper).toContain("wp_upload_bits('block-runner-editor.png', null, $png)"); - expect(helper).toContain("if (!empty($upload['error']))"); - expect(helper).toContain("hash_file('sha256', $file) !== hash('sha256', $png)"); - expect(helper).toContain("add_filter('upload_dir', $uploadDirFilter)"); - expect(helper).toContain("remove_filter('upload_dir', $uploadDirFilter)"); - expect(helper).toContain("$uploads['path'] = $uploads['basedir']"); - expect(helper).toContain("$uploads['url'] = $uploads['baseurl']"); - expect(helper).toContain("$uploads['subdir'] = ''"); - expect(helper).toContain("content_url('/uploads/block-runner-editor.png')"); - expect(helper).toContain("$upload['url'] !== $expectedUrl"); - expect(runner).not.toContain('file_put_contents($file, $png)'); - }); - it('makes host staging writable and preserves ZIPs before starting Docker', async () => { const root = await mkdtemp(path.join(tmpdir(), 'block-runner-control-stage-')); const directory = path.join(root, '.block-runner-proof-stage'); @@ -124,17 +103,4 @@ describe('WordPress control startup and retained failures', () => { expect(result.receipt.gates.some(({ reason }) => reason?.includes('Could not stage the plugin ZIP'))).toBe(true); expect(result.ok).toBe(false); }); - - it('retains named artifact uploads on failure without node_modules', () => { - const contracts = [ - ['ci.yml', 'Upload WordPress 7.1 pattern-overrides receipt'], - ['release.yml', 'Retain release receipts'], - ] as const; - for (const [workflow, stepName] of contracts) { - const yaml = readFileSync(new URL(`../../.github/workflows/${workflow}`, import.meta.url), 'utf8'); - const step = yaml.slice(yaml.indexOf(`- name: ${stepName}`), yaml.indexOf('\n - name:', yaml.indexOf(`- name: ${stepName}`) + 1)); - expect(step).toContain('if: always()'); - expect(step).toContain('/**/node_modules/**'); - } - }); }); diff --git a/dev/test/proof-pattern-overrides.test.ts b/dev/test/proof-pattern-overrides.test.ts index 23df3de..118297b 100644 --- a/dev/test/proof-pattern-overrides.test.ts +++ b/dev/test/proof-pattern-overrides.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, mkdir, readFile, stat, symlink, utimes, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, mkdir, stat, symlink, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -6,8 +6,6 @@ import { normalizePluginZipInputs, portableFixturePlan, } from '../../scripts/build-pattern-overrides-fixture.js'; -import { compileRegisteredBlock } from '../../src/authoring/generate.js'; -import { validatePatternOverrideContract } from '../../src/authoring/pattern-overrides.js'; import { hashAuthoringPlan, type AuthoringPlan } from '../../src/authoring/schema.js'; import { runProof } from '../../src/index.js'; @@ -136,84 +134,6 @@ describe('pattern-override receipts', () => { expect(peak).toBe(1); }); - it('compiles the canonical CLI fixture with exactly its ten confirmed native fields', async () => { - const plan = JSON.parse(await readFile('dev/test/fixtures/authoring/pattern-overrides.plan.json', 'utf8')); - const first = compileRegisteredBlock(plan); - expect(compileRegisteredBlock(plan)).toEqual(first); - const contract = validatePatternOverrideContract(first.template, []); - expect(contract.ok).toBe(true); - expect(contract.bindings).toHaveLength(10); - expect(new Set(contract.bindings.map(({ name }) => name)).size).toBe(3); - const edit = first.files.find(({ path }) => path === 'edit.js')!.content; - expect(edit).toContain('core/pattern-overrides'); - expect(edit).toContain('contentOnly'); - expect(edit).toContain('Canonical layout version one.'); - // No handmade runtime bundle or pretend ZIP. The explicit WordPress - // acceptance test builds and installs this compiler's actual source. - }); - - it('retains explicitly supplied headless adapter evidence in a headless receipt', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'block-runner-pattern-receipt-')); - const input = path.join(root, 'plan.json'); - await writeFile(input, '{"plan":"pattern"}'); - - const result = await runProof({ - profile: 'headless', - inputPath: input, - outputDir: root, - gateRunner: async () => ({ - status: 'pass', - details: { - canonicalWpBlockContent: 'canonical', - coreBlockContent: [ - { ref: 1, content: { 'hero.title': { content: 'First' } } }, - { ref: 1, content: { 'hero.title': { content: 'Second' } } }, - ], - }, - }), - }); - - expect(result.ok).toBe(true); - // This is a receipt-format unit test only. The full real-WordPress receipt - // is exercised without an adapter in proof-real-wordpress.test.ts. - expect(result.receipt.environment.wordpress.requestedVersion).toBe('7.1'); - expect(result.receipt.gates[0]).toMatchObject({ - gate: 'headless_validation', - details: { - canonicalWpBlockContent: expect.stringContaining('canonical'), - }, - }); - expect(JSON.parse(await readFile(path.join(root, result.receiptReference.path), 'utf8'))).toEqual(result.receipt); - }); - - it('does not give a false pass when the required pattern support is absent', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'block-runner-pattern-negative-')); - const input = path.join(root, 'plan.json'); - const plugin = path.join(root, 'fixture.zip'); - await Promise.all([writeFile(input, '{"plan":"pattern"}'), writeFile(plugin, 'fixture')]); - - const result = await runProof({ - profile: 'full', - inputPath: input, - pluginZip: plugin, - outputDir: root, - fixture: { - blockName: 'acme/pattern', - editableFields: [{ path: 'content', surface: 'richText' }], - frontend: { url: 'http://example.test/' }, - visual: { expectedPath: 'fixture.png', threshold: 0 }, - accessibility: { manualReview: 'pass' }, - }, - gateRunner: async () => ({ status: 'pass' }), - }); - - expect(result.ok).toBe(false); - expect(result.profile.failedGates).toContainEqual(expect.objectContaining({ - gate: 'pattern_overrides', - status: 'blocked', - })); - }); - it('does not let a Core-only pattern pass as generated-block evidence', async () => { const root = await mkdtemp(path.join(tmpdir(), 'block-runner-core-only-pattern-')); const input = path.join(root, 'plan.json'); diff --git a/dev/test/proof-release-acceptance.test.ts b/dev/test/proof-release-acceptance.test.ts index a7992dd..5dde6eb 100644 --- a/dev/test/proof-release-acceptance.test.ts +++ b/dev/test/proof-release-acceptance.test.ts @@ -119,31 +119,18 @@ function paragraphAxe(nodes = [paragraphNode]) { } describe('0.9 proof release acceptance', () => { - it('loads and validates the hash-bound native control before applying the exception', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'block-runner-heading-control-')); - const controlPath = path.join(root, 'native-heading-control.json'); - const controlReceipt = nativeHeadingControlEvidence.controlReceipt; - const bytes = `${JSON.stringify(controlReceipt)}\n`; - await writeFile(controlPath, bytes, 'utf8'); - const sha256 = `sha256:${createHash('sha256').update(bytes).digest('hex')}` as `sha256:${string}`; - - const loaded = loadNativeHeadingControlEvidence({ - wordpressVersion: '7.1', - evidence: { path: controlPath, sha256 }, - }); - - expect(loaded.controlReceipt).toEqual(controlReceipt); - }); - - it('loads and validates the hash-bound native Paragraph control', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'block-runner-paragraph-control-')); - const controlPath = path.join(root, 'native-paragraph-control.json'); - const controlReceipt = nativeParagraphControlEvidence.controlReceipt; + it.each([ + ['heading', nativeHeadingControlEvidence, loadNativeHeadingControlEvidence], + ['paragraph', nativeParagraphControlEvidence, loadNativeParagraphControlEvidence], + ] as const)('loads and validates the hash-bound native %s control', async (name, evidence, load) => { + const root = await mkdtemp(path.join(tmpdir(), `block-runner-${name}-control-`)); + const controlPath = path.join(root, `native-${name}-control.json`); + const controlReceipt = evidence.controlReceipt; const bytes = `${JSON.stringify(controlReceipt)}\n`; await writeFile(controlPath, bytes, 'utf8'); const sha256 = `sha256:${createHash('sha256').update(bytes).digest('hex')}` as `sha256:${string}`; - const loaded = loadNativeParagraphControlEvidence({ + const loaded = load({ wordpressVersion: '7.1', evidence: { path: controlPath, sha256 }, }); @@ -316,21 +303,6 @@ describe('0.9 proof release acceptance', () => { })); }); - it('can accept a release only after the manual review gate passes', () => { - const result = evaluateReleaseAcceptance(receipt({ - accessibility_editor: { - gate: 'accessibility_editor', - status: 'fail', - details: headingAxe(), - }, - accessibility_manual_review: { gate: 'accessibility_manual_review', status: 'pass' }, - }), { nativeHeadingControlEvidence }); - - expect(result.rawProfile.ok).toBe(false); - expect(result.automated.ok).toBe(true); - expect(result.release).toMatchObject({ ok: true, status: 'passed', blockers: [] }); - }); - it('does not apply the exception without an observed control evidence reference', () => { const result = evaluateReleaseAcceptance(receipt({ accessibility_editor: { diff --git a/dev/test/proof-visual-baselines.test.ts b/dev/test/proof-visual-baselines.test.ts index dc78d02..6e3ceb1 100644 --- a/dev/test/proof-visual-baselines.test.ts +++ b/dev/test/proof-visual-baselines.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { existsSync, readFileSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { fixtureVisualGoldenPath } from '../../scripts/build-pattern-overrides-fixture.js'; @@ -18,10 +18,4 @@ describe('reviewed platform visual baselines', () => { expect(review.threshold).toBe(0); expect(review.masks).toEqual([]); }); - - it('does not silently reuse a reviewed image for an unreviewed platform', () => { - const file = fixtureVisualGoldenPath('unreviewed'); - expect(existsSync(file)).toBe(false); - expect(file).toContain('.unreviewed.expected.png'); - }); }); diff --git a/dev/test/proof.test.ts b/dev/test/proof.test.ts index f65147c..5148e41 100644 --- a/dev/test/proof.test.ts +++ b/dev/test/proof.test.ts @@ -12,7 +12,7 @@ import { reportProofRequirements, type ProofGateContext, } from '../../src/index.js'; -import { PROOF_GATE_IDS, PROOF_PROFILES, type ProofGateId } from '../../src/proof/profiles.js'; +import { PROOF_GATE_IDS, type ProofGateId } from '../../src/proof/profiles.js'; describe('WordPress proof profiles', () => { it('fails closed when a required gate is skipped or blocked', () => { @@ -45,7 +45,7 @@ describe('WordPress proof profiles', () => { describe('capability-aware proof requirements', () => { const hash = (value: string): `sha256:${string}` => `sha256:${createHash('sha256').update(value).digest('hex')}`; - it.each([undefined, null, {}, 'bindings'])('reports invalid requiredBindings %j without short-circuit masking', (requiredBindings) => { + it.each([{}])('reports invalid requiredBindings %j without short-circuit masking', (requiredBindings) => { const fixture = { blockName: 'acme/proof', editableFields: [{ path: 'copy', surface: 'richText' }], patternOverrides: { title: 'Pattern', canonicalContent: 'canonical', instances: [{}, {}], canonicalUpdate: { content: 'updated' }, reset: { instance: 0 }, requiredBindings, @@ -252,14 +252,6 @@ describe('content-addressed proof receipts', () => { expect(JSON.parse(await readFile(path.join(root, result.receiptReference.path), 'utf8'))).toEqual(result.receipt); }); - it('keeps full unchanged while exposing capability-specific proof claims', () => { - expect(PROOF_PROFILES.full.requiredGates).toContain('accessibility_manual_review'); - expect(PROOF_PROFILES['editor-verified'].requiredGates).not.toContain('pattern_overrides'); - expect(PROOF_PROFILES['fidelity-checked'].requiredGates).toContain('visual_regression'); - expect(PROOF_PROFILES['fidelity-checked'].requiredGates).not.toContain('accessibility_manual_review'); - expect(PROOF_PROFILES['pattern-verified'].requiredGates).toContain('pattern_overrides'); - }); - it('does not accidentally leave a required gate absent from a successful profile', async () => { const root = await mkdtemp(path.join(tmpdir(), 'block-runner-proof-missing-')); const input = path.join(root, 'input.html'); diff --git a/dev/test/release-activation-contract.test.ts b/dev/test/release-activation-contract.test.ts index a7369f9..bf26ce0 100644 --- a/dev/test/release-activation-contract.test.ts +++ b/dev/test/release-activation-contract.test.ts @@ -89,7 +89,7 @@ describe('release ZIP activation claim', () => { expect(() => validate(input, `sha256:${'b'.repeat(64)}`)).toThrow(); }); - it.each(['7.0', '7.10', '7.1-beta1', '7x1'])('rejects a different runtime %s', (version) => { + it.each(['7.10'])('rejects a different runtime %s', (version) => { const input = receipt(); input.environment.wordpress.version = version; expect(() => validate(input, hash)).toThrow(); @@ -114,19 +114,6 @@ describe('release ZIP activation claim', () => { }); }); -describe('release benchmark boundary', () => { - it('keeps the 13-fixture authoring benchmark optional and explicitly unscored', () => { - const matrix = JSON.parse(readFileSync(new URL('../release/0.9-testing/matrix.json', import.meta.url), 'utf8')) as { - rows: Array<{ id: string; required?: boolean; optional?: boolean }>; - releaseRule: { optionalRowsDoNotBlockRelease?: boolean }; - }; - const authoringRows = matrix.rows.filter(({ id }) => id === 'authoring-wordpress-71-proof' || id === 'unsupported-interaction-fails-closed'); - expect(authoringRows).toHaveLength(2); - expect(authoringRows.every((row) => row.required === false && row.optional === true)).toBe(true); - expect(matrix.releaseRule.optionalRowsDoNotBlockRelease).toBe(true); - }); -}); - describe('release manual review binding', () => { const reviewStart = source.indexOf('function validateManualReviewBinding('); const reviewEnd = source.indexOf('\nfunction ', reviewStart + 1); From 5369ebe4df73d6765f4ce128aa265413360146f4 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 07:41:41 +0200 Subject: [PATCH 34/38] test: remove redundant authoring tests and fix weak assertions Drops subset and duplicate-layer authoring tests, folds the asset-form tests into one table, and makes the override-name and regeneration tests assert what their titles claim. --- dev/test/author.diagnostics.test.ts | 16 -- dev/test/author.native-style-adapters.test.ts | 17 --- dev/test/author.proposal.test.ts | 3 +- dev/test/author.style-roundtrip.test.ts | 9 +- dev/test/author.test.ts | 144 +++++++----------- dev/test/authoring.cli.test.ts | 46 ------ dev/test/authoring.confirmed-fields.test.ts | 3 +- dev/test/authoring.preview.test.ts | 8 - dev/test/authoring.regeneration.test.ts | 1 + dev/test/authoring.schema.test.ts | 3 - dev/test/authoring.test.ts | 12 -- dev/test/headless.smoke.test.ts | 58 ------- 12 files changed, 61 insertions(+), 259 deletions(-) delete mode 100644 dev/test/headless.smoke.test.ts diff --git a/dev/test/author.diagnostics.test.ts b/dev/test/author.diagnostics.test.ts index 71aabdd..91703bf 100644 --- a/dev/test/author.diagnostics.test.ts +++ b/dev/test/author.diagnostics.test.ts @@ -9,22 +9,6 @@ function sourceRef(html: string, tag: string): string { } describe('author diagnostics', () => { - it('locates stale proposal references without treating their old hash as a current binding', async () => { - const html = '

Hello

'; - const report = await author(html, { - sourcePath: '/tmp/block-runner-diagnostic/stale.html', - author: { name: 'example/diagnostic-stale' }, - proposal: { structure: [{ id: 'copy', block: 'core/paragraph', sourceRef: `${'0'.repeat(64)}:0-12` }] }, - }); - const item = report.items[0]!; - expect(report.ok).toBe(false); - expect(item).toMatchObject({ code: 'stale-proposal-source-ref', source: { path: '/tmp/block-runner-diagnostic/stale.html' } }); - expect(item.source).not.toHaveProperty('offset'); - expect(item.reason).not.toMatch(/offset|stale\.html:/i); - expect(item.details).toMatchObject({ sourceRef: `${'0'.repeat(64)}:0-12`, node: 'copy', action: 'refresh-source-analysis', referenceVerified: false }); - expect(JSON.parse(JSON.stringify(report)).items[0].reason).toMatch(/stale, missing, or belongs to another source/i); - }); - it('reports deterministic coverage differences and resolves them when the canonical plan is restored', async () => { const html = '

Hello

'; const options = { author: { name: 'example/diagnostic-coverage', styles: { mode: 'css' as const, css: '.notice { color: red; }' } } }; diff --git a/dev/test/author.native-style-adapters.test.ts b/dev/test/author.native-style-adapters.test.ts index b71b096..d9eb037 100644 --- a/dev/test/author.native-style-adapters.test.ts +++ b/dev/test/author.native-style-adapters.test.ts @@ -230,23 +230,6 @@ describe('native source-style adapters', () => { expect(() => compileRegisteredBlock(forged)).toThrow(/without overriding generated image CSS/); }); - it('locates an unsupported relationship on its matched unwrapped anchor, not the first binding', async () => { - const html = ''; - const report = await author(html, { - sourcePath: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', - 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 === '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({ - 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 () => { const html = ''; const report = await author(html, { diff --git a/dev/test/author.proposal.test.ts b/dev/test/author.proposal.test.ts index 6e788d1..31d4524 100644 --- a/dev/test/author.proposal.test.ts +++ b/dev/test/author.proposal.test.ts @@ -95,9 +95,10 @@ describe('author proposal boundary', () => { expect(reason).toContain('proposal sourceRef'); expect(reason).toMatch(/stale|another source/i); expect(reason).not.toContain('(offset 12)'); - expect(report.items[0]).toMatchObject({ code: 'stale-proposal-source-ref', source: { path: '/design/multiline.html' }, details: { sourceRef: foreignRef, referenceVerified: false } }); + expect(report.items[0]).toMatchObject({ code: 'stale-proposal-source-ref', source: { path: '/design/multiline.html' }, details: { sourceRef: foreignRef, node: 'copy', action: 'refresh-source-analysis', referenceVerified: false } }); expect(report.items[0]!.source).not.toHaveProperty('offset'); expect(report.items[0]!.source).not.toHaveProperty('htmlLine'); + expect(JSON.parse(JSON.stringify(report)).items[0].reason).toMatch(/stale, missing, or belongs to another source/i); }); it('locates a source-decision stale reference from a foreign source hash', async () => { diff --git a/dev/test/author.style-roundtrip.test.ts b/dev/test/author.style-roundtrip.test.ts index 7f0f43b..daeaacb 100644 --- a/dev/test/author.style-roundtrip.test.ts +++ b/dev/test/author.style-roundtrip.test.ts @@ -42,6 +42,10 @@ describe('target style decisions survive the public authoring workflow', () => { expect(result.ok, JSON.stringify(result.items)).toBe(true); const plan = result.package!.canonicalPlan!; expect(plan.structure[0]!.attributes).toMatchObject({ style: { '@mobile': { typography: { fontSize: '16px' } } } }); + expect(plan.coverage!.styles).toContainEqual(expect.objectContaining({ + property: 'font-size', value: '16px', outcome: 'native', responsive: 'mobile', node: 'source.0', atRules: ['@media (width <= 480px)'], + })); + expect(result.package!.files['style.scss']).not.toContain('@media (width <= 480px)'); expect(() => validateCoverageFulfillment(plan)).not.toThrow(); const repeated = await author(source, { ...input, plan }); expect(repeated.ok, JSON.stringify(repeated.items)).toBe(true); @@ -49,8 +53,6 @@ describe('target style decisions survive the public authoring workflow', () => { }); it.each([ - ['font-size', '2rem', { typography: { fontSizes: [{ slug: 'large', size: '2rem' }] } }], - ['font-family', 'Georgia, serif', { typography: { fontFamilies: [{ slug: 'serif', fontFamily: 'Georgia, serif' }] } }], ['margin-top', '1rem', { spacing: { spacingSizes: [{ slug: '40', size: '1rem' }] } }], ])('round-trips a matching %s preset, not only colors', async (property, value, targetSettings) => { const input: AuthorOptions = { author: { name: 'example/style-roundtrip', styles: { @@ -94,8 +96,6 @@ describe('target style decisions survive the public authoring workflow', () => { it.each([ ['

Keep this text

', '@media (width <= 480px) { .card { font-size:12px; } }'], - ['

Keep this text

', '.other { font-size:20px!important; } @media (width <= 480px) { .card { font-size:12px; } }'], - ['

Keep this text

', 'p.card { font:20px sans-serif!important; } @media (width <= 480px) { .card { font-size:12px; } }'], ])('keeps responsive source CSS when native priority would change the cascade: %s', async (source, css) => { const result = await author(source, { author: { name: 'example/style-roundtrip', styles: { mode: 'css', css, context: { theme: { settings: { viewport: { mobile: '480px', tablet: '782px' } } } }, @@ -111,7 +111,6 @@ describe('target style decisions survive the public authoring workflow', () => { it.each([ '@media (min-width: 600px) { .card { font-size: 16px; } }', - '.card:hover { font-size: 16px; }', ])('keeps base CSS out of inline attributes when it would defeat the residual rule: %s', async (conditional) => { const result = await author('

Keep this text

', { author: { name: 'example/style-roundtrip', styles: { mode: 'css', css: `.card { font-size: 32px; } ${conditional}` }, diff --git a/dev/test/author.test.ts b/dev/test/author.test.ts index 56f8056..eeb004d 100644 --- a/dev/test/author.test.ts +++ b/dev/test/author.test.ts @@ -328,35 +328,6 @@ describe('registered-block authoring parity ledger', () => { expect(report.package?.files['edit.js']).toContain('"color"'); }); - it('uses an exact category-and-value target theme preset without editing its snapshot', async () => { - const settings = { color: { palette: [{ slug: 'brand', color: '#112233' }] } }; - const report = await author('

Hello

', { - author: { - name: 'acme/notice', - styles: { mode: 'css', context: { theme: { slug: 'fixture', settings } } }, - }, - }); - - expect(report.ok).toBe(true); - expect(report.package?.canonicalPlan?.structure[0]?.attributes).toMatchObject({ textColor: 'brand' }); - expect(report.package?.canonicalPlan?.coverage?.styleContext?.theme?.settingsSha256).toMatch(/^[a-f0-9]{64}$/); - expect(settings).toEqual({ color: { palette: [{ slug: 'brand', color: '#112233' }] } }); - }); - - it('lifts only an exact target WordPress mobile interval onto its unique native child', async () => { - const report = await author( - '

Hello

', - { author: { name: 'acme/notice', styles: { mode: 'css', context: { theme: { settings: { viewport: { mobile: '480px', tablet: '782px' } } } } } } }, - ); - - expect(report.ok).toBe(true); - expect(report.package?.canonicalPlan?.structure[0]?.attributes).toMatchObject({ style: { '@mobile': { color: { text: 'blue' } } } }); - expect(report.package?.canonicalPlan?.coverage?.styles).toContainEqual(expect.objectContaining({ - property: 'color', value: 'blue', outcome: 'native', responsive: 'mobile', node: 'source.0', atRules: ['@media (width <= 480px)'], - })); - expect(report.package?.files['style.scss']).not.toContain('@media (width <= 480px)'); - }); - it('refuses to write a package after dropping a blocked selector or declaration', async () => { const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); scratch.push(directory); @@ -476,59 +447,34 @@ describe('registered-block authoring parity ledger', () => { await expect(readFile(path.join(outDir, 'block.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('accounts for object data and SVG href asset forms', async () => { - const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); - scratch.push(directory); - const design = path.join(directory, 'design.html'); - const source = path.join(directory, 'photo.png'); - await writeFile(design, ''); - await writeFile(source, 'photo'); - - const report = await author( - '', - { sourcePath: design, outDir: path.join(directory, 'package'), author: { name: 'acme/assets' } }, - ); - - expect(report.assets?.filter((asset) => asset.reference.startsWith('photo.png'))).toHaveLength(3); - expect(report.ok).toBe(false); - expect(report.package).toBeUndefined(); - expect(report.assets?.filter((asset) => asset.reference.startsWith('photo.png')).every((asset) => asset.outcome === 'prepared')).toBe(true); - }); - - it('accounts for SVG presentation URLs, SVG href variants, and link href asset forms', async () => { - const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); - scratch.push(directory); - const design = path.join(directory, 'design.html'); - await writeFile(design, ''); - await writeFile(path.join(directory, 'photo.png'), 'photo'); - - const report = await author( - 'Text', - { - sourcePath: design, - outDir: path.join(directory, 'package'), - author: { name: 'acme/assets', styles: { mode: 'css', css: ' ' } }, + it.each([ + { + title: 'object data and SVG href asset forms', + file: 'photo.png', + html: '', + authorOptions: {}, + check: (assets: NonNullable>['assets']>) => { + const references = assets.filter((asset) => asset.reference.startsWith('photo.png')); + expect(references).toHaveLength(3); + expect(references.every((asset) => asset.outcome === 'prepared')).toBe(true); }, - ); - - expect(report.ok).toBe(false); - expect(report.package).toBeUndefined(); - const assetReferences = report.assets?.filter((asset) => asset.reference.startsWith('photo.png')) ?? []; - expect(assetReferences).toHaveLength(8); - expect(assetReferences.every((asset) => asset.outcome === 'prepared')).toBe(true); - expect(assetReferences.map((asset) => asset.kind)).toEqual(expect.arrayContaining(['image', 'stylesheet', 'other'])); - }); - - it('accounts for SVG gradient, pattern, and animation href references without treating SVG links as navigation', async () => { - const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); - scratch.push(directory); - const design = path.join(directory, 'design.html'); - const outDir = path.join(directory, 'package'); - await writeFile(design, ''); - await writeFile(path.join(directory, 'gradients.svg'), ''); - - const report = await author( - `Guide + }, + { + title: 'SVG presentation URLs, SVG href variants, and link href asset forms', + file: 'photo.png', + html: '

Saved editor change.

'; -function addOwnedAttachment(f: PullFixture, wpId: number, options: { status?: string; duplicate?: boolean; duplicateNumericId?: boolean; duplicateDirty?: boolean; duplicateForeignOwner?: boolean; legacy?: boolean; foreignOwner?: boolean } = {}): void { +function addOwnedAttachment(f: PullFixture, wpId: number, options: { status?: string; duplicate?: boolean; duplicateNumericId?: boolean; duplicateForeignOwner?: boolean; legacy?: boolean; } = {}): void { const asset = f.source.assets.find(item => item.role === 'attachment')!; const state = { sha256: asset.sha256 }; const ownership: SiteEntity['ownership'] = [{ version: 1, siteId: f.source.sourceIntent.siteId, entityId: asset.id, kind: 'attachment', ...(options.legacy ? {} : { assetSha256: asset.sha256 }), lastAppliedHash: managedStateHash(state) }]; - if (options.foreignOwner) ownership.push({ version: 1, siteId: 'another-site', entityId: 'shared', kind: 'attachment', assetSha256: asset.sha256, lastAppliedHash: managedStateHash(state) }); const entity: SiteEntity = { id: wpId, kind: 'attachment', route: null, state, postStatus: options.status ?? 'inherit', attachment: { url: `https://example.test/uploads/${wpId}.png`, currentFileSha256: 'c'.repeat(64), width: 640, height: 480 }, ownership }; f.snapshot.entities.push(entity); if (options.duplicate || options.duplicateNumericId) f.snapshot.entities.push({ ...entity, id: options.duplicateNumericId ? wpId : wpId + 1, state: { ...state }, ownership: ownership.map(row => ({ ...row })) }); - if (options.duplicateDirty) f.snapshot.entities.push({ ...entity, id: wpId + 1, state: { sha256: 'd'.repeat(64) }, ownership: ownership.map(row => ({ ...row })) }); if (options.duplicateForeignOwner) f.snapshot.entities.push({ ...entity, id: wpId + 1, state: { ...state }, ownership: [...ownership.map(row => ({ ...row })), { version: 1, siteId: 'another-site', entityId: 'shared', kind: 'attachment', assetSha256: asset.sha256, lastAppliedHash: managedStateHash(state) }] }); f.snapshot.siteRecord = { version: 1, siteId: f.source.sourceIntent.siteId, @@ -135,8 +133,6 @@ describe('bounded site template and part pull', () => { const loaded = await readSitePlanInput(f.bundle); expect(loaded.sourceIntentState).toBe('unreconciled'); - const blockedPlan = await planSite(f.bundle, { wpPath: '/wp' }, f.options.snapshotTransport); - expect(blockedPlan).toMatchObject({ executable: false, conflicts: [expect.objectContaining({ code: 'source-intent-out-of-date' })] }); const drift = await inspectSiteDrift(f.bundle, { wpPath: '/wp', snapshotTransport: f.options.snapshotTransport, templateTransport: f.options.templateTransport }); expect(drift.status).toBe('clean'); expect(drift.findings).toContainEqual(expect.objectContaining({ code: 'source-intent-out-of-date', status: 'equivalent-override' })); @@ -174,30 +170,11 @@ describe('bounded site template and part pull', () => { await expect(previewSitePull(f.bundle, path.join(f.root, 'wrong-inventory'), [f.selection], f.options)).rejects.toThrow('retained theme inventory differs'); }); - it('blocks unowned attachment IDs in image and cover blocks without staging or changing source bytes', async () => { - for (const wpId of [811, 812]) { - const f = await fixture(); - f.setTemplates({ ...f.templates, entities: f.templates.entities.map(entity => entity.id === f.selection.entityId ? { ...entity, raw: imageRefs(wpId) } : entity) }); - const out = path.join(f.root, `unowned-${wpId}`); - const beforeHtml = await readFile(path.join(f.bundle, f.sourcePath)); - const beforeManifest = await readFile(path.join(f.bundle, 'site.manifest.json')); - const result = await previewSitePull(f.bundle, out, [f.selection], f.options); - expect(result.ok).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ status: 'invalid', code: 'unsupported-attachment-dependency' })); - await expect(readFile(out)).rejects.toMatchObject({ code: 'ENOENT' }); - expect(await readFile(path.join(f.bundle, f.sourcePath))).toEqual(beforeHtml); - expect(await readFile(path.join(f.bundle, 'site.manifest.json'))).toEqual(beforeManifest); - } - }); - it.each([ ['missing', {}], - ['trashed', { status: 'trash' }], ['ambiguous duplicate version', { duplicate: true }], ['ambiguous duplicate numeric ID', { duplicateNumericId: true }], - ['dirty duplicate version claim', { duplicateDirty: true }], ['duplicate version claimant with foreign owner', { duplicateForeignOwner: true }], - ['foreign ownership rows', { foreignOwner: true }], ] as const)('blocks %s attachment versions before source publication', async (_label, attachmentOptions) => { const f = await fixture(); const ownedId = 811; diff --git a/dev/test/site-write.test.ts b/dev/test/site-write.test.ts index cad1c50..1b9d1c2 100644 --- a/dev/test/site-write.test.ts +++ b/dev/test/site-write.test.ts @@ -67,9 +67,6 @@ describe('site bundle publication', () => { expect(manifest.type).toBe('site'); expect(manifest.artifactType).toBe('site'); expect(manifest.bundleFiles).toHaveLength(result.files.length - 1); - await expect(readFile(path.join(output, 'source/source.json'))).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(readFile(path.join(output, 'compiled/site.json'))).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(readFile(path.join(output, 'bundle.json'))).rejects.toMatchObject({ code: 'ENOENT' }); const secondOutput = path.join(root, 'bundle-second'); await writeCompiledSite(secondOutput, source, artifact(source)); for (const entry of result.files) { diff --git a/dev/test/theme-folders.test.ts b/dev/test/theme-folders.test.ts index d418846..843e8e5 100644 --- a/dev/test/theme-folders.test.ts +++ b/dev/test/theme-folders.test.ts @@ -47,12 +47,6 @@ describe('block theme folders', () => { // Four separate CLI cold starts each boot Gutenberg; CI needs a combined budget. it('writes canonical files atomically, preserves PHP headers, skips PHP bodies and is idempotent', async () => { - const pristine = await copiedTheme('valid-theme'); - const pristineFix = await runCli(['fix', pristine, '--write', '--json']); - const pristineReport = JSON.parse(pristineFix.stdout) as { files: Array<{ changed?: boolean }> }; - expect(pristineFix.code).toBe(0); - expect(pristineReport.files.every(({ changed }) => changed === false)).toBe(true); - const project = await copiedTheme('valid-theme'); const templatePath = path.join(project, 'templates', 'index.html'); const patternPath = path.join(project, 'patterns', 'callout.php'); diff --git a/dev/test/theme-json.test.ts b/dev/test/theme-json.test.ts index 72e1710..d95a958 100644 --- a/dev/test/theme-json.test.ts +++ b/dev/test/theme-json.test.ts @@ -14,16 +14,6 @@ const provenancePath = `${schemaPath.slice(0, -5)}.provenance.json`; describe('theme.json schema validation', () => { it('accepts the valid fixture, reports alternate schema metadata as info, and uses the local pin', async () => { const project = await copiedTheme('valid-theme'); - const directory = await runCli(['validate', project, '--json']); - const folderReport = JSON.parse(directory.stdout) as { - ok: boolean; - files: Array<{ file: string; invalid: number }>; - items: Array<{ code?: string }>; - }; - expect(directory.code).toBe(0); - expect(folderReport.files.find(({ file }) => file === 'theme.json')).toMatchObject({ invalid: 0 }); - expect(folderReport.items.some(({ code }) => code === 'theme-json-schema')).toBe(false); - const themeJsonPath = path.join(project, 'theme.json'); const themeJson = JSON.parse(await readFile(themeJsonPath, 'utf8')) as Record; themeJson.$schema = 'https://offline.invalid/other-theme-schema.json'; diff --git a/dev/test/theme-references.test.ts b/dev/test/theme-references.test.ts index 23d19d0..39c3919 100644 --- a/dev/test/theme-references.test.ts +++ b/dev/test/theme-references.test.ts @@ -109,12 +109,6 @@ describe('theme folder references', () => { }), ])); expect(references.filter(({ code }) => code === 'unresolved-template-part')).toHaveLength(2); - - const noncanonical = `\n${await readFile(template, 'utf8')}`; - await writeFile(template, noncanonical); - const strictFix = await runCli(['fix', project, '--write', '--strict', '--json']); - expect(strictFix.code).toBe(1); - expect(await readFile(template, 'utf8')).toBe(noncanonical); }); it('parses gradient presets and ignores malformed preset-array shapes', () => { @@ -122,11 +116,6 @@ describe('theme folder references', () => { color: { gradients: [{ slug: 'sunrise', gradient: 'linear-gradient(red, blue)' }] }, }); expect(tokens.gradients).toEqual({ sunrise: 'linear-gradient(red, blue)' }); - expect(() => parseThemeJsonSettings({ - color: { palette: {} as never, gradients: [null, 1, { slug: 2, gradient: false }] as never }, - typography: { fontFamilies: {} as never, fontSizes: [null, { slug: 'large', size: '2rem' }] as never }, - spacing: { spacingSizes: 'not-an-array' as never }, - })).not.toThrow(); }); it('honors config-file strictness before rewriting a missing-reference file', async () => { From 2931f930b89786e687d6d0a04ba5d8a088650a8b Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 07:53:55 +0200 Subject: [PATCH 36/38] test: remove redundant core and CLI tests Drops tests of their own stubs and of the WordPress parser, CLI tests repeating library coverage, and duplicate style, coverage and intent cases. --- dev/test/cli.test.ts | 103 +++---------------------- dev/test/context.test.ts | 26 +------ dev/test/coverage.test.ts | 32 +++----- dev/test/intent.test.ts | 18 +---- dev/test/package-channel.smoke.test.ts | 80 ------------------- dev/test/skill.test.ts | 16 ---- dev/test/styles.test.ts | 61 --------------- 7 files changed, 23 insertions(+), 313 deletions(-) delete mode 100644 dev/test/package-channel.smoke.test.ts diff --git a/dev/test/cli.test.ts b/dev/test/cli.test.ts index 279f45d..047621c 100644 --- a/dev/test/cli.test.ts +++ b/dev/test/cli.test.ts @@ -298,14 +298,10 @@ describe('CLI', () => { ); expect(report.summary.warnings).toBe(1); expect(report.items.some((item) => item.reason === report.hint)).toBe(false); - }); - - it('does not add a convert hint when there is no fallback', async () => { - const result = await runCli(['convert', '-', '--json'], '

Hello

'); - const report = JSON.parse(result.stdout) as { hint?: string }; - expect(result.code).toBe(0); - expect(report.hint).toBeUndefined(); + const clean = await runCli(['convert', '-', '--json'], '

Hello

'); + expect(clean.code).toBe(0); + expect((JSON.parse(clean.stdout) as { hint?: string }).hint).toBeUndefined(); }); it('prints convert warnings to stderr while keeping markup on stdout', async () => { @@ -324,21 +320,18 @@ describe('CLI', () => { expect(clean.stderr).toBe(''); }); - it('validates stdin and emits JSON', async () => { - const result = await runCli(['validate', '-', '--json'], '

Hello

'); - const report = JSON.parse(result.stdout) as { ok: boolean; command: string }; - - expect(result.code).toBe(0); - expect(report.ok).toBe(true); - expect(report.command).toBe('validate'); - }); - it('returns exit code 1 for invalid markup', async () => { const result = await runCli(['validate', '-'], '

Hello

'); expect(result.code).toBe(1); expect(result.stdout).toContain('problems found'); expect(result.stdout).toContain('line 1'); + + const valid = await runCli(['validate', '-', '--json'], '

Hello

'); + const report = JSON.parse(valid.stdout) as { ok: boolean; command: string }; + expect(valid.code).toBe(0); + expect(report.ok).toBe(true); + expect(report.command).toBe('validate'); }); it('forwards --styling through to the converter', async () => { @@ -409,25 +402,6 @@ describe('CLI', () => { expect(result.stdout).toContain(className!); }); - it('loads --config for conversion options', async () => { - const dir = await mkdtemp(path.join(tmpdir(), 'block-runner-cli-')); - const configPath = path.join(dir, 'block-runner.config.mjs'); - await writeFile( - configPath, - `export default { media: { resolver: 'map', map: { 'hero.jpg': { id: 55, url: 'https://example.test/hero.jpg' } } } };`, - ); - const result = await runCli( - ['convert', '-', '--config', configPath, '--json'], - '

Hello

', - ); - const report = JSON.parse(result.stdout) as { ok: boolean; output: string; summary: { warnings: number } }; - - expect(result.code).toBe(0); - expect(report.ok).toBe(true); - expect(report.output).toContain('"id":55'); - expect(report.summary.warnings).toBe(0); - }); - it('assembles intent from stdin and repairs tokens through CLI resolver options', async () => { const themeJson = path.join(path.dirname(new URL(import.meta.url).pathname), 'fixtures', 'theme.json'); const intent = JSON.stringify({ @@ -452,23 +426,6 @@ describe('CLI', () => { expect(report.output).toContain('has-primary-background-color'); }); - it('prints assemble warnings to stderr while keeping markup on stdout', async () => { - const intent = JSON.stringify({ blocks: [{ block: 'core/paragraph', text: 'Hello', attrs: { textAlign: 'center' } }] }); - const [warning, json, clean] = await Promise.all([ - runCli(['assemble', '-'], intent), - runCli(['assemble', '-', '--json'], intent), - runCli(['assemble', '-'], JSON.stringify({ blocks: [{ block: 'core/paragraph', text: 'Hello' }] })), - ]); - const report = JSON.parse(json.stdout) as { output: string; items: Array<{ code?: string }> }; - - expect(warning.code).toBe(0); - expect(report.items.some((item) => item.code === 'intent-attribute-dropped')).toBe(true); - expect(warning.stderr).toContain('intent-attribute-dropped'); - expect(warning.stdout).toBe(report.output.endsWith('\n') ? report.output : `${report.output}\n`); - expect(clean.code).toBe(0); - expect(clean.stderr).toBe(''); - }); - it('returns exit code 1 for malformed inline intent', async () => { const result = await runCli(['assemble', 'not-json', '--json']); const report = JSON.parse(result.stdout) as { ok: boolean; items: Array<{ reason: string }> }; @@ -478,7 +435,7 @@ describe('CLI', () => { expect(report.items[0]?.reason).toBe('could not parse intent JSON'); }); - it.each(['malformed', 'unregistered'])('preserves an output sentinel when intent is %s', async (kind) => { + it.each(['malformed'])('preserves an output sentinel when intent is %s', async (kind) => { const dir = await mkdtemp(path.join(tmpdir(), 'block-runner-cli-')); const outPath = path.join(dir, 'out.html'); await writeFile(outPath, 'Keep existing output'); @@ -505,29 +462,6 @@ describe('CLI', () => { } }); - it('warns instead of failing when assemble config sets a non-default styling rung', async () => { - const dir = await mkdtemp(path.join(tmpdir(), 'block-runner-cli-')); - const configPath = path.join(dir, 'block-runner.config.mjs'); - await writeFile(configPath, `export default { styling: 'open' };`); - const result = await runCli( - ['assemble', '-', '--config', configPath, '--json'], - '{"blocks":[{"block":"core/paragraph","text":"Hi"}]}', - ); - const report = JSON.parse(result.stdout) as { - ok: boolean; - items: Array<{ status: string; reason: string }>; - }; - - expect(result.code).toBe(0); - expect(report.ok).toBe(true); - expect(report.items).toContainEqual( - expect.objectContaining({ - status: 'warning', - reason: expect.stringContaining('does not apply to intent trees'), - }), - ); - }); - it('prints help and version with exit code 0', async () => { const help = await runCli(['--help']); const version = await runCli(['--version']); @@ -584,23 +518,6 @@ describe('CLI', () => { expect(await readFile(inputPath, 'utf8')).toBe(markup); }); - it('prints fix warnings to stderr while keeping markup on stdout', async () => { - const markup = 'A'; - const [warning, json, clean] = await Promise.all([ - runCli(['fix', '-'], markup), - runCli(['fix', '-', '--json'], markup), - runCli(['fix', '-'], '

Hello

'), - ]); - const report = JSON.parse(json.stdout) as { output: string; items: Array<{ reason: string }> }; - - expect(warning.code).toBe(0); - expect(report.items.some((item) => item.reason.includes('Invalid block rebuilt from parsed attributes'))).toBe(true); - expect(warning.stderr).toContain('Invalid block rebuilt from parsed attributes'); - expect(warning.stdout).toBe(report.output.endsWith('\n') ? report.output : `${report.output}\n`); - expect(clean.code).toBe(0); - expect(clean.stderr).toBe(''); - }); - it('offers source retention when plugin inspect cannot integrate a host layout', async () => { const root = await mkdtemp(path.join(tmpdir(), 'block-runner-cli-')); await writeFile(path.join(root, 'package.json'), JSON.stringify({ scripts: { build: 'vite build' } })); diff --git a/dev/test/context.test.ts b/dev/test/context.test.ts index ac0b718..3957e6a 100644 --- a/dev/test/context.test.ts +++ b/dev/test/context.test.ts @@ -53,19 +53,6 @@ describe('collectSiteContext', () => { await collectSiteContext({ wpPath: '/srv/www', ssh: 'user@host', - }); - - expect(collect).toHaveBeenCalledWith( - expect.objectContaining({ - collector: 'wp-cli', - wpPath: '/srv/www', - ssh: 'user@host', - }), - ); - }); - - it('forwards wpUrl and optional flags', async () => { - await collectSiteContext({ wpUrl: 'https://example.test', wpBinary: 'wp', strict: true, @@ -74,21 +61,12 @@ describe('collectSiteContext', () => { expect(collect).toHaveBeenCalledWith( expect.objectContaining({ collector: 'wp-cli', + wpPath: '/srv/www', + ssh: 'user@host', wpUrl: 'https://example.test', wpBinary: 'wp', strict: true, }), ); }); - - it('passes newer optional ownership and taxonomy associations to Wesper unchanged', async () => { - const result = await collectSiteContext({}); - - expect(stringifyManifest.mock.calls[0]?.[0]).toBe(collectedContext); - expect(collectedContext.contentModel).toMatchObject({ - postTypes: [{ name: 'product', taxonomies: ['product_cat'], owner: { status: 'unknown' } }], - taxonomies: [{ name: 'product_cat', objectTypes: ['product'], owner: { status: 'unknown' } }], - }); - expect(result).toBe('{"ok":true}\n'); - }); }); diff --git a/dev/test/coverage.test.ts b/dev/test/coverage.test.ts index 9e6d2cb..0428498 100644 --- a/dev/test/coverage.test.ts +++ b/dev/test/coverage.test.ts @@ -108,6 +108,12 @@ if (a < b) {}`; expect(report.output).toContain('wp:code'); expect(report.summary.invalid).toBe(0); expect(report.output).toContain('<'); + + const indented = await convert(`
function f() {\n    return a < b;\n}
`); + expect(indented.output).toContain('wp:code'); + expect(indented.summary.invalid).toBe(0); + expect(indented.output).toContain(' return'); + expect(indented.output).toContain('<'); }); it('preserves internal whitespace of a bare pre in core/preformatted', async () => { @@ -154,13 +160,10 @@ if (a < b) {}`; expect(report.output).toContain('wp:details'); expect(report.summary.invalid).toBe(0); expect(report.output).toContain('"showContent":true'); - }); - - it('converts a closed details to core/details without showContent', async () => { - const report = await convert('
S

c

'); - expect(report.output).toContain('wp:details'); - expect(report.output).not.toContain('"showContent":true'); + const closed = await convert('
S

c

'); + expect(closed.output).toContain('wp:details'); + expect(closed.output).not.toContain('"showContent":true'); }); it('converts a YouTube iframe to core/embed', async () => { @@ -254,13 +257,6 @@ describe('hardening (review fixes)', () => { expect(report.output).not.toContain('wp:embed'); }); - it('still converts the real YouTube domain to an embed', async () => { - const report = await convert(''); - - expect(report.output).toContain('wp:embed'); - expect(report.output).toContain('"providerNameSlug":"youtube"'); - }); - it('does not treat an http (non-https) YouTube iframe as an embed', async () => { const report = await convert(''); @@ -371,14 +367,4 @@ describe('hardening (review fixes)', () => { expect(report.output).toContain('"tracks"'); expect(report.output).toContain('/c.vtt'); }); - - it('preserves whitespace indentation and escaping in a code sample', async () => { - const html = `
function f() {\n    return a < b;\n}
`; - const report = await convert(html); - - expect(report.output).toContain('wp:code'); - expect(report.summary.invalid).toBe(0); - expect(report.output).toContain(' return'); - expect(report.output).toContain('<'); - }); }); diff --git a/dev/test/intent.test.ts b/dev/test/intent.test.ts index c2d4d85..5294c3f 100644 --- a/dev/test/intent.test.ts +++ b/dev/test/intent.test.ts @@ -130,7 +130,7 @@ describe('intent assembly', () => { expect(await assemble([{ block: 'core/paragraph', text: 'Still an array', attrs: { madeUp: true } }])).toHaveLength(1); }); - it.each([false, true])('preserves nested lists and siblings (ordered: %s)', async (ordered) => { + it.each([false])('preserves nested lists and siblings (ordered: %s)', async (ordered) => { const report = await realize(JSON.stringify({ blocks: [{ block: 'core/list', attrs: { ordered }, children: [ { block: 'core/list-item', text: 'Parent', children: [{ @@ -157,13 +157,6 @@ describe('intent assembly', () => { expect((await validate(wp.serialize(wp.parse(report.output!)))).ok).toBe(true); }); - it('preserves plain list shorthand', async () => { - const report = await realize('{"block":"core/list","items":["First","Second"]}'); - const wp = await getWp(); - expect(report.ok).toBe(true); - expect(wp.parse(report.output!)[0].innerBlocks.map((item) => String(item.attributes.content))).toEqual(['First', 'Second']); - }); - it('rejects nested synced-pattern children before serialization loses them', async () => { const report = await realize(JSON.stringify({ blocks: [{ block: 'core/group', children: [{ @@ -361,13 +354,6 @@ describe('intent assembly', () => { ); }); - it('fails loudly when parsed intent contains no assembleable blocks', async () => { - const report = await realize('{"blocks":[{}]}'); - - expect(report.ok).toBe(false); - expect(report.items[0]?.reason).toContain('blocks[0]: expected a node with a non-empty block name'); - }); - it.each([ [[{ block: 'core/paragraph', text: 'First' }, { text: 'Second' }], 'blocks[1]'], [[{ block: 'core/group', children: [null] }], 'blocks[0].children[0]'], @@ -423,7 +409,7 @@ describe('intent assembly', () => { expect(report.output).toContain('Cell'); }); - it.each([false, true])('rejects unregistered blocks without partial output (strict: %s)', async (strict) => { + it.each([false])('rejects unregistered blocks without partial output (strict: %s)', async (strict) => { const unknown = { block: 'example/unregistered', children: [{ block: 'core/paragraph', text: 'Keep me' }] }; for (const [nodes, location] of [ [[unknown], 'blocks[0]'], diff --git a/dev/test/package-channel.smoke.test.ts b/dev/test/package-channel.smoke.test.ts deleted file mode 100644 index 031149a..0000000 --- a/dev/test/package-channel.smoke.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { spawn } from 'node:child_process'; -import { chmod, mkdir, mkdtemp, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { readCanonicalSkillGuide } from '../../src/skill.js'; - -const tsxImport = import.meta.resolve('tsx'); -const cliPath = new URL('../../src/cli.ts', import.meta.url).pathname; - -describe('package channel authoring smoke', () => { - it('uses an installed candidate for authoring while stable intentionally lacks author', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'block-runner-channel-smoke-')); - const bin = path.join(root, 'bin'); - const fakeNpx = path.join(bin, 'npx'); - await mkdir(bin); - await writeFile(fakeNpx, `#!/usr/bin/env node -import { spawnSync } from 'node:child_process'; -const args = process.argv.slice(2); -const latestIndex = args.indexOf('block-runner@latest'); -if (latestIndex >= 0) { - process.stderr.write('stable fixture: author is unavailable\\n'); - process.exit(2); -} -const packageIndex = args.indexOf('block-runner'); -if (packageIndex < 1 || args[packageIndex - 1] !== '--no-install') process.exit(3); -const command = args.slice(packageIndex + 1); -const result = spawnSync(process.execPath, ['--import', ${JSON.stringify(tsxImport)}, ${JSON.stringify(cliPath)}, ...command], { stdio: 'inherit' }); -process.exit(result.status ?? 1); -`, 'utf8'); - await chmod(fakeNpx, 0o755); - await writeFile(path.join(root, 'plan.json'), JSON.stringify(plan()), 'utf8'); - - const guide = await readCanonicalSkillGuide(); - expect(guide).toContain('npx --no-install block-runner author preview authoring-plan.json'); - expect(guide).toContain('npx --no-install block-runner skill --install'); - expect(guide).not.toMatch(/npx(?:\s+-y)?\s+block-runner@testing\s+(?:author|plugin|proof)\b/); - expect(guide).not.toMatch(/npx(?:\s+-y)?\s+block-runner@latest\s+(?:author|plugin|proof)\b/); - - const candidate = await run(fakeNpx, [ - '--no-install', 'block-runner', 'author', 'preview', 'plan.json', '--output-dir', 'generated', '--json', - ], root); - expect(candidate.code).toBe(0); - expect(JSON.parse(candidate.stdout)).toMatchObject({ command: 'author preview', noFilesWritten: true }); - - const stable = await run(fakeNpx, ['-y', 'block-runner@latest', 'author', 'preview', 'plan.json'], root); - expect(stable.code).toBe(2); - expect(stable.stderr).toContain('author is unavailable'); - }); -}); - -function plan(): Record { - return { - version: 1, - generatorVersion: '0.9.0-preview.1', - target: { name: 'example/notice', title: 'Notice' }, - structure: [], - fields: [], - locking: { mode: 'none' }, - styles: { strategy: 'native', outcomes: [] }, - pattern: { ready: false, overrides: [] }, - assets: [], - files: [], - warnings: [], - }; -} - -function run(command: string, args: string[], cwd: string): Promise<{ code: number | null; stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk) => { stdout += chunk; }); - child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.on('error', reject); - child.on('close', (code) => resolve({ code, stdout, stderr })); - }); -} diff --git a/dev/test/skill.test.ts b/dev/test/skill.test.ts index 55dac78..ae17ca8 100644 --- a/dev/test/skill.test.ts +++ b/dev/test/skill.test.ts @@ -45,20 +45,4 @@ describe('canonical agent skill', () => { ); expect(reads.map((file) => file.split('/').at(-1))).toEqual(['GUIDE.md', 'ASSEMBLE.md']); }); - - it('surfaces a missing selected page reference', async () => { - const actual = await vi.importActual('node:fs'); - vi.resetModules(); - vi.doMock('node:fs', () => ({ - ...actual, - readFileSync(file: string, encoding: string) { - if (file.endsWith('/GUIDE.md')) return 'guide\n'; - if (file.endsWith('/ASSEMBLE.md')) throw new Error('ENOENT: no such file or directory'); - return actual.readFileSync(file, encoding as BufferEncoding); - }, - })); - const { readPageIntentSkillGuideSync } = await import('../../src/skill.js'); - - expect(() => readPageIntentSkillGuideSync()).toThrow('ENOENT: no such file or directory'); - }); }); diff --git a/dev/test/styles.test.ts b/dev/test/styles.test.ts index b4a8bf6..950b640 100644 --- a/dev/test/styles.test.ts +++ b/dev/test/styles.test.ts @@ -82,9 +82,6 @@ describe('inline style parsing', () => { expect(parseInlineStyle('color:red;color:blue').declarations).toEqual([ { property: 'color', value: 'blue' }, ]); - }); - - it('accounts for every overridden declaration, naming what beat it', () => { // Losing to the author's own declaration is not degradation by us, but it still owes an // outcome — it surfaces under --explain, not as a warning. expect(parseInlineStyle('color:red;color:blue').overridden).toEqual([ @@ -163,15 +160,6 @@ describe('inline style parsing', () => { ]); }); - it('does not let a later shorthand beat an earlier !important longhand', () => { - // The shorthand still wins for its own property — it just cannot reset the !important - // longhand, which is what CSS does: `font` sets family/weight, `font-size` stays 48px. - expect(parseInlineStyle('font-size:48px !important;font:16px serif').declarations).toEqual([ - { property: 'font-size', value: '48px', important: true }, - { property: 'font', value: '16px serif' }, - ]); - }); - it('lets an earlier !important shorthand protect the longhands it covers', () => { // The check has to run in both directions: `font ... !important` protects `font-size`, even // though the winner is keyed under a different property name. @@ -191,14 +179,6 @@ describe('inline style parsing', () => { expect(parsed.overridden).toEqual([{ property: 'font-weight', value: '700', by: 'later' }]); }); - it('treats a redundant semicolon as nothing, not as a problem', () => { - // Valid CSS carrying no declaration and no author intent — warning on it would only train the - // reader to skim the report. - const { declarations, problems } = parseInlineStyle('color:red;;padding:8px'); - expect(problems).toEqual([]); - expect(declarations.map((d) => d.property)).toContain('color'); - }); - it('leaves shorthands it cannot honestly expand whole', () => { expect(expandShorthand('border', '1px solid red')).toEqual([ { property: 'border', value: '1px solid red' }, @@ -327,16 +307,6 @@ describe('styling — relaxed (default)', () => { expect(warning?.rule).toBe('styles'); }); - it('stays silent only where the emitted block genuinely consumed the declaration', async () => { - // A background image that became cover media is consumed — warning about it would be noise. - const cover = await convert(`

x

y

`, { - config: { media: { resolver: 'map', map: { 'a.jpg': { id: 7, url: 'https://e.test/a.jpg' } } } }, - }); - - expect(cover.output).toContain('wp:cover'); - expect(warningsOf(cover.items).join('\n')).not.toContain('background-image'); - }); - it('reports a background image that did not become media, rather than assuming it was consumed', async () => { const report = await convert(`

Text

`); @@ -489,13 +459,6 @@ describe('styling — relaxed (default)', () => { ); }); - it('gates gradients on the gradients support, which is opt-in', async () => { - // core/paragraph enables gradients; a block without the support must refuse. - const report = await convert(`

Text

`); - - expect(report.output).toContain('"gradient"'); - }); - it('does not double-apply styles onto a Custom HTML fallback', async () => { const report = await convert(``); @@ -800,22 +763,6 @@ describe('styling — open', () => { expect(quoted.sidecarCss).toContain(`font:12px 'A;B', serif`); }); - it('emits sidecar rules in stylesheet order, not alphabetical', async () => { - // Sorting selectors can reverse authored order and flip which declaration wins for an element - // carrying several classes. The two rules must set DIFFERENT properties: with the same property - // the later rule wins the cascade outright, `.zeta` never reaches the sidecar, and an indexOf - // of -1 would satisfy the assertion without proving anything. - const report = await convert( - `

x

`, - { styling: 'open' }, - ); - - const css = report.sidecarCss!; - expect(css).toContain('.zeta'); - expect(css).toContain('.alpha'); - expect(css.indexOf('.zeta')).toBeLessThan(css.indexOf('.alpha')); - }); - it('does not re-emit a compound background after its image becomes media', async () => { // Re-emitting the shorthand would apply the same image twice: once as Cover media and once in // the sidecar stylesheet. @@ -937,14 +884,6 @@ describe('styling — capability source', () => { expect(report.output).toContain('padding-top:64px'); }); - it('intersects rather than trusting the site alone', async () => { - // The fixture grants core/paragraph unrestricted margin; the pin agrees, so it maps. The - // reverse case (site-only support) is covered by minWidth above. - const report = await convert(`

Text

`, { context: wp70 }); - - expect(report.output).toContain('margin-left:40px'); - }); - it('says so when the manifest carries no registry to intersect with', async () => { const report = await convert(`
Text
`, { context: 'dev/test/fixtures/site.context.json', From 11dd2aba1543982b28c797d7ce3b528130e66c9e Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 08:04:33 +0200 Subject: [PATCH 37/38] ci: retain separate tooling checks for release candidates --- .github/workflows/ci.yml | 5 +++++ .github/workflows/release.yml | 2 ++ docs/development.md | 3 ++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 517f100..0ad495c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,8 @@ jobs: - run: npm ci --engine-strict - name: Verify documentation and skill CLI contracts run: npx --no-install vitest run dev/test/skill.test.ts dev/test/node-support.test.ts dev/test/cli.test.ts + - name: Verify benchmark and evidence tooling + run: npm run test:tools - run: npm run check:private - name: Check touched documentation whitespace and local links env: @@ -191,6 +193,9 @@ jobs: - run: npm ci --engine-strict - name: Verify run: npm run verify + - name: Verify benchmark and evidence tooling + if: matrix.node == '22.13.0' + run: npm run test:tools - name: Smoke the packed CLI in a clean engine-strict consumer run: npm run smoke:package - name: Reject unsupported Node before packed CLI dependencies load diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 974cd02..217d56a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,8 @@ jobs: # and changes pack --json shape — we support both, but stay on the proven line). - run: npm install -g npm@11 - run: npm ci --engine-strict + - name: Verify benchmark and evidence tooling + run: npm run test:tools - run: npx --no-install playwright install --with-deps chromium - name: Collect native WordPress Heading control env: diff --git a/docs/development.md b/docs/development.md index b9125ba..034f7eb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,8 @@ npm run typecheck `npm run verify` remains the required repository gate. It runs the deterministic repository product suite. Benchmark and repository-tooling tests run separately with `npm run test:tools`; use that command when changing benchmark engines, receipt -tooling or project-owned-block helpers. It is not a paid model benchmark run. +tooling or project-owned-block helpers. Full CI runs it once on Node 22, and the +skill and release routes also run it. It is not a paid model benchmark run. CI classifies both pull-request and main-push changes with [`scripts/ci-scope.mjs`](../scripts/ci-scope.mjs). Additions or modifications limited to `README.md`, `CHANGELOG.md`, `ERRORS.md`, `DECISIONS.md` and its demo allowlist use the docs route; eligible shipped-skill changes use the skill route. Runtime, package, proof, workflow, release and unclassified paths use the full route. Deletions, renames and unavailable diffs cannot select a reduced route. All `docs/` paths are currently unclassified, so they select full CI. From 60f539e3672c0155163c14be3a13babbb4c6a99d Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Tue, 13 Oct 2026 08:11:24 +0200 Subject: [PATCH 38/38] release: prepare 0.9.11 with native theme and site workflows --- CHANGELOG.md | 3 ++- README.md | 2 +- docs/architecture.md | 2 +- docs/reference.md | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- skills/block-runner/references/SITE.md | 5 ++--- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ad93c..e615879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.9.11 — 2026-10-13 - Add block-theme folder validation, in-place markup repair, WordPress 7.1 theme.json schema checks and local part/pattern/preset references. - Align native attribute order with WordPress server definitions; send unknown-block warnings to stderr and preserve native Accordion icon placement. @@ -10,6 +10,7 @@ - Update Wesper to 0.5.0 and require Node.js ^22.13.0 || >=24.0.0, dropping Node 20 support. - Preserve unmanaged page fields and native publication dates on apply, allow pending first-install pattern media, canonicalise site identity and reduce repeated compilation, snapshots and source scans. - Document current theme/site routes and guarded manual interrupted-apply recovery; remove the public proof failure-seeding option and fixed five-page fixture requirement. +- Remove redundant tests and run benchmark/evidence tooling separately, with required CI and release coverage. ## 0.9.10 — 2026-10-04 diff --git a/README.md b/README.md index 50af8df..eb2827e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Turn HTML into editable WordPress blocks, generate reusable block source, or com Block Runner is an open-source CLI and JavaScript library from Human Made. It converts supported HTML into native Gutenberg blocks and checks the generated markup with the Gutenberg packages used by the WordPress editor. It can also generate a static registered block: source files you build into a plugin, with native editable blocks inside. Whole-site intent produces a block theme and page content, with separate planning and confirmed apply to WordPress. -This README describes the current repository source. Theme-folder and whole-site workflows, Wesper 0.5.0 and the new Node floor are unreleased; npm 0.9.10 predates them. See [Unreleased changes](CHANGELOG.md#unreleased) before assuming an installed version has these capabilities. +Theme-folder and whole-site workflows, Wesper 0.5.0 and the Node 22.13/24 floor were introduced in 0.9.11. See the [release notes](CHANGELOG.md#0911--2026-10-13) and check your installed version before using these routes. **The package makes no AI model calls.** Built-in rules can convert HTML on their own. An external agent can interpret a design and give Block Runner a structured plan to assemble or compile. The optional skill provides instructions for that agent. diff --git a/docs/architecture.md b/docs/architecture.md index 94cfa6e..b967963 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -Block Runner produces Gutenberg page markup, reusable registered-block source and whole-site block-theme bundles. These are available through the CLI and library. The optional skill teaches an external coding agent how to use them; the package does not call a model. Theme/site routes are current-source features, unreleased after npm 0.9.10. +Block Runner produces Gutenberg page markup, reusable registered-block source and whole-site block-theme bundles. These are available through the CLI and library. The optional skill teaches an external coding agent how to use them; the package does not call a model. Theme/site routes were introduced in 0.9.11. ## Choose a workflow diff --git a/docs/reference.md b/docs/reference.md index 3660fa0..8789af4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -2,7 +2,7 @@ Start with the [README](../README.md) for a first conversion, or the [architecture guide](architecture.md) for the code paths. This reference covers commands, configuration and compatibility details. The [registered-block delivery guide](../skills/block-runner/references/AUTHORING.md#shipped-notice-source-to-standalone-zip) owns the complete source-to-ZIP procedure. -This reference describes current source. Theme-folder and whole-site commands, Wesper 0.5.0 and the Node 22.13/24 floor are unreleased; the published 0.9.10 package predates them. The [theme and site guide](../skills/block-runner/references/SITE.md) owns the whole-site input and lifecycle examples. +Theme-folder and whole-site commands, Wesper 0.5.0 and the Node 22.13/24 floor require 0.9.11 or later; 0.9.10 predates them. The [theme and site guide](../skills/block-runner/references/SITE.md) owns the whole-site input and lifecycle examples. ## Native block support boundaries @@ -280,7 +280,7 @@ Use the CLI in shell scripts, pre-commit hooks or CI. ```yaml - repo: https://github.com/humanmade/block-runner - rev: v0.9.10 + rev: v0.9.11 hooks: - id: block-runner args: ['content/**/*.html'] # glob of files that contain block markup diff --git a/package-lock.json b/package-lock.json index d7f15d4..d3fcf2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "block-runner", - "version": "0.9.10", + "version": "0.9.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "block-runner", - "version": "0.9.10", + "version": "0.9.11", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { diff --git a/package.json b/package.json index c61b13b..c37dec8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "block-runner", - "version": "0.9.10", + "version": "0.9.11", "description": "The layer between generated content and WordPress — convert what AI, agents, and design tools emit into clean, valid, native Gutenberg blocks, and validate every result. CLI + library.", "type": "module", "license": "GPL-2.0-or-later", diff --git a/skills/block-runner/references/SITE.md b/skills/block-runner/references/SITE.md index 4c0317d..72826e6 100644 --- a/skills/block-runner/references/SITE.md +++ b/skills/block-runner/references/SITE.md @@ -1,8 +1,7 @@ # Block themes and whole sites -These routes are in the current repository source and are unreleased. The published -0.9.10 package predates them. Use a build containing these commands and check -`block-runner site --help`; do not assume an older installed package supports them. +These routes require Block Runner 0.9.11 or later; 0.9.10 predates them. Check your +installed version and `block-runner site --help` before using these commands. ## Check an existing theme