// Offline structural-policy validator. It does not interpret prose or execute plan commands. import {readFile} from 'node:fs/promises' import {fileURLToPath} from 'node:url' const object=value=>value!==null&&typeof value==='object'&&!Array.isArray(value) const text=value=>typeof value==='string'&&value.trim().length>0 const textArray=value=>Array.isArray(value)&&value.every(text) const safePath=value=>text(value)&&!value.includes('\\')&&!value.startsWith('/')&&!/^[A-Za-z]:/.test(value)&&value.split('/').every(part=>part!=='.'&&part!=='..'&&/^[A-Za-z0-9._-]+$/.test(part)&&!/[. ]$/.test(part)) const safePrefix=value=>text(value)&&value.endsWith('/')&&safePath(value.slice(0,-1)) const exactKeys=(value,allowed,label,errors)=>{if(object(value))for(const key of Object.keys(value))if(!allowed.has(key))errors.push(`unknown ${label} field: ${key}`)} const planKeys=new Set(['schema','id','version','phase','goal','changes','dependencies','testRequirements','executionEvidence','rollback','stopConditions']) const policyKeys=new Set(['schema','allowedPathPrefixes','deniedPathPrefixes','allowedDependencies','requiredTestIds','requireRollback']) const changeKeys=new Set(['path','action']),testKeys=new Set(['id','command']),evidenceKeys=new Set(['requirementId','status','evidence']),rollbackKeys=new Set(['strategy','verification']) export function validatePlan(plan,policy){ const errors=[] if(!object(plan))return {id:null,decision:'reject',errors:['plan must be an object']} if(!object(policy))return {id:text(plan.id)?plan.id:null,decision:'reject',errors:['policy must be an object']} exactKeys(plan,planKeys,'plan',errors);exactKeys(policy,policyKeys,'policy',errors) if(plan.schema!=='martybytes-plan-contract/v1')errors.push('unsupported plan schema') if(policy.schema!=='martybytes-plan-policy/v1')errors.push('unsupported policy schema') if(!text(plan.id))errors.push('id must be a non-blank string') if(!text(plan.version))errors.push('version must be a non-blank string') if(!['proposed','verified'].includes(plan.phase))errors.push('phase must be proposed or verified') if(!text(plan.goal))errors.push('goal must be a non-blank string') for(const key of ['allowedPathPrefixes','deniedPathPrefixes','allowedDependencies','requiredTestIds'])if(!textArray(policy[key]))errors.push(`policy ${key} must be a string array`) for(const key of ['allowedPathPrefixes','deniedPathPrefixes'])if(textArray(policy[key])&&policy[key].some(prefix=>!safePrefix(prefix)))errors.push(`policy ${key} must contain safe relative directory prefixes`) if(typeof policy.requireRollback!=='boolean')errors.push('policy requireRollback must be boolean') const allowed=textArray(policy.allowedPathPrefixes)?policy.allowedPathPrefixes:[],denied=textArray(policy.deniedPathPrefixes)?policy.deniedPathPrefixes:[] const changes=Array.isArray(plan.changes)?plan.changes:[] if(!Array.isArray(plan.changes)||!plan.changes.length)errors.push('changes must be a non-empty array') for(const [index,change] of changes.entries()){ if(!object(change)){errors.push(`change ${index} must be an object`);continue} exactKeys(change,changeKeys,`change ${index}`,errors) if(!safePath(change.path))errors.push(`change ${index} path must be safe and relative`) else{ if(!allowed.some(prefix=>change.path.startsWith(prefix)))errors.push(`change path is outside allowed scope: ${change.path}`) if(denied.some(prefix=>change.path.startsWith(prefix)))errors.push(`change path is explicitly denied: ${change.path}`) } if(!['add','modify','delete'].includes(change.action))errors.push(`change ${index} action is invalid`) } const dependencies=textArray(plan.dependencies)?plan.dependencies:[] if(!textArray(plan.dependencies))errors.push('dependencies must be a string array') const allowedDependencies=new Set(textArray(policy.allowedDependencies)?policy.allowedDependencies:[]) for(const dependency of dependencies)if(!allowedDependencies.has(dependency))errors.push(`dependency is not allowed: ${dependency}`) const requirements=Array.isArray(plan.testRequirements)?plan.testRequirements:[] if(!Array.isArray(plan.testRequirements))errors.push('testRequirements must be an array') const requirementIds=new Set() for(const [index,requirement] of requirements.entries()){ if(!object(requirement)){errors.push(`test requirement ${index} must be an object`);continue} exactKeys(requirement,testKeys,`test requirement ${index}`,errors) if(!text(requirement.id)||!text(requirement.command))errors.push(`test requirement ${index} needs non-blank id and command`) else if(requirementIds.has(requirement.id))errors.push(`duplicate test requirement: ${requirement.id}`) else requirementIds.add(requirement.id) } for(const required of textArray(policy.requiredTestIds)?policy.requiredTestIds:[])if(!requirementIds.has(required))errors.push(`required test is missing: ${required}`) const evidence=Array.isArray(plan.executionEvidence)?plan.executionEvidence:[] if(!Array.isArray(plan.executionEvidence))errors.push('executionEvidence must be an array') const evidenced=new Set() for(const [index,item] of evidence.entries()){ if(!object(item)){errors.push(`execution evidence ${index} must be an object`);continue} exactKeys(item,evidenceKeys,`execution evidence ${index}`,errors) if(!text(item.requirementId)||!requirementIds.has(item.requirementId))errors.push(`execution evidence ${index} references an unknown test`) else if(evidenced.has(item.requirementId))errors.push(`duplicate execution evidence for: ${item.requirementId}`) if(!['passed','failed'].includes(item.status))errors.push(`execution evidence ${index} status is invalid`) if(!textArray(item.evidence))errors.push(`execution evidence ${index} evidence must be a string array`) else if(!item.evidence.length)errors.push(`execution evidence ${index} must cite at least one artifact`) else if(item.evidence.some(reference=>!safePath(reference)))errors.push(`execution evidence ${index} artifact paths must be safe and relative`) if(text(item.requirementId))evidenced.add(item.requirementId) } if(plan.phase==='proposed'&&evidence.length)errors.push('proposed plans may declare tests but may not claim execution evidence') if(plan.phase==='verified'){ for(const id of requirementIds)if(!evidenced.has(id))errors.push(`verified plan lacks execution evidence for: ${id}`) for(const item of evidence)if(object(item)&&item.status==='failed')errors.push(`verified plan has failed test: ${item.requirementId}`) } if(!textArray(plan.stopConditions)||!plan.stopConditions.length)errors.push('stopConditions must be a non-empty string array') const hasRollback=Object.prototype.hasOwnProperty.call(plan,'rollback') if(!hasRollback){ if(policy.requireRollback)errors.push('rollback is required by policy') }else if(!object(plan.rollback))errors.push('rollback must be an object when present') else{ exactKeys(plan.rollback,rollbackKeys,'rollback',errors) if(!text(plan.rollback.strategy)||!text(plan.rollback.verification))errors.push('rollback needs non-blank strategy and verification') } return {id:text(plan.id)?plan.id:null,decision:errors.length?'reject':'allow',errors} } async function main(args){ if(args.length<2)throw new Error('Usage: node plan-contract-validator.mjs POLICY.json PLAN.json [PLAN.json ...]') const [policyPath,...planPaths]=args,policy=JSON.parse(await readFile(policyPath,'utf8')),results=[] for(const planPath of planPaths)results.push(validatePlan(JSON.parse(await readFile(planPath,'utf8')),policy)) console.log(JSON.stringify({schema:'martybytes-plan-contract-review/v1',results},null,2)) if(results.some(result=>result.decision==='reject'))process.exitCode=2 } if(process.argv[1]===fileURLToPath(import.meta.url))main(process.argv.slice(2)).catch(error=>{console.log(JSON.stringify({schema:'martybytes-plan-contract-review/v1',error:{code:'input-error',message:error.message}},null,2));process.exitCode=1})