Skill Tracer
Skills › Archify › archify › patch.mjs

patch.mjs

117 lines · archify/explainer/patch.mjs at a406594

1import fs from 'node:fs';
2import path from 'node:path';
3import { createHash } from 'node:crypto';
4import { parseValidatedExplainer, rebindPreparedBundle, validatePreparedTargets } from './prepare.mjs';
5import { resolveExplainerSources } from './sources.mjs';
6import { prepareExplainerDiagrams } from './diagrams.mjs';
7import { computeExplainerRevision } from './revision.mjs';
8import { unpackPageBundle } from './page-bundle.mjs';
9import { throwDiagnosticError } from '../renderers/shared/diagnostics.mjs';
11function fail(reason, message, input, panelId, example) {
12 throwDiagnosticError(message, [{code:`explainer/patch-${reason}`,severity:'error',message,
13 subject:{input,panelId,line:1,component:'panel'},evidence:{reason,example},supportedFixes:[example]}]);
15function embedded(html, id, input) {
16 const matches = [...html.matchAll(new RegExp(`<script\\b[^>]*\\bid=["']${id}["'][^>]*>([\\s\\S]*?)<\\/script>`, 'gi'))];
17 if(matches.length !== 1) fail('payload','The artifact must contain one original draft and prepared resource payload.',input,undefined,'Use an artifact produced by the formal explainer CLI.');
18 try { return JSON.parse(matches[0][1]); } catch { fail('payload','Embedded draft payload is invalid.',input,undefined,'Rebuild the artifact from its original draft.'); }
20/** Replace an authored stable panel while retaining immutable resources outside that panel. */
21export async function preparePanelPatch({artifactPath,panelId,replacementPath,repoRoot,quality,writingMode,expectedArtifact}) {
22 const artifactBytes=fs.readFileSync(artifactPath);
23 if(expectedArtifact && (artifactBytes.length!==expectedArtifact.bytes || createHash('sha256').update(artifactBytes).digest('hex')!==expectedArtifact.sha256)) fail('artifact-changed','The original artifact changed after validation.',artifactPath,panelId,'Finish other writes to the artifact and retry the panel patch.');
24 const html = artifactBytes.toString('utf8');
25 let previous;
26 try { previous = unpackPageBundle(embedded(html,'archify-explainer-prepared',artifactPath)); } catch (error) { if (error.diagnostics) throw error; fail('payload','Embedded native diagram payload cannot be inflated.',artifactPath,panelId,'Rebuild the original artifact before patching.'); }
27 // The page names local files relative to itself; resolve them before reading or re-preparing.
28 const near = value => typeof value === 'string' ? path.resolve(path.dirname(artifactPath), value) : value;
29 const draft = {...previous,sourcePath:near(previous.sourcePath)};
30 if(typeof draft.sourceText !== 'string' || draft.sourceText !== previous.sourceText
31 || createHash('sha256').update(draft.sourceText).digest('hex') !== previous.source?.sha256
32 || createHash('sha256').update(JSON.stringify({...previous,digest:undefined})).digest('hex') !== previous.digest) {
33 fail('payload','The embedded original draft is not bound to the prepared resources.',artifactPath,panelId,'Rebuild the original artifact before patching.');
34 }
35 const oldParsed = parseValidatedExplainer(draft.sourceText,{inputPath:draft.sourcePath || artifactPath,writingMode:'off'});
36 if(JSON.stringify(oldParsed.document)!==JSON.stringify(previous.document)) fail('payload','The original draft and normalized document disagree.',artifactPath,panelId,'Rebuild the original artifact before patching.');
37 const index = oldParsed.document.panels.findIndex(panel=>panel.id===panelId);
38 if(index<0) fail('target','The requested panel does not exist.',artifactPath,panelId,'Choose a stable panel id from the original artifact.');
39 const range=oldParsed.sourceMap[`/panels/${index}`];
40 if(!Number.isInteger(range?.start)||!Number.isInteger(range?.end)) fail('range','The original draft has no replaceable panel range.',artifactPath,panelId,'Rebuild the artifact using the current parser.');
41 const replacementBytes = fs.readFileSync(replacementPath);
42 const replacement = replacementBytes.toString('utf8');
43 if(!Buffer.from(replacement).equals(replacementBytes)) fail('source-encoding','Panel replacement must contain valid UTF-8 bytes.',replacementPath,panelId,'Save the replacement panel as UTF-8 text.');
44 const sourceText = draft.sourceText.slice(0,range.start)+replacement+draft.sourceText.slice(range.end);
45 const parsed=parseValidatedExplainer(sourceText,{inputPath:draft.sourcePath || artifactPath,writingMode:writingMode || previous.writingMode || 'warn'});
46 if(parsed.document.panels.length!==oldParsed.document.panels.length || parsed.document.panels[index]?.id!==panelId) {
47 fail('identity','A panel replacement must retain the target stable id and panel count.',replacementPath,panelId,oldParsed.format==='json'?'{"id":"'+panelId+'","title":"Question?","blocks":[...]}':'## Question? {#'+panelId+'}\n\nReplacement content.');
48 }
49 const expected=structuredClone(oldParsed.document);
50 expected.panels[index]=parsed.document.panels[index];
51 if(JSON.stringify(expected)!==JSON.stringify(parsed.document)) fail('scope','The replacement changes content outside the selected panel.',replacementPath,panelId,'Replace only the target panel; preserve document metadata, sources and other panels.');
52 const oldBlocks=new Map(oldParsed.document.panels[index].blocks.map(block=>[block.id,block]));
53 const changed=parsed.document.panels[index].blocks.filter(block=>JSON.stringify(oldBlocks.get(block.id))!==JSON.stringify(block));
54 // Already verified immutable code excerpts remain usable after the original checkout disappears.
55 const changedCodeIds=new Set(changed.filter(block=>block.type==='code' && typeof previous.preparedSources?.sources?.[block.source]?.text !== 'string').map(block=>block.source));
56 const selectedQuality=quality || previous.quality || 'standard';
57 const qualityChanged=selectedQuality !== previous.quality;
58 const oldDiagramBlocks=new Map(oldParsed.document.panels.flatMap(panel=>panel.blocks).filter(block=>block.type==='diagram').map(block=>[block.id,block]));
59 const reusedDiagramSources=new Set();
60 const frozenDiagramInput=block=>{
61 if(block.type==='diagram' && block.source && oldDiagramBlocks.get(block.id)?.source===block.source && previous.preparedDiagrams?.diagrams?.[block.id]?.spec) {
62 reusedDiagramSources.add(block.id);
63 return {...block,source:undefined,spec:previous.preparedDiagrams.diagrams[block.id].spec};
64 }
65 return block;
66 };
67 const partial={...parsed.document,panels:[{...parsed.document.panels[index],blocks:changed.filter(block=>
68 (block.type!=='code'||changedCodeIds.has(block.source))
69 && !(block.type==='image' && oldBlocks.get(block.id)?.path===block.path && previous.preparedSources?.assets?.[block.id])
70 && !(block.type==='chart' && block.data && JSON.stringify(oldBlocks.get(block.id)?.data)===JSON.stringify(block.data) && previous.preparedSources?.charts?.[block.id]))
71 .map(frozenDiagramInput)}],sources:parsed.document.sources.filter(source=>changedCodeIds.has(source.id))};
72 const common={repoRoot,baseDir:previous.baseDir===null ? null : near(previous.baseDir) || path.dirname(draft.sourcePath || artifactPath),sourceMap:parsed.sourceMap,sourcePath:draft.sourcePath || artifactPath,quality:selectedQuality};
73 const partialMap={...parsed.sourceMap};
74 for(const [partialIndex,block] of partial.panels[0].blocks.entries()) {
75 const originalIndex=parsed.document.panels[index].blocks.findIndex(entry=>entry.id===block.id);
76 const originalPrefix=`/panels/${index}/blocks/${originalIndex}`;
77 for(const [pointer,location] of Object.entries(parsed.sourceMap)) if(pointer===originalPrefix || pointer.startsWith(`${originalPrefix}/`)) partialMap[`/panels/0/blocks/${partialIndex}${pointer.slice(originalPrefix.length)}`]=location;
78 }
79 for(const [partialIndex,source] of partial.sources.entries()) {
80 const originalIndex=parsed.document.sources.findIndex(entry=>entry.id===source.id);
81 const originalPrefix=`/sources/${originalIndex}`;
82 for(const [pointer,location] of Object.entries(parsed.sourceMap)) if(pointer===originalPrefix || pointer.startsWith(`${originalPrefix}/`)) partialMap[`/sources/${partialIndex}${pointer.slice(originalPrefix.length)}`]=location;
83 }
84 const newSources=await resolveExplainerSources(partial,{...common,sourceMap:partialMap});
85 // A profile change needs fresh native validation for every child, including
86 // untouched panels. Frozen original specs keep this independent of old files.
87 const diagramDocument=qualityChanged ? {...parsed.document,panels:parsed.document.panels.map(panel=>({...panel,blocks:panel.blocks.map(frozenDiagramInput)}))} : partial;
88 const newDiagrams=await prepareExplainerDiagrams(diagramDocument,{...common,sourceMap:qualityChanged ? parsed.sourceMap : partialMap});
89 newDiagrams.dependencies=newDiagrams.dependencies.map(entry=>{
90 if(!reusedDiagramSources.has(entry.id)) return entry;
91 const original=(previous.dependencies || []).find(dep=>dep.kind==='diagram-spec' && dep.id===entry.id);
92 if(!original) fail('payload','The original diagram has no frozen specification evidence.',artifactPath,panelId,'Rebuild the original artifact before patching.');
93 newDiagrams.diagrams[entry.id].specification=previous.preparedDiagrams.diagrams[entry.id].specification;
94 return {...original,path:near(original.path),retained:true};
95 });
96 const preparedSources={...previous.preparedSources,sources:{...previous.preparedSources.sources,...newSources.sources},assets:{...previous.preparedSources.assets,...newSources.assets},charts:{...previous.preparedSources.charts,...newSources.charts}};
97 const preparedDiagrams={...previous.preparedDiagrams,diagrams:{...previous.preparedDiagrams.diagrams,...newDiagrams.diagrams}};
98 const activeBlocks=parsed.document.panels.flatMap(panel=>panel.blocks);
99 const imageIds=new Set(activeBlocks.filter(block=>block.type==='image').map(block=>block.id));
100 const diagramIds=new Set(activeBlocks.filter(block=>block.type==='diagram').map(block=>block.id));
101 const dataChartIds=new Set(activeBlocks.filter(block=>block.type==='chart'&&block.data).map(block=>block.id));
102 preparedSources.assets=Object.fromEntries(Object.entries(preparedSources.assets).filter(([id])=>imageIds.has(id)));
103 preparedSources.charts=Object.fromEntries(Object.entries(preparedSources.charts).filter(([id])=>dataChartIds.has(id)));
104 if(!Object.keys(preparedSources.charts).length)delete preparedSources.charts;
105 preparedDiagrams.diagrams=Object.fromEntries(Object.entries(preparedDiagrams.diagrams).filter(([id])=>diagramIds.has(id)));
106 validatePreparedTargets(parsed.document,preparedDiagrams.diagrams,common);
107 const dependencyKey=entry=>`${entry.kind}:${entry.id}`;
108 const newDependencies=[...(newSources.dependencies||[]),...(newDiagrams.dependencies||[])];
109 const preparedIds=new Set(newDependencies.map(dependencyKey));
110 const localDependency=(entry,baseDir)=>({...entry,...(entry.kind==='repository'||entry.inline||!entry.path?{}:{path:path.resolve(baseDir,entry.path)})});
111 const dependencies=[...(previous.dependencies||[]).filter(entry=>!preparedIds.has(dependencyKey(entry)) && (entry.kind!=='image'||imageIds.has(entry.id)) && (entry.kind!=='data'||dataChartIds.has(entry.id)) && (entry.kind!=='diagram-spec'||diagramIds.has(entry.id))).map(entry=>localDependency(entry,path.dirname(artifactPath))),
112 ...newDependencies.map(entry=>localDependency(entry,common.baseDir))];
113 // Old dependencies are embedded immutable bytes. Only newly prepared resources are reread/verified.
114 const bundle=rebindPreparedBundle({...previous,sourcePath:common.sourcePath,baseDir:common.baseDir,preparedSources,preparedDiagrams,dependencies,quality:selectedQuality,writingMode:writingMode || previous.writingMode || 'warn'},parsed);
115 bundle.dependencies = dependencies.map(entry => ({...entry, retained: entry.retained===true || !preparedIds.has(dependencyKey(entry))}));
116 return {bundle:rebindPreparedBundle(bundle,parsed),sourceText,sourcePath:draft.sourcePath || artifactPath,previousRevision:computeExplainerRevision(previous),previousSourceSha256:previous.source.sha256,panelId};