prepare.mjs
131 lines · archify/explainer/prepare.mjs at a406594
1import fs from 'node:fs';
2import path from 'node:path';
3import { createHash } from 'node:crypto';
4import { parseExplainer } from './parse.mjs';
5import { validateExplainer } from './validate.mjs';
6import { resolveExplainerSources } from './sources.mjs';
7import { parseInline } from './inline.mjs';
8import { prepareExplainerDiagrams, validateExplainerDiagramTarget } from './diagrams.mjs';
9import { throwDiagnosticError } from '../renderers/shared/diagnostics.mjs';
11const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
12const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
13const identity = bytes => ({ sha256: createHash('sha256').update(bytes).digest('hex'), bytes: Buffer.byteLength(bytes) });
14const digest = bundle => identity(JSON.stringify({ ...bundle, digest: undefined })).sha256;
15function fail(code, message, input, reason, example = 'Retry from the unchanged original draft and local resources.') {
16 throwDiagnosticError(message, [{ code: `explainer/${code}`, severity: 'error', message,
17 subject: { input, line: 1, component: 'document' }, evidence: { reason, example }, supportedFixes: [example] }]);
18}
19export function parseValidatedExplainer(sourceText, options = {}) {
20 if (Buffer.byteLength(sourceText) > MAX_SOURCE_BYTES) fail('source-limit', 'Explainer draft exceeds the 16 MiB source budget.', options.inputPath, 'source-byte-limit', 'Reduce the draft below 16 MiB and use relative native diagram sources for large specifications.');
21 const parsed = parseExplainer(sourceText, { inputPath: options.inputPath, output: options.output });
22 if (!parsed.document || parsed.diagnostics.some(entry => entry.severity !== 'warning')) {
23 throwDiagnosticError('Explainer draft could not be parsed.', parsed.diagnostics);
24 }
25 const validated = validateExplainer(parsed.document, { sourceMap: parsed.sourceMap, inputPath: options.inputPath, writingMode: options.writingMode });
26 if (!validated.ok) throwDiagnosticError('Explainer draft is invalid.', validated.diagnostics);
27 return { ...parsed, diagnostics: [...parsed.diagnostics, ...validated.diagnostics] };
28}
29export async function prepareExplainer(sourceText, options = {}) {
30 const inputPath = path.resolve(options.inputPath);
31 const parsed = parseValidatedExplainer(sourceText, { ...options, inputPath });
32 const baseDir = options.baseDir || path.dirname(inputPath);
33 const quality = options.quality || parsed.document.meta?.quality_profile || 'standard';
34 const common = { repoRoot: options.repoRoot, baseDir, sourceMap: parsed.sourceMap, sourcePath: inputPath, quality };
35 const preparedSources = await resolveExplainerSources(parsed.document, common);
36 const preparedDiagrams = await prepareExplainerDiagrams(parsed.document, common);
37 validatePreparedTargets(parsed.document, preparedDiagrams.diagrams, common);
38 const dependencies = [...(preparedSources.dependencies || []), ...(preparedDiagrams.dependencies || [])].map(entry => ({ ...entry, id: entry.id || entry.blockId,
39 ...(entry.kind === 'repository' || entry.inline ? {} : { path: path.resolve(baseDir, entry.path) }) }));
40 const bundle = { schemaVersion: 1, kind: 'archify-explainer-prepared', ...parsed, sourcePath: inputPath, baseDir,
41 quality, writingMode: options.writingMode || 'warn', source: identity(Buffer.from(sourceText)),
42 preparedSources: { sources: preparedSources.sources, assets: preparedSources.assets, ...(Object.keys(preparedSources.charts || {}).length ? { charts: preparedSources.charts } : {}), ...(preparedSources.repository ? { repository: preparedSources.repository } : {}) },
43 preparedDiagrams: { diagrams: preparedDiagrams.diagrams }, dependencies };
44 bundle.digest = digest(bundle);
45 assertPreparedDependencies(bundle);
46 return bundle;
47}
48export function assertPreparedDependencies(bundle) {
49 for (const dependency of bundle.dependencies || []) {
50 // Repository excerpts were read from the pinned Git object, never the mutable checkout.
51 if (dependency.kind === 'repository' || dependency.inline || dependency.retained) {
52 if (typeof dependency.contentBase64 === 'string') {
53 const frozen = identity(Buffer.from(dependency.contentBase64, 'base64'));
54 if (frozen.sha256 !== dependency.sha256 || frozen.bytes !== dependency.bytes) fail('prepared-mismatch', 'Frozen resource bytes do not match their identity.', bundle.sourcePath, 'frozen-resource-byte-mismatch');
55 } else if (dependency.retained) fail('prepared-invalid', 'Retained resources require their original frozen bytes.', bundle.sourcePath, 'missing-frozen-resource');
56 continue;
57 }
58 let actual;
59 try { actual = identity(fs.readFileSync(dependency.path)); } catch (error) {
60 fail('dependency-changed', 'A frozen explainer resource is no longer readable.', bundle.sourcePath, error.code || 'read-failed');
61 }
62 if (actual.sha256 !== dependency.sha256 || actual.bytes !== dependency.bytes) {
63 fail('dependency-changed', 'An explainer resource changed during preparation.', bundle.sourcePath, 'frozen-resource-byte-mismatch');
64 }
65 }
66}
67export function writePreparedBundle(directory, bundle) {
68 const file = path.join(directory, 'explainer.prepared.json');
69 const bytes = Buffer.from(JSON.stringify(bundle));
70 if (bytes.length > MAX_BUNDLE_BYTES) fail('resource-limit', 'Prepared explainer exceeds the resource budget.', bundle.sourcePath, 'bundle-byte-limit');
71 fs.writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 });
72 return file;
73}
74export function readPreparedBundle(file, sourceText, inputPath, { checkDependencies = true, expectedInputPath = inputPath, quality, writingMode } = {}) {
75 let bundle;
76 try {
77 const stat = fs.statSync(file);
78 if (!stat.isFile() || stat.size > MAX_BUNDLE_BYTES) throw new Error('bundle-byte-limit');
79 bundle = JSON.parse(fs.readFileSync(file, 'utf8'));
80 } catch (error) { fail('prepared-invalid', 'Prepared explainer could not be read.', inputPath, error.code || 'invalid-bundle'); }
81 const canonical = value => { try { return fs.realpathSync.native(path.resolve(value)); } catch { return path.resolve(value); } };
82 if (typeof (bundle.inputPath || bundle.sourcePath) !== 'string' || canonical(bundle.inputPath || bundle.sourcePath) !== canonical(expectedInputPath)) {
83 fail('prepared-mismatch', 'Prepared explainer belongs to a different author input path.', inputPath, 'draft-path-mismatch');
84 }
85 const source = identity(Buffer.from(sourceText));
86 if (bundle?.kind !== 'archify-explainer-prepared' || bundle.schemaVersion !== 1 || bundle.digest !== digest(bundle)
87 || source.sha256 !== bundle.source?.sha256 || source.bytes !== bundle.source?.bytes || bundle.sourceText !== sourceText) {
88 fail('prepared-mismatch', 'Prepared explainer does not match the original draft bytes.', inputPath, 'draft-or-bundle-digest-mismatch');
89 }
90 if (quality && quality !== bundle.quality) fail('prepared-mismatch', 'Prepared explainer uses a different quality profile.', inputPath, 'quality-profile-mismatch', 'Prepare the original draft again under the requested quality profile.');
91 if (writingMode && writingMode !== (bundle.writingMode || 'warn')) fail('prepared-mismatch', 'Prepared explainer uses a different writing check mode.', inputPath, 'writing-mode-mismatch', 'Prepare the original draft again under the requested writing check mode.');
92 const parsed = parseValidatedExplainer(sourceText, { inputPath: bundle.sourcePath, writingMode: bundle.writingMode || 'warn' });
93 if (JSON.stringify(parsed.document) !== JSON.stringify(bundle.document)) fail('prepared-mismatch', 'Prepared document differs from the authored draft.', inputPath, 'normalized-document-mismatch');
94 if (checkDependencies) assertPreparedDependencies(bundle);
95 return bundle;
96}
97export function rebindPreparedBundle(bundle, parsed) {
98 const next = { ...bundle, ...parsed, source: identity(Buffer.from(parsed.sourceText)) };
99 next.digest = digest(next);
100 return next;
101}
103export function validatePreparedTargets(document, diagrams, options = {}) {
104 const visit = (value, pointer, panelId, component) => {
105 if (typeof value === 'string') {
106 const location = options.sourceMap?.[pointer] || {};
107 const inline = parseInline(value, { line: location.line || 1, input: options.sourcePath, panelId, component });
108 for (const reference of inline.references) {
109 if (!['node','route'].includes(reference.kind)) continue;
110 const target = reference.kind === 'node' ? {diagram:reference.diagram,node:reference.node} : {diagram:reference.diagram,route:[reference.from,reference.to]};
111 const line = (location.line || 1) + (value.slice(0,reference.offset).match(/\n/g)?.length || 0);
112 validateExplainerDiagramTarget(diagrams,target,{...options,pointer,line});
113 }
114 } else if (Array.isArray(value)) value.forEach((entry,index)=>visit(entry,`${pointer}/${index}`,panelId,component));
115 else if (value && typeof value === 'object') for(const [key,entry] of Object.entries(value)) {
116 if (['spec','source','path','language','target','changes'].includes(key) || (['diff','snippet'].includes(component) && key==='text')) continue;
117 visit(entry,`${pointer}/${key}`,panelId,component);
118 }
119 };
120 visit(document.answer,'/answer',undefined,'answer');
121 document.panels.forEach((panel,index)=>{
122 visit(panel.title,`/panels/${index}/title`,panel.id,'panel');
123 panel.blocks.forEach((block,blockIndex)=>{
124 const pointer=`/panels/${index}/blocks/${blockIndex}`;
125 visit(block,pointer,panel.id,block.type);
126 if(block.type==='steps') block.items.forEach((item,itemIndex)=>{
127 if(item.target)validateExplainerDiagramTarget(diagrams,item.target,{...options,pointer:`${pointer}/items/${itemIndex}/target`,line:options.sourceMap?.[`${pointer}/items/${itemIndex}`]?.line || options.sourceMap?.[pointer]?.line || 1});
128 });
129 });
130 });
131}