import fs from 'node:fs' import path from 'node:path' const source = JSON.parse(fs.readFileSync('contracts/api/kbx.api.json', 'utf8')) const sourceByRoute = new Map(source.operations.map(op => [`${op.method} ${op.path}`, op])) const sourceById = new Map(source.operations.map(op => [op.id, op])) function walk(dir, ext) { const values=[] for (const entry of fs.readdirSync(dir, { withFileTypes:true })) { const p=path.join(dir,entry.name) if(entry.isDirectory()) values.push(...walk(p,ext)) else if(!ext || p.endsWith(ext)) values.push(p) } return values } const endpointRoutes=[] for (const file of walk('backend/Modules','.cs')) { const text=fs.readFileSync(file,'utf8') const regex=/\b(Get|Post|Put|Delete|Patch)\("([^"]+)"\)/g for (const match of text.matchAll(regex)) { const after=text.slice((match.index ?? 0)+match[0].length) const next=after.search(/\b(?:Get|Post|Put|Delete|Patch)\("/) const chunk=next>=0?after.slice(0,next):after const permission=chunk.match(/Permissions\("([^"]+)"\)/)?.[1] ?? null endpointRoutes.push({method:match[1].toUpperCase(),path:match[2],permission,file}) } } const errors=[] const actualByRoute=new Map(endpointRoutes.map(op=>[`${op.method} ${op.path}`,op])) for (const [key, actual] of actualByRoute) { const declared=sourceByRoute.get(key) if(!declared){errors.push(`backend route missing from API contract: ${key} (${actual.file})`);continue} if((declared.permission??null)!==(actual.permission??null))errors.push(`permission drift ${key}: contract=${declared.permission} backend=${actual.permission}`) } for (const [key, declared] of sourceByRoute) if(!actualByRoute.has(key)) errors.push(`API contract route missing from backend: ${key} (${declared.id})`) // Idempotency='required' means the server contract must contain an explicit stable key boundary. for (const op of source.operations.filter(x=>x.idempotency==='required')) { const actual=actualByRoute.get(`${op.method} ${op.path}`) if(!actual) continue const scope=path.dirname(actual.file) const text=walk(scope,'.cs').map(f=>fs.readFileSync(f,'utf8')).join('\n') if(!/(Idempotency-Key|IdempotencyKey|idempotency_key)/.test(text)) errors.push(`required idempotency is not enforced in server scope: ${op.id}`) } // Business code must use operation IDs, not raw transport implementation details. for (const file of walk('apps/web/src')) { if(file.includes('/http/')) continue const text=fs.readFileSync(file,'utf8') if(/from ['"]axios['"]/.test(text) || /\baxios\./.test(text) || /\bfetch\s*\(/.test(text)) errors.push(`direct HTTP client usage outside http adapter: ${file}`) if(/['"`]\/api\//.test(text)) errors.push(`raw /api route outside generated client: ${file}`) } // Every operation ID referenced by the web application must exist. for (const file of walk('apps/web/src','.ts')) { const text=fs.readFileSync(file,'utf8') for (const m of text.matchAll(/kbxApi\.request(?:<[^;\n]*?>)?\(\s*['"]([^'"]+)['"]/g)) { if(!sourceById.has(m[1])) errors.push(`unknown API operation id '${m[1]}' in ${file}`) } } const apiManifest=JSON.parse(fs.readFileSync('generated/api-manifest.json','utf8')) const openapi=JSON.parse(fs.readFileSync('contracts/api/openapi.kbx.json','utf8')) const openApiCount=Object.values(openapi.paths).reduce((n,item)=>n+Object.keys(item).filter(k=>['get','post','put','patch','delete'].includes(k)).length,0) if(apiManifest.operationCount!==source.operations.length)errors.push('generated API manifest count drift') if(openApiCount!==source.operations.length)errors.push(`OpenAPI operation count drift: ${openApiCount} != ${source.operations.length}`) const tsCatalog=fs.readFileSync('packages/kbx-contracts/src/generated/apiCatalog.ts','utf8') const csCatalog=fs.readFileSync('backend/Shared/Contracts/Generated/KbxApiCatalog.g.cs','utf8') if(!tsCatalog.includes(apiManifest.sourceSha256))errors.push('TypeScript API catalog source SHA drift') if(!csCatalog.includes(apiManifest.sourceSha256))errors.push('C# API catalog source SHA drift') const problemManifest=JSON.parse(fs.readFileSync('generated/problem-manifest.json','utf8')) const requiredProblems=['validation','business-rule','conflict','permission','not-found','integration','system'] for(const type of requiredProblems)if(!problemManifest.types.includes(type))errors.push(`missing KBX problem type: ${type}`) const tsProblems=fs.readFileSync('packages/kbx-contracts/src/problem.ts','utf8') const csProblems=fs.readFileSync('backend/Shared/Problems/KbxProblems.cs','utf8') for(const type of requiredProblems){if(!tsProblems.includes(`'${type}'`))errors.push(`TS problem contract missing: ${type}`)} for(const name of ['KbxValidationProblem','KbxBusinessProblem','KbxConflictProblem','KbxPermissionProblem','KbxNotFoundProblem','KbxIntegrationProblem','KbxSystemProblem'])if(!csProblems.includes(name))errors.push(`C# problem contract missing: ${name}`) // Ratchet legacy plain 404 responses: existing debt can shrink, never grow. const plain404=walk('backend/Modules','.cs').reduce((count,file)=>count+(fs.readFileSync(file,'utf8').match(/(?:Send\.)?NotFoundAsync\s*\(/g)?.length??0),0) const debtPath='governance/api-debt-baseline.json' const debt=JSON.parse(fs.readFileSync(debtPath,'utf8')) if(plain404>debt.plainNotFoundResponses)errors.push(`plain NotFound response debt increased: ${plain404} > ${debt.plainNotFoundResponses}`) fs.writeFileSync('generated/api-debt-report.json',JSON.stringify({plainNotFoundResponses:plain404,baseline:debt.plainNotFoundResponses},null,2)+'\n') if(errors.length){console.error(errors.map(x=>`API GOVERNANCE ERROR: ${x}`).join('\n'));process.exit(1)} console.log(`API governance PASS: ${source.operations.length} operations, ${problemManifest.types.length} problem types, raw transport usage 0, plain404=${plain404}/${debt.plainNotFoundResponses}`)