Skill Tracer
Skills › Archify › archify › check.mjs

check.mjs

167 lines · archify/explainer/check.mjs at a406594

1import { createHash } from 'node:crypto';
2import fs from 'node:fs';
3import os from 'node:os';
4import path from 'node:path';
5import { spawnSync } from 'node:child_process';
6import { fileURLToPath } from 'node:url';
7import { parseExplainer } from './parse.mjs';
8import { computeExplainerRevision } from './revision.mjs';
9import { unpackPageBundle } from './page-bundle.mjs';
11export const isExplainerHtml = html => /<html\b[^>]*\bdata-archify-artifact\s*=\s*["']explainer["']/i.test(html) || (html.includes('archify-explainer-prepared') && scanHtml(html).scripts.some(s=>s.attrs.id==='archify-explainer-prepared'));
12const hash = value => createHash('sha256').update(value).digest('hex');
13const decode = value => value.replace(/&(?:amp|lt|gt|quot|apos|#39|#x[0-9a-f]+|#\d+);/gi, token => {
14 const names = {'&amp;':'&','&lt;':'<','&gt;':'>','&quot;':'"','&apos;':"'",'&#39;':"'"};
15 if (names[token.toLowerCase()]) return names[token.toLowerCase()];
16 const n = token[2].toLowerCase() === 'x' ? parseInt(token.slice(3,-1),16) : Number(token.slice(2,-1));
17 return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : '\uFFFD';
18});
20// Bounded HTML token scanner: quoted attributes, comments, and raw-text elements
21// are consumed together, so a JSON string containing markup is never a DOM ID.
22export function scanHtml(html) {
23 const elements = [];
24 const scripts = [];
25 const token = /<!--[\s\S]*?(?:-->|$)|<![^>]*>|<(\/?)([a-z][\w:-]*)(?=[\s/>])(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
26 let match, line = 1, lineOffset = 0;
27 while ((match = token.exec(html))) {
28 if (!match[2] || match[1]) continue;
29 const tag = match[2].toLowerCase();
30 const attrs = {};
31 const attr = /([^\s=<>"'`/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
32 const raw = match[0].slice(match[0].indexOf(match[2]) + match[2].length, -1);
33 let a;
34 while ((a = attr.exec(raw))) attrs[a[1].toLowerCase()] = decode(a[2] ?? a[3] ?? a[4] ?? '');
35 // Advance once through the input, including skipped raw text, instead of
36 // recounting every preceding line for each element of a large native SVG.
37 for (let newline = html.indexOf('\n', lineOffset); newline >= 0 && newline < match.index; newline = html.indexOf('\n', newline + 1)) line++;
38 lineOffset = match.index;
39 const element = {tag,attrs,line,index:match.index};
40 elements.push(element);
41 if (['script','style','textarea','title'].includes(tag)) {
42 const end = new RegExp(`</${tag}\\s*>`,'gi');
43 end.lastIndex = token.lastIndex;
44 const closing = end.exec(html);
45 const text = html.slice(token.lastIndex, closing?.index ?? html.length);
46 if (tag === 'script') scripts.push({...element,text});
47 token.lastIndex = closing ? end.lastIndex : html.length;
48 }
49 }
50 return {elements,scripts};
53export function checkExplainerHtml(html, { file = '<html>', checkNative = true } = {}) {
54 const bytes = Buffer.from(html);
55 const {elements,scripts} = scanHtml(html);
56 const diagnostics = [];
57 const checks = [];
58 const issue = (code,reason,example,element,component='document') => diagnostics.push({
59 code:`explainer/${code}`,severity:'error',message:reason,
60 subject:{input:file,line:element?.line || 1,component,...(element?.attrs?.id ? {id:element.attrs.id} : {})},
61 evidence:{reason,example},supportedFixes:[example],
62 });
63 const add = (name, before, details=[]) => checks.push({name,ok:diagnostics.length===before,details});
64 let before = diagnostics.length;
65 if (elements.find(e=>e.tag==='html')?.attrs['data-archify-artifact']!=='explainer') issue('artifact-family','The document is missing its explainer family marker.','<html data-archify-artifact="explainer">');
66 const ids = new Map();
67 for (const e of elements) {
68 if (e.attrs.id) {
69 if (ids.has(e.attrs.id)) issue('duplicate-id',`Duplicate DOM ID: ${e.attrs.id}.`,'Give each panel, block, source and SVG preview a unique stable ID.',e);
70 ids.set(e.attrs.id,e);
71 }
72 if (Object.keys(e.attrs).some(key=>/^on/.test(key))) issue('inline-handler','Inline event attributes are not permitted.','Use the packaged reader event handlers.',e);
73 if (e.tag === 'script' && e.attrs.src) issue('external-runtime','The explainer runtime must be self-contained.','Embed the packaged reader script.',e);
74 if (['iframe','object','embed'].includes(e.tag)) issue('external-frame','Frames must be created from immutable native payloads on demand.','Keep a static native SVG preview and the frozen child HTML payload.',e);
75 for (const attr of ['href','src','xlink:href']) {
76 const value=e.attrs[attr];
77 if (!value) continue;
78 if (/^(?:javascript|vbscript):/i.test(value)) issue('unsafe-url','An executable URL is not allowed.','Use an https URL or a stable #anchor.',e);
79 if (value.startsWith('#') && !ids.has(decodeURIComponentSafe(value.slice(1)))) { /* resolved after all IDs */ }
80 }
81 if (e.tag==='img' && !/^data:image\/(?:png|jpeg|webp|gif);base64,/i.test(e.attrs.src || '')) issue('external-image','Images must contain their frozen local bytes.','Use a prepared local image with a data:image URL.',e,'image');
82 }
83 for (const e of elements) {
84 if (e.attrs.href?.startsWith('#') && e.attrs.href.length>1 && !ids.has(decodeURIComponentSafe(e.attrs.href.slice(1)))) issue('unresolved-reference',`Unresolved anchor: ${e.attrs.href}.`,'Link to an existing panel, block or source ID.',e,'reference');
85 }
86 add('explainer_structure',before,[`${ids.size} unique DOM IDs`]);
87 function payload(id) {
88 const found=scripts.filter(s=>s.attrs.id===id);
89 if (found.length!==1 || found[0].attrs.type!=='application/json') { issue('payload',`Expected one application/json payload: ${id}.`,`<script type="application/json" id="${id}">…</script>`,found[0]); return null; }
90 if (/[<>&\u2028\u2029]/.test(found[0].text)) issue('payload-escaping',`Unsafe raw characters in ${id}.`,'Escape <, >, &, U+2028 and U+2029 as JSON Unicode escapes.',found[0]);
91 try { return JSON.parse(found[0].text); } catch { issue('payload-json',`Invalid JSON payload: ${id}.`,'Re-render the validated source instead of editing embedded payloads.',found[0]); return null; }
92 }
93 before=diagnostics.length;
94 let bundle=payload('archify-explainer-prepared');
95 try { bundle=unpackPageBundle(bundle); } catch { issue('native-hash','A compressed native diagram payload cannot be inflated.','Re-render the native diagram and freeze its original HTML bytes.'); bundle=null; }
96 const document=bundle?.document;
97 const source=bundle ? {text:bundle.sourceText} : null;
98 const manifest=payload('explainer-manifest');
99 const children=bundle?.preparedDiagrams?.diagrams;
100 if (bundle && (bundle.kind!=='archify-explainer-prepared' || bundle.schemaVersion!==1 || hash(JSON.stringify({...bundle,digest:undefined}))!==bundle.digest)) issue('bundle-hash','The canonical prepared bundle does not match its digest.','Re-prepare and render the original draft with immutable resources.');
101 if (bundle && typeof bundle.sourceText==='string') {
102 const parsed=parseExplainer(bundle.sourceText,{inputPath:bundle.sourcePath,output:document?.meta?.output});
103 if (!parsed.document || parsed.diagnostics.some(d=>d.severity!=='warning') || JSON.stringify(parsed.document)!==JSON.stringify(document)) issue('source-document-binding','The canonical document differs from its embedded authored draft.','Re-parse the embedded draft and render its normalized document.');
104 if (bundle.source?.sha256!==hash(bundle.sourceText) || bundle.source?.bytes!==Buffer.byteLength(bundle.sourceText)) issue('bundle-source-hash','The prepared source bytes do not match their identity.','Freeze the exact original UTF-8 draft bytes.');
105 }
106 if (document && manifest && source) {
107 if (document.diagram_type!=='explainer' || document.schema_version!==1 || !Array.isArray(document.panels) || !Array.isArray(document.sources)) issue('canonical-document','The canonical explainer envelope is invalid.','Use schema_version:1, diagram_type:"explainer", panels:[] and sources:[].');
108 if (typeof source.text!=='string' || hash(source.text || '')!==manifest.sourceSha256) issue('source-hash','The embedded source does not match its immutable source hash.','Re-render the original draft and retain its exact SHA-256 source hash.');
109 try {
110 if (manifest.revision!==computeExplainerRevision(bundle)) issue('content-revision','The content revision does not bind the draft and frozen resources.','Recompute the revision from the canonical draft, source locations, child diagrams and contained assets.');
111 } catch {issue('content-revision','The content revision cannot be computed from invalid frozen resources.','Re-prepare the original draft and every declared resource.');}
112 if (hash(JSON.stringify(document))!==manifest.documentSha256) issue('document-hash','The canonical document does not match its manifest hash.','Re-render after changing the canonical document.');
113 if (manifest.artifactId!==document.meta?.id || elements.find(e=>e.tag==='html')?.attrs['data-revision']!==manifest.revision) issue('artifact-identity','Artifact ID or revision disagrees with the manifest.','Use the renderer-generated identity and revision.');
114 for (const panel of document.panels || []) {
115 for (const item of [panel,...(panel.blocks || [])]) if (!ids.has(item.id)) issue('canonical-id',`Canonical ID is absent from the rendered document: ${item.id}.`,'Render every authored panel and block with its stable ID.');
116 }
117 for (const src of document.sources || []) if (!ids.has(`source-${src.id}`)) issue('source-target',`Source target is missing: ${src.id}.`,'Render every declared source in the source list.');
118 }
119 add('explainer_payload_identity',before);
120 before=diagnostics.length;
121 const dependencies=bundle?.dependencies;
122 if (manifest?.dependencies && JSON.stringify(manifest.dependencies)!==JSON.stringify((dependencies || []).map(({contentBase64,...entry})=>entry))) issue('manifest-dependencies','The compact manifest disagrees with the canonical dependency identities.','Generate dependency metadata from the canonical prepared bundle.');
123 if (!Array.isArray(dependencies)) issue('dependency-manifest','The frozen dependency manifest is missing.','Store dependencies:[] even when no external resources are used.');
124 const depIds=new Set();
125 for (const dep of dependencies || []) {
126 const key=`${dep.kind}:${dep.id}`;
127 if (depIds.has(key)) issue('dependency-duplicate',`Duplicate dependency: ${key}.`,'Freeze each resource once.');
128 depIds.add(key);
129 const frozen=typeof dep.contentBase64==='string' ? Buffer.from(dep.contentBase64,'base64') : null;
130 if (!frozen || frozen.toString('base64')!==dep.contentBase64 || !Number.isSafeInteger(dep.bytes) || dep.bytes!==frozen.length || hash(frozen)!==dep.sha256) issue('dependency-hash',`Frozen dependency identity is corrupt: ${key}.`,'Regenerate the dependency bytes, byte count and SHA-256 from the source.');
131 }
132 for (const [id,asset] of Object.entries(manifest?.assets || {})) {
133 const dep=(dependencies || []).find(d=>d.kind==='image' && d.id===id);
134 if (!dep || dep.sha256!==asset.sha256 || dep.bytes!==asset.bytes) issue('asset-binding',`Image asset is not bound to its frozen dependency: ${id}.`,'Freeze the image before rendering.');
135 const img=elements.find(e=>e.tag==='img' && e.attrs.src===`data:${asset.mime};base64,${dep?.contentBase64}`);
136 if (!img) issue('asset-content',`Rendered image bytes differ from the frozen asset: ${id}.`,'Render the frozen data URL without replacing its bytes.');
137 }
138 for (const [id,src] of Object.entries(bundle?.preparedSources?.sources || {})) if (typeof src.text==='string') {
139 const dep=(dependencies || []).find(d=>d.kind==='repository' && d.id===id);
140 if (!dep || dep.sha256!==src.sha256 || dep.bytes!==src.bytes || !/^[0-9a-f]{40}$/.test(src.revision || '') || dep.revision!==src.revision) issue('source-binding',`Code excerpt is missing pinned frozen bytes: ${id}.`,'Resolve the repository source against a full Git commit before rendering.');
141 }
142 add('explainer_dependencies',before,[`${dependencies?.length || 0} frozen dependencies`]);
143 before=diagnostics.length;
144 const childReceipts=[];
145 const expected=(document?.panels || []).flatMap(p=>p.blocks || []).filter(b=>b.type==='diagram').map(b=>b.id);
146 if (!children || typeof children!=='object' || Array.isArray(children) || expected.length!==Object.keys(children).length || expected.some(id=>!children[id])) issue('native-count','Native diagram payloads do not match canonical diagram blocks.','Embed one native payload per diagram block; zero diagrams is valid.');
147 for (const [id,child] of Object.entries(children || {})) {
148 const binding=manifest?.children?.[id];
149 if (typeof child.html!=='string' || hash(child.html || '')!==child.sha256 || Buffer.byteLength(child.html || '')!==child.bytes || binding?.sha256!==child.sha256 || binding?.bytes!==child.bytes) { issue('native-hash',`Native diagram payload is corrupt: ${id}.`,'Re-render the native diagram and freeze its original HTML bytes.',ids.get(id),'diagram'); continue; }
150 if (isExplainerHtml(child.html)) {issue('native-family',`Nested explainers are not native diagrams: ${id}.`,'Use an existing Archify native diagram type.',ids.get(id),'diagram');continue;}
151 if (checkNative) {
152 const directory=fs.mkdtempSync(path.join(os.tmpdir(),'archify-explainer-check-'));
153 try {
154 const artifactPath=path.join(directory,'child.html'); fs.writeFileSync(artifactPath,child.html);
155 const run=spawnSync(process.execPath,[fileURLToPath(new URL('../scripts/check-render-output.mjs',import.meta.url)),artifactPath],{encoding:'utf8',maxBuffer:8*1024*1024,timeout:60000});
156 let receipt; try {receipt=JSON.parse(run.stdout);} catch {receipt=null;}
157 childReceipts.push({id,receipt});
158 if (run.status!==0 || !receipt?.ok || receipt.artifact?.sha256!==child.sha256 || receipt.composition?.profile!==(bundle?.quality || document?.meta?.quality_profile || 'standard')) issue('native-check',`Native diagram failed its existing composition checks: ${id}.`,'Fix the nested diagram using the native checker diagnostics.',ids.get(id),'diagram');
159 } finally {fs.rmSync(directory,{recursive:true,force:true});}
160 }
161 }
162 add('explainer_native_diagrams',before,childReceipts.map(({id,receipt})=>`${id}: ${receipt?.ok ? 'pass':'fail'}`));
163 const profile=bundle?.quality || document?.meta?.quality_profile || 'standard';
164 const errors=diagnostics.length;
165 return {ok:errors===0,file,family:'explainer',artifact:{sha256:hash(bytes),bytes:bytes.length},checks,composition:{schemaVersion:1,profile,status:errors ? 'fail':'pass',summary:{errors,warnings:0},issues:diagnostics,nativeDiagrams:childReceipts},diagnostics};
167function decodeURIComponentSafe(value) { try {return decodeURIComponent(value);} catch {return value;} }