Skill Tracer
Skills › Archify › archify › validate.mjs

validate.mjs

138 lines · archify/explainer/validate.mjs at a406594

1import { explainer as validateGenerated } from '../renderers/shared/generated-validators.mjs';
2import { validateSchema } from '../renderers/shared/validator.mjs';
3import { parseInline } from './inline.mjs';
4import { lintExplainerWriting } from './writing.mjs';
5import { parseExplainerDiff } from './diff.mjs';
6import { compileExplainerChart } from './charts.mjs';
7
8const ID = /^[A-Za-z][A-Za-z0-9_-]*$/;
9const READER_IDS = new Set(['board','diagram-dialog','diagram-dialog-title','toast']);
10const NODE_KEYS = {architecture:'components',sequence:'participants',lifecycle:'states',erd:'entities',class:'types',timeline:'events',waterfall:'spans',workflow:'nodes',dataflow:'nodes',tree:'nodes'};
11const EDGE_KEYS = {architecture:'connections',sequence:'messages',lifecycle:'transitions',erd:'relationships',class:'relationships',workflow:'edges',dataflow:'edges'};
12const at = (document,pointer) => pointer.split('/').slice(1).reduce((value,key)=>value?.[key.replaceAll('~1','/').replaceAll('~0','~')],document);
14/** Structural and semantic author validation; resource preparation owns byte and Git verification.
15 * Deferred references into external native files are checked after prepareExplainerDiagrams.
16 */
17export function validateExplainer(document, {sourceMap = {}, inputPath, writingMode = 'warn'} = {}) {
18 const diagnostics = [], entries = [];
19 function location(pointer,component,panelId) {
20 let key=pointer, value;
21 while(value===undefined) { value=sourceMap instanceof Map?sourceMap.get(key):sourceMap[key]; if(!key)break;key=key.slice(0,key.lastIndexOf('/')); }
22 return {line:value?.line || 1,column:value?.column || 1,component,path:pointer,...(panelId?{panelId}:{}),...(inputPath?{input:inputPath}:{})};
23 }
24 function fail(code,pointer,component,reason,example,panelId) {
25 diagnostics.push({code:`explainer/${code}`,severity:'error',message:reason,subject:location(pointer,component,panelId),
26 evidence:{reason,example},supportedFixes:[`Use a valid form, for example: ${example}`]});
27 }
28 if(!validateGenerated(document)) {
29 const seen=new Set();
30 for(const error of validateGenerated.errors||[]) {
31 if(error.keyword==='if')continue;
32 let pointer=error.instancePath||'';
33 if(error.keyword==='required')pointer+=`/${error.params.missingProperty}`;
34 if(error.keyword==='additionalProperties')pointer+=`/${error.params.additionalProperty}`;
35 const blockPath=pointer.match(/^\/panels\/\d+\/blocks\/\d+/u)?.[0];
36 const component=blockPath?at(document,blockPath)?.type||'block':pointer.startsWith('/sources/')?'source':pointer.startsWith('/panels/')?'panel':'document';
37 const reason=`${pointer||'/'} ${error.message}`;
38 const key=`${error.keyword}\0${pointer}\0${JSON.stringify(error.params)}`;if(seen.has(key))continue;seen.add(key);
39 let example;
40 if(error.keyword==='required')example=`Add ${error.params.missingProperty} to the ${component}.`;
41 else if(error.keyword==='additionalProperties')example=`Remove unsupported ${error.params.additionalProperty}.`;
42 else if(pointer==='/meta/output')example='"output":"answer.html"';
43 else if(error.keyword==='type')example=error.params.type==='number'?'value=12.5':error.params.type==='boolean'?'multiple=true':error.params.type==='array'?'"items":["First item","Second item"]':error.params.type==='object'?'"meta":{"id":"answer-page","title":"Answer"}':'"text":"A direct explanation."';
44 else if(error.keyword==='const')example=`Use ${JSON.stringify(error.params.allowedValue)}.`;
45 else if(error.keyword==='enum')example=`Choose ${error.params.allowedValues.map(value=>JSON.stringify(value)).join(', ')}.`;
46 else if(error.keyword==='oneOf'&&component==='diagram')example='@diagram sequence source=request.sequence.json';
47 else if(error.keyword==='minimum'||error.keyword==='exclusiveMinimum'||error.keyword==='maximum')example=`Use a finite value satisfying the stated bound, such as ${error.params.limit + (error.keyword==='exclusiveMinimum'?1:0)}.`;
48 else if(error.keyword==='pattern')example=pointer.endsWith('/revision')?'revision=0123456789abcdef0123456789abcdef01234567':'id=request-path';
49 else if(error.keyword==='minItems')example='| a | First option |\n| b | Second option |';
50 else if(error.keyword==='minLength')example='text="A direct explanation."';
51 else example=component==='panel'?'## Why does this happen? {#reasoning}\nA direct explanation.':component==='source'?'@source facts example title="Stated facts"':`@${component} id=example`;
52 fail(`schema-${error.keyword}`,pointer||'/',component,reason,example);
53 }
54 return {ok:false,diagnostics};
55 }
56 const targets=new Map([['answer',{pointer:'/answer',component:'answer'}]]), sourceIds=new Map(), diagrams=new Map();
57 function id(value,pointer,component,panelId,registry=targets) {
58 if(!ID.test(value)||value.length>100)fail('id',pointer,component,'IDs require an ASCII letter followed by letters, digits, hyphens or underscores.','id=request-path',panelId);
59 if(registry.has(value))fail('duplicate-id',pointer,component,'This identity is already used. Use an explicit distinct ID.','id=request-path-branch',panelId);
60 registry.set(value,{pointer,component,panelId});
61 if(registry===targets&&(value.startsWith('explainer-')||READER_IDS.has(value)))fail('reserved-id',pointer,component,'This identity is reserved for reader controls or generated footnotes.','id=request-path',panelId);
62 }
63 for(const [si,source]of document.sources.entries())id(source.id,`/sources/${si}/id`,'source',undefined,sourceIds);
64 for(const [pi,panel]of document.panels.entries()) {
65 const pp=`/panels/${pi}`;id(panel.id,`${pp}/id`,'panel',panel.id);
66 for(const [bi,block]of panel.blocks.entries()) {
67 const bp=`${pp}/blocks/${bi}`;id(block.id,`${bp}/id`,block.type,panel.id);
68 if(block.type==='diagram')diagrams.set(block.id,{block,pointer:bp,panelId:panel.id});
69 if(['comparison','steps'].includes(block.type))for(const [ii,item]of (block.rows||block.items).entries()) {
70 const ip=`${bp}/${block.rows?'rows':'items'}/${ii}`;id(item.id,`${ip}/id`,block.type,panel.id);
71 }
72 }
73 }
74 for(const sourceId of sourceIds.keys())if(targets.has(`source-${sourceId}`))fail('reserved-id',targets.get(`source-${sourceId}`).pointer,'source','This identity collides with a generated source anchor.','id=reader-section');
75 function reference(value,pointer,component,panelId,registry,example) {
76 if(!registry.has(value))fail('unresolved-reference',pointer,component,'The referenced identity does not exist or has the wrong component type.',example,panelId);
77 }
78 function references(values,pointer,component,panelId,registry,example) {
79 const seen=new Set();for(const [i,value]of (values||[]).entries()) {reference(value,`${pointer}/${i}`,component,panelId,registry,example);if(seen.has(value))fail('duplicate-reference',`${pointer}/${i}`,component,'Each reference may occur only once.','Use each ID once.',panelId);seen.add(value);}
80 }
81 function diagramTarget(target,pointer,component,panelId) {
82 if (target.node !== undefined && target.route !== undefined) fail('diagram-target-exclusive',pointer,component,'A diagram target cannot select a node and a route at the same time.','{"diagram":"request-path","node":"api"} or {"diagram":"request-path","route":["api","redis"]}',panelId);
83 reference(target.diagram,`${pointer}/diagram`,component,panelId,diagrams,'Use the ID of a diagram block, such as request-path.');
84 const diagram=diagrams.get(target.diagram);if(!diagram?.block.spec)return;
85 const spec=diagram.block.spec;
86 const nodes=new Set((spec[NODE_KEYS[diagram.block.diagramType]]||[]).map(node=>node.id));
87 const edges=spec[EDGE_KEYS[diagram.block.diagramType]]||[];
88 if(target.node!==undefined&&!nodes.has(target.node))fail('diagram-node',`${pointer}/node`,component,'The node does not exist in the authored native diagram.','node=api',panelId);
89 for(const node of target.route||[])if(!nodes.has(node))fail('diagram-route',`${pointer}/route`,component,'A route endpoint does not exist in the authored native diagram.','route=api~redis',panelId);
90 if(target.edges)references(target.edges,`${pointer}/edges`,component,panelId,new Map(edges.filter(edge=>edge.id).map(edge=>[edge.id,edge])),'Use an edge ID from the native diagram.');
91 }
92 function text(value,pointer,component,panelId) {
93 if(typeof value!=='string')return;
94 const where=location(pointer,component,panelId);
95 const parsed=parseInline(value,{...where,input:inputPath});diagnostics.push(...parsed.diagnostics.map(entry=>({...entry,subject:{...entry.subject,path:pointer}})));
96 const prose=tokens=>tokens.map(token=>token.type==='code'?'':token.children?prose(token.children):token.type==='link'?token.label:token.value||'').join('');
97 if(component!=='source')entries.push({text:prose(parsed.tokens),raw:value,pointer,line:where.line,component,...(panelId?{panelId}:{})});
98 for(const ref of parsed.references) {
99 if(ref.kind==='source')reference(ref.id,pointer,component,panelId,sourceIds,'[Evidence](source:known-source)');
100 if(ref.kind==='block')reference(ref.id,pointer,component,panelId,targets,'[Read explanation](block:known-block)');
101 if(ref.kind==='node')diagramTarget({diagram:ref.diagram,node:ref.node},pointer,component,panelId);
102 if(ref.kind==='route')diagramTarget({diagram:ref.diagram,route:[ref.from,ref.to]},pointer,component,panelId);
103 if(ref.kind==='typical'&&!['source','scope'].includes(component))entries.push({text:ref.text,line:where.line,component:'typical',...(panelId?{panelId}:{})});
104 }
105 }
106 text(document.meta.title,'/meta/title','title');text(document.answer.summary,'/answer/summary','answer');(document.answer.reasons||[]).forEach((reason,i)=>text(reason,`/answer/reasons/${i}`,'answer'));
107 text(document.answer.scope,'/answer/scope','scope');
108 for(const [si,source]of document.sources.entries()) {
109 const pointer=`/sources/${si}`;text(source.title,`${pointer}/title`,'source');
110 if(source.lines&&source.lines[1]<source.lines[0])fail('source-lines',`${pointer}/lines`,'source','The repository line range must be ordered.','lines=10:20');
111 if(source.url) {let url;try{url=new URL(source.url);}catch{}if(!url||!['http:','https:'].includes(url.protocol)||url.username||url.password||/[\u0000-\u0020\u007f]/u.test(source.url))fail('source-url',`${pointer}/url`,'source','Source URLs must be credential-free HTTP(S) links.','url=https://example.com/docs');}
112 }
113 for(const [pi,panel]of document.panels.entries()) {
114 const pp=`/panels/${pi}`;text(panel.title,`${pp}/title`,'panel',panel.id);
115 for(const [bi,block]of panel.blocks.entries()) {
116 const bp=`${pp}/blocks/${bi}`, component=block.type;
117 if(component==='diff')diagnostics.push(...parseExplainerDiff(block.text,{...location(`${bp}/text`,component,panel.id),input:inputPath}).diagnostics);
118 if(component==='chart'&&(block.text===undefined)===(block.data===undefined))fail('chart',`${bp}`,component,'A chart has either fence text or a data file, not both or neither.','@chart type=bar data=latency.csv x=day y=p99',panel.id);
119 if(component==='chart'&&block.text!==undefined){const chart=compileExplainerChart(block.text);if(chart.error){const before=diagnostics.length;fail('chart',`${bp}/text`,component,chart.error,chart.example,panel.id);if(chart.line>0)diagnostics[before].subject.line+=chart.line-1;}}
120 for(const key of ['text','title','label','note','caption','alt'])if(!['diff','snippet','chart'].includes(component)||key!=='text')text(block[key],`${bp}/${key}`,component,panel.id);
121 if(component==='list')block.items.forEach((item,i)=>text(item,`${bp}/items/${i}`,component,panel.id));
122 if(component==='facts')block.entries.forEach((item,i)=>{text(item.key,`${bp}/entries/${i}/key`,component,panel.id);text(item.value,`${bp}/entries/${i}/value`,component,panel.id);});
123 if(component==='comparison') {
124 block.columns.forEach((column,i)=>text(column,`${bp}/columns/${i}`,component,panel.id));text(block.labelColumn,`${bp}/labelColumn`,component,panel.id);
125 block.rows.forEach((row,i)=>{const rp=`${bp}/rows/${i}`;text(row.label,`${rp}/label`,component,panel.id);if(row.cells.length!==block.columns.length)fail('table-width',`${rp}/cells`,component,'Each row must have one cell for every comparison column.','| Criterion | Option A | Option B |',panel.id);row.cells.forEach((cell,j)=>{text(cell.text,`${rp}/cells/${j}/text`,component,panel.id);text(cell.verdict,`${rp}/cells/${j}/verdict`,component,panel.id);});});
126 }
127 if(component==='steps')block.items.forEach((item,i)=>{text(item.text,`${bp}/items/${i}/text`,component,panel.id);text(item.detail,`${bp}/items/${i}/detail`,component,panel.id);if(item.target)diagramTarget(item.target,`${bp}/items/${i}/target`,component,panel.id);});
128 if(component==='code') {reference(block.source,`${bp}/source`,component,panel.id,sourceIds,'source=repository-snippet');if(sourceIds.has(block.source)&&document.sources.find(source=>source.id===block.source)?.path===undefined)fail('code-source-kind',`${bp}/source`,component,'Code blocks require a repository source whose commit and lines can be checked.','@source snippet title="Code" path=src/router.js lines=1:10',panel.id);}
129 if(component==='diagram'&&block.nodeSources)for(const [node,source] of Object.entries(block.nodeSources))reference(source,`${bp}/nodeSources/${node}`,component,panel.id,sourceIds,`| ${node} | Label | backend | source:router |`);
130 if(component==='diagram'&&block.spec) {
131 if(block.spec.diagram_type!==block.diagramType)fail('diagram-type',`${bp}/spec/diagram_type`,component,'The native specification must match the declared diagram type.','@sequence source=request.sequence.json',panel.id);
132 try{validateSchema(block.diagramType,block.spec);}catch(error){for(const entry of error.archifyDiagnostics||[])diagnostics.push({...entry,code:`explainer/native-${entry.code}`,subject:location(`${bp}/spec${entry.subject?.path==='/'?'':entry.subject?.path||''}`,component,panel.id),evidence:{...entry.evidence,reason:entry.message,example:`Use valid native ${block.diagramType} JSON.`}});}
133 }
134 }
135 }
136 diagnostics.push(...lintExplainerWriting(entries,{mode:writingMode,locale:document.meta.locale||'auto'}).map(entry=>({...entry,subject:{...entry.subject,...(inputPath?{input:inputPath}:{})}})));
137 return {ok:!diagnostics.some(entry=>entry.severity==='error'),diagnostics};