Files
KArtSell.Aegis/docs/Design/kbx-foundation-v36/scripts/validate-api-governance.mjs
T

93 lines
5.7 KiB
JavaScript

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}`)