import {readFile,writeFile} from 'node:fs/promises' import {fileURLToPath} from 'node:url' const keys=new Set(['schema','policy','before','change']), artifactKeys=new Set(['id','revision','requires']) const exact=(o,allowed,label)=>{if(!o||typeof o!=='object'||Array.isArray(o))throw Error(`${label} must be an object`);for(const k of Object.keys(o))if(!allowed.has(k))throw Error(`${label}.${k} is not allowed`);for(const k of allowed)if(!(k in o))throw Error(`${label}.${k} is required`)} export function evaluate(input){ exact(input,keys,'input');if(input.schema!=='martybytes-spec-lineage/v1')throw Error('unsupported schema');if(input.policy!=='living-spec')throw Error('unsupported policy') exact(input.before,new Set(['spec','plan','tasks','checks']),'before');const map=new Map() for(const name of ['spec','plan','tasks','checks']){const a=input.before[name];exact(a,artifactKeys,`before.${name}`);if(typeof a.id!=='string'||!/^[-a-z0-9]+$/.test(a.id)||!Number.isSafeInteger(a.revision)||a.revision<1||!Array.isArray(a.requires)||a.requires.some(x=>typeof x!=='string')||new Set(a.requires).size!==a.requires.length)throw Error(`invalid ${name}`);if(map.has(a.id))throw Error('duplicate artifact id');map.set(a.id,{...a,name})} if(input.before.spec.requires.length)throw Error('living-spec root must not have dependencies') for(const a of map.values())for(const ref of a.requires){const m=/^([-a-z0-9]+)@(\d+)$/.exec(ref),target=m&&map.get(m[1]);if(!target||ref!==`${target.id}@${target.revision}`)throw Error(`unknown, noncanonical, or wrong-revision dependency: ${ref}`)} const visiting=new Set(),visited=new Set(),order=[];function visit(a){if(visiting.has(a.id))throw Error('dependency cycle');if(visited.has(a.id))return;visiting.add(a.id);for(const ref of a.requires)visit(map.get(ref.split('@')[0]));visiting.delete(a.id);visited.add(a.id);order.push(a)}for(const a of map.values())visit(a) exact(input.change,new Set(['artifact','fromRevision','toRevision','summary']),'change');const c=input.change;if(c.artifact!=='spec'||!Number.isSafeInteger(c.fromRevision)||!Number.isSafeInteger(c.toRevision)||c.toRevision!==c.fromRevision+1||typeof c.summary!=='string'||!c.summary.trim())throw Error('invalid change');if(input.before.spec.revision!==c.fromRevision)throw Error('change does not bind current spec') const changed=`${input.before.spec.id}@${c.fromRevision}`, stale=[],frontier=new Set([changed]) for(const a of order){if(a.name==='spec')continue;const hits=a.requires.filter(x=>frontier.has(x));if(hits.length){stale.push({artifact:a.name,id:a.id,reason:`depends on ${hits.join(', ')}`});frontier.add(`${a.id}@${a.revision}`)}} const proposedRebuild={spec:`${input.before.spec.id}@${c.toRevision}`};for(const a of stale){const next=map.get(a.id).revision+1;if(!Number.isSafeInteger(next))throw Error(`revision overflow: ${a.id}`);proposedRebuild[a.artifact]=`${a.id}@${next}`} return{schema:'martybytes-spec-lineage-result/v1',policy:input.policy,change:{from:changed,to:proposedRebuild.spec,summary:c.summary},stale,rebuildOrder:stale.map(x=>x.artifact),reviewStops:['approve changed requirement','review proposed downstream rebuild','rerun checks bound to rebuilt artifacts'],proposedRebuild} } async function main([inputPath,outputPath]){if(!inputPath||!outputPath)throw Error('Usage: node spec-lineage-lab.mjs input.json result.json');const result=evaluate(JSON.parse(await readFile(inputPath,'utf8')));await writeFile(outputPath,JSON.stringify(result,null,2)+'\n',{flag:'wx'})} if(process.argv[1]===fileURLToPath(import.meta.url))main(process.argv.slice(2)).catch(e=>{console.error(e.message);process.exitCode=1})