import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { spawnSync } from 'node:child_process' const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kbx-scaffold-')) fs.mkdirSync(path.join(root, 'apps/web/src/help'), { recursive: true }) fs.writeFileSync(path.join(root, 'apps/web/src/help/helpRegistry.ts'), "import type { KbxHelpContent } from '@kbx/contracts'\nexport const helpRegistry: Record = {\n}\n") const recipeContract=JSON.parse(fs.readFileSync('contracts/screens/kbx.screen-recipes.json','utf8')) const recipeByType=new Map(recipeContract.recipes.map(recipe=>[recipe.type,recipe])) const cases=[ ['list','KbxListPage',['KbxSearchPanel','KbxDataGrid','KbxSummaryBar']], ['master','KbxMasterPage',['KbxDataGrid','KbxFormGrid','KbxFormSection']], ['transaction','KbxTransactionPage',['KbxDataGrid','KbxFormGrid','KbxSummaryBar']], ['fast-entry','KbxFastEntryPage',['KbxDataGrid','KbxValidationSummary','KbxSummaryBar']], ['master-detail','KbxMasterDetailPage',['KbxSearchPanel','KbxDataGrid']], ['queue','KbxQueuePage',['KbxExceptionSummary','KbxDataGrid','KbxSummaryBar']], ['reconcile','KbxReconcilePage',['KbxSearchPanel','KbxDataGrid']], ['import','KbxImportPage',['KbxProgressSteps','KbxExcelImport']], ['wms-mobile','KbxWmsMobilePage',['KbxBarcodeCapture','KbxWmsActionButton']], ] for(const [index,[type,component,needles]] of cases.entries()){ const number=String(901+index) const modulePath=`erp/scaffold/${type}` const args=[ 'scripts/create-kbx-screen.mjs','--module','ERP','--area','SCF','--number',number,'--type',type, '--name',`스캐폴드 ${type}`,'--path',modulePath,'--permission','erp.item.read', ] if(['master','transaction','fast-entry'].includes(type))args.push('--write-permission','erp.item.write') const result = spawnSync(process.execPath,args,{ cwd: process.cwd(), env: { ...process.env, KBX_ROOT: root }, encoding: 'utf8' }) if (result.status !== 0) { console.error(result.stdout, result.stderr); process.exit(result.status ?? 1) } const target=path.join(root,'apps/web/src/modules',modulePath) const definition=fs.readdirSync(target).find(file=>file.endsWith('.definition.ts')) const page=fs.readdirSync(target).find(file=>file.endsWith('Page.vue')) if(!definition||!page)throw new Error(`scaffolder ${type} missing definition/page`) const definitionText=fs.readFileSync(path.join(target,definition),'utf8') const text=fs.readFileSync(path.join(target,page),'utf8') const readme=fs.readFileSync(path.join(target,'README.md'),'utf8') const testPlanFile=fs.readdirSync(target).find(file=>file.endsWith('.test-plan.ts')) if(!testPlanFile)throw new Error(`scaffolder ${type} missing generated recipe test plan`) const testPlan=fs.readFileSync(path.join(target,testPlanFile),'utf8') const recipe=recipeByType.get(type) if(!recipe)throw new Error(`recipe missing for ${type}`) if(!definitionText.includes(`templateCode: '${recipe.code}'`))throw new Error(`scaffolder ${type} missing ${recipe.code}`) if(!text.includes(component))throw new Error(`scaffolder ${type} must use ${component}`) for(const needle of needles)if(!text.includes(needle))throw new Error(`scaffolder ${type} missing ${needle}`) for(const scenario of recipe.canonicalScenarioIds){ if(!readme.includes(scenario))throw new Error(`scaffolder ${type} README missing canonical scenario ${scenario}`) if(!testPlan.includes(scenario))throw new Error(`scaffolder ${type} test plan missing canonical scenario ${scenario}`) } for(const check of recipe.testProfile.requiredChecks)if(!testPlan.includes(check))throw new Error(`scaffolder ${type} test plan missing required check ${check}`) for(const evidence of recipe.testProfile.requiredEvidence)if(!testPlan.includes(evidence))throw new Error(`scaffolder ${type} test plan missing evidence ${evidence}`) if(/primevue\//.test(text)||/ag-grid-vue3/.test(text))throw new Error(`scaffolder ${type} leaks underlying UI libraries`) if(['master','transaction','fast-entry'].includes(type)&&!/permission:'erp\.item\.write'/.test(definitionText))throw new Error(`scaffolder ${type} mutating commands must carry explicit write permission`) } const unsafeRoot=fs.mkdtempSync(path.join(os.tmpdir(),'kbx-scaffold-deny-')) fs.mkdirSync(path.join(unsafeRoot,'apps/web/src/help'),{recursive:true}) fs.writeFileSync(path.join(unsafeRoot,'apps/web/src/help/helpRegistry.ts'),"export const helpRegistry = {}\n") const denied=spawnSync(process.execPath,['scripts/create-kbx-screen.mjs','--module','ERP','--area','DEN','--number','999','--type','transaction','--name','권한 없는 거래','--path','erp/scaffold/deny','--permission','erp.item.read'],{cwd:process.cwd(),env:{...process.env,KBX_ROOT:unsafeRoot},encoding:'utf8'}) if(denied.status===0||!`${denied.stderr}${denied.stdout}`.includes('--write-permission is required'))throw new Error('scaffolder must fail closed when a mutating recipe has no explicit write permission') fs.rmSync(unsafeRoot,{recursive:true,force:true}) const help = fs.readFileSync(path.join(root, 'apps/web/src/help/helpRegistry.ts'),'utf8') if((help.match(/^ 'ERP-SCF-9\d\d':/gm)??[]).length!==9)throw new Error('scaffolder did not add all help registry stubs') console.log('KBX scaffolder contract PASS (T01-T09 recipe-driven composition, generated test plans, canonical scenarios, templateCode, and fail-closed write permissions)') fs.rmSync(root, { recursive: true, force: true })