V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,199 @@
import fs from 'node:fs'
import path from 'node:path'
const release=JSON.parse(fs.readFileSync('governance/release.json','utf8'))
const baseDir=path.join('governance/baselines',release.baseline)
const read=p=>JSON.parse(fs.readFileSync(p,'utf8'))
const prev={
components:read(path.join(baseDir,'component-manifest.json')),
screens:read(path.join(baseDir,'screen-manifest.json')),
tokens:read(path.join(baseDir,'token-manifest.json')),
fields:read(path.join(baseDir,'field-manifest.json')),
api:fs.existsSync(path.join(baseDir,'api-manifest.json'))?read(path.join(baseDir,'api-manifest.json')):{operations:[]},
problems:fs.existsSync(path.join(baseDir,'problem-manifest.json'))?read(path.join(baseDir,'problem-manifest.json')):{types:[]},
permissions:fs.existsSync(path.join(baseDir,'permission-manifest.json'))?read(path.join(baseDir,'permission-manifest.json')):{permissions:[]},
sensitive:fs.existsSync(path.join(baseDir,'sensitive-data-manifest.json'))?read(path.join(baseDir,'sensitive-data-manifest.json')):{policies:[]},
telemetry:fs.existsSync(path.join(baseDir,'telemetry-manifest.json'))?read(path.join(baseDir,'telemetry-manifest.json')):{events:[],metrics:[]},
experiments:fs.existsSync(path.join(baseDir,'experiment-manifest.json'))?read(path.join(baseDir,'experiment-manifest.json')):{featureFlags:[],experiments:[]},
testing:fs.existsSync(path.join(baseDir,'test-scenario-manifest.json'))?read(path.join(baseDir,'test-scenario-manifest.json')):{scenarios:[]},
integrations:fs.existsSync(path.join(baseDir,'integration-manifest.json'))?read(path.join(baseDir,'integration-manifest.json')):{integrations:[]},
providers:fs.existsSync(path.join(baseDir,'provider-manifest.json'))?read(path.join(baseDir,'provider-manifest.json')):{providers:[]},
externalData:fs.existsSync(path.join(baseDir,'external-data-manifest.json'))?read(path.join(baseDir,'external-data-manifest.json')):{datasets:[]},
configuration:fs.existsSync(path.join(baseDir,'configuration-manifest.json'))?read(path.join(baseDir,'configuration-manifest.json')):{settings:[],environments:[]},
screenRecipes:fs.existsSync(path.join(baseDir,'screen-recipe-manifest.json'))?read(path.join(baseDir,'screen-recipe-manifest.json')):{recipes:[]},
}
const curr={
components:read('generated/component-manifest.json'),
screens:read('generated/screen-manifest.json'),
tokens:read('generated/token-manifest.json'),
fields:read('generated/field-manifest.json'),
api:read('generated/api-manifest.json'),
problems:read('generated/problem-manifest.json'),
permissions:read('generated/permission-manifest.json'),
sensitive:read('generated/sensitive-data-manifest.json'),
telemetry:read('generated/telemetry-manifest.json'),
experiments:read('generated/experiment-manifest.json'),
testing:read('generated/test-scenario-manifest.json'),
integrations:read('generated/integration-manifest.json'),
providers:read('generated/provider-manifest.json'),
externalData:read('generated/external-data-manifest.json'),
configuration:read('generated/configuration-manifest.json'),
screenRecipes:read('generated/screen-recipe-manifest.json'),
}
const levels={none:0,patch:1,minor:2,major:3}
let required='none';const changes=[]
const promote=l=>{if(levels[l]>levels[required])required=l}
function diffNamed(kind,oldList,newList,key='name'){
const oldMap=new Map(oldList.map(x=>[x[key],x])),newMap=new Map(newList.map(x=>[x[key],x]))
for(const [k] of oldMap){if(!newMap.has(k)){changes.push({level:'major',kind,change:'removed',key:k});promote('major')}}
for(const [k] of newMap){if(!oldMap.has(k)){changes.push({level:'minor',kind,change:'added',key:k});promote('minor')}}
return {oldMap,newMap}
}
const c=diffNamed('component',prev.components,curr.components)
for(const [name,n] of c.newMap){const o=c.oldMap.get(name);if(!o)continue;if(o.version!==n.version){const [om,oni]=o.version.split('.').map(Number),[nm,nni]=n.version.split('.').map(Number);let l='patch';if(nm>om)l='major';else if(nni>oni)l='minor';changes.push({level:l,kind:'component',change:'version',key:name,from:o.version,to:n.version});promote(l)}}
const s=diffNamed('screen',prev.screens,curr.screens,'id')
for(const [id,n] of s.newMap){const o=s.oldMap.get(id);if(!o)continue;if(o.version!==n.version){const om=Number(o.version.split('.')[0]),nm=Number(n.version.split('.')[0]);const l=nm>om?'major':'patch';changes.push({level:l,kind:'screen',change:'version',key:id,from:o.version,to:n.version});promote(l)}for(const property of ['type','templateCode'])if((o[property]??null)!==(n[property]??null)){changes.push({level:'major',kind:'screen',change:property,key:id,from:o[property]??null,to:n[property]??null});promote('major')}}
const sr=diffNamed('screen-recipe',prev.screenRecipes.recipes??[],curr.screenRecipes.recipes??[],'code')
for(const [code,n] of sr.newMap){
const o=sr.oldMap.get(code);if(!o)continue
for(const property of ['type','templateComponent'])if((o[property]??null)!==(n[property]??null)){changes.push({level:'major',kind:'screen-recipe',change:property,key:code,from:o[property]??null,to:n[property]??null});promote('major')}
for(const property of ['defaultCommands','requiredPolicies','recoveryPolicies','securityPolicies','scaffoldSurfaces'])if(JSON.stringify(o[property]??[])!==JSON.stringify(n[property]??[])){changes.push({level:'major',kind:'screen-recipe',change:property,key:code});promote('major')}
if(JSON.stringify(o.canonicalScenarioIds??[])!==JSON.stringify(n.canonicalScenarioIds??[])){changes.push({level:'patch',risk:'test-coverage',kind:'screen-recipe',change:'canonicalScenarioIds',key:code});promote('patch')}
if(JSON.stringify(o.testProfile??null)!==JSON.stringify(n.testProfile??null)){changes.push({level:'patch',risk:'test-coverage',kind:'screen-recipe',change:'testProfile',key:code});promote('patch')}
}
const oldT=new Map(prev.tokens.tokens.map(x=>[x.path,x])),newT=new Map(curr.tokens.tokens.map(x=>[x.path,x]))
for(const [k] of oldT){if(!newT.has(k)){changes.push({level:'major',kind:'token',change:'removed',key:k});promote('major')}}
for(const [k,n] of newT){const o=oldT.get(k);if(!o){changes.push({level:'minor',kind:'token',change:'added',key:k});promote('minor');continue}if(o.value!==n.value){changes.push({level:'patch',risk:'visual-risk',kind:'token',change:'value',key:k,from:o.value,to:n.value});promote('patch')}}
const f=diffNamed('field',prev.fields.fields??[],curr.fields.fields??[],'key')
const structural=['dataType','maxLength','precision','scale','lookupEntity','sensitive']
for(const [key,n] of f.newMap){
const o=f.oldMap.get(key);if(!o)continue
for(const property of structural){
if((o[property]??null)!==(n[property]??null)){
changes.push({level:'major',kind:'field',change:property,key,from:o[property]??null,to:n[property]??null});promote('major')
}
}
if(o.label!==n.label || JSON.stringify(o.aliases??[])!==JSON.stringify(n.aliases??[])){
changes.push({level:'patch',risk:'mapping-risk',kind:'field',change:'label-or-alias',key});promote('patch')
}
if(Boolean(o.deprecated)!==Boolean(n.deprecated)){
changes.push({level:'patch',kind:'field',change:'deprecation',key,from:Boolean(o.deprecated),to:Boolean(n.deprecated)});promote('patch')
}
}
const oldApi=new Map((prev.api.operations??[]).map(x=>[x.id,x])),newApi=new Map((curr.api.operations??[]).map(x=>[x.id,x]))
for(const [id,o] of oldApi){
const n=newApi.get(id)
if(!n){changes.push({level:'major',kind:'api',change:'removed',key:id});promote('major');continue}
for(const property of ['method','path','permission']){
if((o[property]??null)!==(n[property]??null)){changes.push({level:'major',kind:'api',change:property,key:id,from:o[property]??null,to:n[property]??null});promote('major')}
}
if(o.idempotency!==n.idempotency){
const tightening=o.idempotency!=='required'&&n.idempotency==='required'
const level=tightening?'major':'patch';changes.push({level,kind:'api',change:'idempotency',key:id,from:o.idempotency,to:n.idempotency});promote(level)
}
const oldSuccess=new Set(o.successStatuses??[200]),newSuccess=new Set(n.successStatuses??[200])
for(const status of oldSuccess)if(!newSuccess.has(status)){changes.push({level:'major',kind:'api',change:'success-status-removed',key:id,from:status});promote('major')}
}
for(const [id] of newApi)if(!oldApi.has(id)){changes.push({level:'minor',kind:'api',change:'added',key:id});promote('minor')}
const oldProblems=new Set(prev.problems.types??[]),newProblems=new Set(curr.problems.types??[])
for(const type of oldProblems)if(!newProblems.has(type)){changes.push({level:'major',kind:'problem',change:'removed',key:type});promote('major')}
for(const type of newProblems)if(!oldProblems.has(type)){changes.push({level:'minor',kind:'problem',change:'added',key:type});promote('minor')}
const perm=diffNamed('permission',prev.permissions.permissions??[],curr.permissions.permissions??[],'id')
for(const [id,n] of perm.newMap){const o=perm.oldMap.get(id);if(!o)continue;for(const prop of ['module','resource','action'])if((o[prop]??null)!==(n[prop]??null)){changes.push({level:'major',kind:'permission',change:prop,key:id,from:o[prop],to:n[prop]});promote('major')}}
const sp=diffNamed('sensitive-policy',prev.sensitive.policies??[],curr.sensitive.policies??[],'id')
for(const [id,n] of sp.newMap){const o=sp.oldMap.get(id);if(!o)continue;for(const prop of ['viewPermission','revealPermission','unmaskedExportPermission','aiExposure','telemetryExposure'])if((o[prop]??null)!==(n[prop]??null)){changes.push({level:'major',kind:'sensitive-policy',change:prop,key:id,from:o[prop],to:n[prop]});promote('major')}if(JSON.stringify(o.fields??[])!==JSON.stringify(n.fields??[])){changes.push({level:'major',kind:'sensitive-policy',change:'fields',key:id});promote('major')}}
const te=diffNamed('telemetry-event',prev.telemetry.events??[],curr.telemetry.events??[],'name')
for(const [name,n] of te.newMap){const o=te.oldMap.get(name);if(!o)continue;if(o.category!==n.category||JSON.stringify(o.allowedAttributes??[])!==JSON.stringify(n.allowedAttributes??[])||Boolean(o.requiresDuration)!==Boolean(n.requiresDuration)){changes.push({level:'major',kind:'telemetry-event',change:'contract',key:name});promote('major')}}
const tm=diffNamed('ux-metric',prev.telemetry.metrics??[],curr.telemetry.metrics??[],'key')
for(const [key,n] of tm.newMap){const o=tm.oldMap.get(key);if(!o)continue;if(JSON.stringify(o)!==JSON.stringify(n)){changes.push({level:'major',kind:'ux-metric',change:'formula',key});promote('major')}}
const ef=diffNamed('feature-flag',prev.experiments.featureFlags??[],curr.experiments.featureFlags??[],'id')
for(const [id,n] of ef.newMap){const o=ef.oldMap.get(id);if(!o)continue;if(o.screenId!==n.screenId||o.surface!==n.surface){changes.push({level:'major',kind:'feature-flag',change:'scope',key:id});promote('major')}}
const ex=diffNamed('experiment',prev.experiments.experiments??[],curr.experiments.experiments??[],'id')
for(const [id,n] of ex.newMap){const o=ex.oldMap.get(id);if(!o)continue;if(o.screenId!==n.screenId||o.flagId!==n.flagId){changes.push({level:'major',kind:'experiment',change:'scope',key:id});promote('major')}}
const intg=diffNamed('integration',prev.integrations.integrations??[],curr.integrations.integrations??[],'id')
for(const [id,n] of intg.newMap){
const o=intg.oldMap.get(id); if(!o) continue
for(const prop of ['direction','transport','delivery','ordering','idempotency','terminalAction']){
if((o[prop]??null)!==(n[prop]??null)){changes.push({level:'major',kind:'integration',change:prop,key:id,from:o[prop],to:n[prop]});promote('major')}
}
if(o.timeoutMs!==n.timeoutMs || JSON.stringify(o.shortRetry)!==JSON.stringify(n.shortRetry) || JSON.stringify(o.longRetry)!==JSON.stringify(n.longRetry) || JSON.stringify(o.circuitBreaker)!==JSON.stringify(n.circuitBreaker)){
changes.push({level:'patch',risk:'resilience-policy',kind:'integration',change:'resilience-policy',key:id});promote('patch')
}
}
const providers=diffNamed('external-provider',prev.providers.providers??[],curr.providers.providers??[],'id')
for(const [id,n] of providers.newMap){
const o=providers.oldMap.get(id); if(!o) continue
for(const prop of ['purpose','mutationAllowed']){if(JSON.stringify(o[prop]??null)!==JSON.stringify(n[prop]??null)){changes.push({level:'major',kind:'external-provider',change:prop,key:id,from:o[prop],to:n[prop]});promote('major')}}
if(JSON.stringify(o.official)!==JSON.stringify(n.official)){changes.push({level:'patch',risk:'provider-contract',kind:'external-provider',change:'official-facts',key:id});promote('patch')}
if(JSON.stringify(o.kbxPolicy)!==JSON.stringify(n.kbxPolicy)){changes.push({level:'patch',risk:'resilience-policy',kind:'external-provider',change:'kbx-policy',key:id});promote('patch')}
}
const datasets=diffNamed('external-dataset',prev.externalData.datasets??[],curr.externalData.datasets??[],'id')
for(const [id,n] of datasets.newMap){
const o=datasets.oldMap.get(id); if(!o) continue
for(const prop of ['providerId','providerOperationId','canonicalType','normalizer']){
if((o[prop]??null)!==(n[prop]??null)){changes.push({level:'major',kind:'external-dataset',change:prop,key:id,from:o[prop]??null,to:n[prop]??null});promote('major')}
}
if(o.normalizerVersion!==n.normalizerVersion){changes.push({level:'patch',risk:'data-normalization',kind:'external-dataset',change:'normalizer-version',key:id,from:o.normalizerVersion,to:n.normalizerVersion});promote('patch')}
if(JSON.stringify(o.freshness)!==JSON.stringify(n.freshness)){changes.push({level:'patch',risk:'freshness-policy',kind:'external-dataset',change:'freshness-policy',key:id});promote('patch')}
if(JSON.stringify(o.snapshot)!==JSON.stringify(n.snapshot)){changes.push({level:'patch',risk:'retention-policy',kind:'external-dataset',change:'snapshot-policy',key:id});promote('patch')}
}
const configSettings=diffNamed('configuration-setting',prev.configuration.settings??[],curr.configuration.settings??[],'key')
for(const [key,n] of configSettings.newMap){
const o=configSettings.oldMap.get(key);if(!o)continue
for(const prop of ['type','secret']){
if(JSON.stringify(o[prop]??null)!==JSON.stringify(n[prop]??null)){changes.push({level:'major',kind:'configuration-setting',change:prop,key,from:o[prop]??null,to:n[prop]??null});promote('major')}
}
if(JSON.stringify(o.allowedValues??[])!==JSON.stringify(n.allowedValues??[])){
const removed=(o.allowedValues??[]).some(x=>!(n.allowedValues??[]).includes(x))
const level=removed?'major':'minor';changes.push({level,kind:'configuration-setting',change:'allowed-values',key});promote(level)
}
if(JSON.stringify(o.requiredIn??[])!==JSON.stringify(n.requiredIn??[]) || JSON.stringify(o.requiredWhen??null)!==JSON.stringify(n.requiredWhen??null)){
changes.push({level:'minor',risk:'deployment-config',kind:'configuration-setting',change:'requirement',key});promote('minor')
}
if(o.default!==n.default){changes.push({level:'patch',risk:'deployment-config',kind:'configuration-setting',change:'default',key,from:o.default??null,to:n.default??null});promote('patch')}
}
const envProfiles=diffNamed('environment-profile',prev.configuration.environments??[],curr.configuration.environments??[],'id')
for(const [id,n] of envProfiles.newMap){
const o=envProfiles.oldMap.get(id);if(!o)continue
if(JSON.stringify(o)!==JSON.stringify(n)){changes.push({level:'patch',risk:'deployment-policy',kind:'environment-profile',change:'policy',key:id});promote('patch')}
}
const oldTestScenarios=new Map((prev.testing.scenarios??[]).map(x=>[x.id,x]))
const newTestScenarios=new Map((curr.testing.scenarios??[]).map(x=>[x.id,x]))
for(const [id] of oldTestScenarios){if(!newTestScenarios.has(id)){changes.push({level:'patch',kind:'test-scenario',change:'removed',key:id});promote('patch')}}
for(const [id,n] of newTestScenarios){
const o=oldTestScenarios.get(id)
if(!o){changes.push({level:'patch',kind:'test-scenario',change:'added',key:id});promote('patch');continue}
const oldShape=JSON.stringify({screenId:o.screenId,kind:o.kind,apiOperations:o.apiOperations,requiredPermissions:o.requiredPermissions,isolation:o.isolation})
const newShape=JSON.stringify({screenId:n.screenId,kind:n.kind,apiOperations:n.apiOperations,requiredPermissions:n.requiredPermissions,isolation:n.isolation})
if(oldShape!==newShape){changes.push({level:'patch',kind:'test-scenario',change:'contract',key:id});promote('patch')}
}
const declared=release.declaredChangeLevel
const ok=levels[declared]>=levels[required]
const impact={baseline:release.baseline,contractVersion:release.contractVersion,declaredChangeLevel:declared,requiredChangeLevel:required,compatible:ok,changes}
fs.writeFileSync('generated/release-impact.json',JSON.stringify(impact,null,2)+'\n')
const md=['# KBX Release Impact','',`- Baseline: ${release.baseline}`,`- Contract version: ${release.contractVersion}`,`- Declared level: ${declared}`,`- Required level: ${required}`,`- Compatible: ${ok?'YES':'NO'}`,'','## Tracked contract changes','']
if(!changes.length)md.push('- Public Screen/Component/Token/Field contract change 없음.')
else for(const x of changes)md.push(`- **${x.level}** ${x.kind} ${x.change}: \`${x.key}\`${x.from!==undefined?` (${JSON.stringify(x.from)}${JSON.stringify(x.to)})`:''}${x.risk?`${x.risk}`:''}`)
md.push('','## Governance additions','', '- Canonical permission catalog + Screen/Command/Workflow/API parity', '- Sensitive data policy + audited disclosure', '- AI effective-permission intersection', '- KbxMaskedValue masking UX', '- Permission/Sensitive-policy release compatibility analysis', '- Semantic UX telemetry + Manual Intervention Rate governance', '- Safe UX experiment + controlled rollout + kill-switch governance', '- Deterministic synthetic fixture + executable scenario governance', '- External integration + resilience governance', '- Official-source external provider adapters + provider contract governance', '- External data normalization + provenance + freshness governance', '- Deployment configuration + environment promotion governance')
fs.writeFileSync('generated/release-notes.md',md.join('\n')+'\n')
if(!ok){console.error(`declared change level ${declared} is lower than required ${required}`);process.exit(1)}
console.log(`release impact PASS: required=${required}, declared=${declared}, tracked changes=${changes.length}`)
@@ -0,0 +1,95 @@
import fs from 'node:fs'
import path from 'node:path'
const args = Object.fromEntries(process.argv.slice(2).flatMap((value, index, all) => {
if (!value.startsWith('--')) return []
const key = value.slice(2)
const next = all[index + 1]
return [[key, next && !next.startsWith('--') ? next : 'true']]
}))
const required = ['module', 'area', 'number', 'type', 'name', 'path', 'permission']
for (const key of required) if (!args[key]) throw new Error(`missing --${key}`)
const moduleName = args.module.toUpperCase()
const area = args.area.toUpperCase()
const number = String(args.number).padStart(3, '0')
const type = args.type
const title = args.name
const screenId = `${moduleName}-${area}-${number}`
const allowedModules = new Set(['OMS', 'ERP', 'WMS', 'COMMON'])
const allowedTypes = new Set(['list','master','transaction','fast-entry','master-detail','queue','reconcile','import','wms-mobile'])
if (!allowedModules.has(moduleName)) throw new Error(`invalid module: ${moduleName}`)
if (!allowedTypes.has(type)) throw new Error(`invalid type: ${type}`)
if (!/^\d{3}$/.test(number)) throw new Error('number must resolve to 3 digits')
const root = process.env.KBX_ROOT ? path.resolve(process.env.KBX_ROOT) : process.cwd()
const recipeContract=JSON.parse(fs.readFileSync(path.join(process.cwd(),'contracts/screens/kbx.screen-recipes.json'),'utf8'))
const recipe=recipeContract.recipes.find(item=>item.type===type)
if(!recipe)throw new Error(`screen recipe missing for type ${type}`)
const needsWrite=recipe.defaultCommands.some(command=>command.permissionKind==='write')
if(needsWrite&&!args['write-permission'])throw new Error(`--write-permission is required for ${type} screens; mutating commands must never be scaffolded without an explicit permission`)
if (path.isAbsolute(args.path) || args.path.split(/[\\/]/).includes('..')) throw new Error('--path must be a safe relative module path')
const modulesRoot = path.join(root, 'apps/web/src/modules')
const target = path.resolve(modulesRoot, args.path)
if (!target.startsWith(path.resolve(modulesRoot) + path.sep)) throw new Error('--path escapes modules root')
if (fs.existsSync(target) && fs.readdirSync(target).length > 0) throw new Error(`target already exists and is not empty: ${target}`)
fs.mkdirSync(target, { recursive: true })
const token = `${moduleName.toLowerCase()}${area.charAt(0).toUpperCase()}${area.slice(1).toLowerCase()}${number}`
const screenVar = `${token}Screen`
const pageName = `${moduleName.charAt(0)}${moduleName.slice(1).toLowerCase()}${area.charAt(0)}${area.slice(1).toLowerCase()}${number}Page`
const definitionFile = `${token}.definition.ts`
const pageFile = `${pageName}.vue`
const q=value=>`'${String(value).replaceAll('\\','\\\\').replaceAll("'","\\'")}'`
const commandLines=recipe.defaultCommands.map(command=>{
const permission=command.permissionKind==='write'?args['write-permission']:(command.permissionKind==='execute'?args.permission:null)
const fields=[`id:${q(command.id)}`,`label:${q(command.label)}`,`group:${q(command.group)}`]
if(command.shortcut)fields.push(`shortcut:${q(command.shortcut)}`)
if(command.variant)fields.push(`variant:${q(command.variant)}`)
if(permission)fields.push(`permission:${q(permission)}`)
return ` { ${fields.join(', ')} },`
})
fs.writeFileSync(path.join(target, definitionFile), `import { defineKbxScreen } from '@kbx/ui'\n\nexport const ${screenVar} = defineKbxScreen({\n id: '${screenId}',\n version: '1.0.0',\n module: '${moduleName}',\n type: '${type}',\n templateCode: '${recipe.code}',\n title: '${title}',\n helpKey: '${screenId}',\n permissions: ['${args.permission}'],\n commands: [\n${commandLines.join('\n')}\n ],\n telemetry: { enabled: true },\n})\n`)
const blueprint = {
list:{component:'KbxListPage',imports:['KbxSearchPanel','KbxDataGrid','KbxSummaryBar'],setup:`const search=reactive<Record<string,unknown>>({})\nconst searchFields:KbxSearchField[]=[]\nconst rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` <template #search><KbxSearchPanel v-model="search" :fields="searchFields" @search="execute('search')" /></template>\n <template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="id" /></template>\n <template #summary><KbxSummaryBar :items="summaryItems" /></template>`},
master:{component:'KbxMasterPage',imports:['KbxDataGrid','KbxFormGrid','KbxFormSection'],setup:`const rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]`,slots:` <template #list><KbxDataGrid :rows="rows" :columns="columns" row-key="id" /></template>\n <template #detail><KbxFormSection title="기본정보"><KbxFormGrid><div>TODO: 업무 필드</div></KbxFormGrid></KbxFormSection></template>`},
transaction:{component:'KbxTransactionPage',imports:['KbxDataGrid','KbxFormGrid','KbxFormSection','KbxSummaryBar'],setup:`const rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` <template #header><KbxFormSection title="기본정보"><KbxFormGrid><div>TODO: Header 업무 필드</div></KbxFormGrid></KbxFormSection></template>\n <template #detail><KbxDataGrid :rows="rows" :columns="columns" row-key="clientId" editable /></template>\n <template #summary><KbxSummaryBar :items="summaryItems" align="end" /></template>`},
'fast-entry':{component:'KbxFastEntryPage',imports:['KbxDataGrid','KbxValidationSummary','KbxSummaryBar'],setup:`const rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]\nconst errors:KbxValidationError[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` <template #guide><span>Enter 다음 셀 · F2 조회 · Ctrl+V 붙여넣기</span></template>\n <template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="clientId" editable /></template>\n <template #validation><KbxValidationSummary :errors="errors" /></template>\n <template #summary><KbxSummaryBar :items="summaryItems" /></template>`},
'master-detail':{component:'KbxMasterDetailPage',imports:['KbxSearchPanel','KbxDataGrid'],setup:`const search=reactive<Record<string,unknown>>({})\nconst searchFields:KbxSearchField[]=[]\nconst rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]`,slots:` <template #search><KbxSearchPanel v-model="search" :fields="searchFields" @search="execute('search')" /></template>\n <template #master><KbxDataGrid :rows="rows" :columns="columns" row-key="id" /></template>\n <template #detail><KbxDataGrid :rows="[]" :columns="columns" row-key="id" /></template>`},
queue:{component:'KbxQueuePage',imports:['KbxDataGrid','KbxExceptionSummary','KbxSummaryBar'],setup:`const rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]\nconst counters:KbxWorkQueueCounter[]=[]\nconst summaryItems:KbxSummaryItem[]=[]`,slots:` <template #summary><KbxSummaryBar :items="summaryItems" /></template>\n <template #exceptions><KbxExceptionSummary :counters="counters" /></template>\n <template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="id" /></template>`},
reconcile:{component:'KbxReconcilePage',imports:['KbxSearchPanel','KbxDataGrid'],setup:`const search=reactive<Record<string,unknown>>({})\nconst searchFields:KbxSearchField[]=[]\nconst rows:Record<string,unknown>[]=[]\nconst columns:KbxGridColumn<Record<string,unknown>>[]=[]`,slots:` <template #search><KbxSearchPanel v-model="search" :fields="searchFields" @search="execute('search')" /></template>\n <template #content><KbxDataGrid :rows="rows" :columns="columns" row-key="id" /></template>\n <template #resolution><span>TODO: 권한이 명시된 해결 Action</span></template>`},
import:{component:'KbxImportPage',imports:['KbxProgressSteps','KbxExcelImport'],setup:`const steps=[{key:'file',label:'파일'},{key:'mapping',label:'매핑'},{key:'validation',label:'검증'},{key:'commit',label:'반영'}]`,slots:` <template #steps><KbxProgressSteps :steps="steps" active-key="file" /></template>\n <KbxExcelImport />\n <template #result><span>TODO: 반영 결과</span></template>`},
'wms-mobile':{component:'KbxWmsMobilePage',imports:['KbxBarcodeCapture','KbxNetworkIndicator','KbxWmsActionButton'],extraProps:' :online="true"',setup:'',slots:` <KbxBarcodeCapture />\n <template #actions><KbxWmsActionButton label="작업 실행" /></template>`},
}[type]
const imports=[blueprint.component,...blueprint.imports]
const needsReactive=blueprint.setup.includes('reactive<')
const contractTypes=[]
for(const typeName of ['KbxSearchField','KbxGridColumn','KbxSummaryItem','KbxValidationError','KbxWorkQueueCounter'])if(blueprint.setup.includes(typeName))contractTypes.push(typeName)
const typeImport=contractTypes.length?`\nimport type { ${contractTypes.join(', ')} } from '@kbx/contracts'`:''
const vueImport=needsReactive?"\nimport { reactive } from 'vue'":''
fs.writeFileSync(path.join(target, pageFile), `<script setup lang="ts">\nimport { ${imports.join(', ')} } from '@kbx/ui'${typeImport}${vueImport}\nimport { ${screenVar} } from './${definitionFile.replace(/\.ts$/, '')}'\n\n${blueprint.setup}\nfunction execute(commandId:string){ void commandId /* TODO: connect business-specific orchestration only. */ }\n</script>\n\n<template>\n <${blueprint.component} :screen="${screenVar}"${blueprint.extraProps??''} @command="execute">\n${blueprint.slots}\n </${blueprint.component}>\n</template>\n`)
fs.writeFileSync(path.join(target, 'routes.ts'), `export const route = {\n path: '/${args.path.replaceAll('\\\\','/').replaceAll('\\','/')}',\n name: '${screenId}',\n component: () => import('./${pageFile}'),\n meta: { screenId: '${screenId}', permission: '${args.permission}' },\n}\n`)
const testPlanVar=`${token}TestPlan`
fs.writeFileSync(path.join(target, `${token}.test-plan.ts`), `import type { KbxGeneratedScreenTestPlan } from '@kbx/contracts'\n\nexport const ${testPlanVar} = {\n screenId: '${screenId}',\n templateCode: '${recipe.code}',\n canonicalScenarioIds: ${JSON.stringify(recipe.canonicalScenarioIds)},\n requiredChecks: ${JSON.stringify(recipe.testProfile.requiredChecks)},\n requiredEvidence: ${JSON.stringify(recipe.testProfile.requiredEvidence)},\n} as const satisfies KbxGeneratedScreenTestPlan\n`)
const lines=[`# ${screenId} ${title}`,'',`Generated by KBX scaffolder using ${recipe.code} ${type} recipe.`,'','## Recipe policies','',...recipe.requiredPolicies.map(x=>`- Data/process: \`${x}\``),...recipe.recoveryPolicies.map(x=>`- Recovery: \`${x}\``),...recipe.securityPolicies.map(x=>`- Security: \`${x}\``),'','## Canonical regression scenarios','',...recipe.canonicalScenarioIds.map(x=>`- \`${x}\``),'','## Must complete before merge','','- Replace TODO business fields/data only; keep the canonical KBX template/component composition.','- Bind server query/command contracts; do not move business truth into the client.','- Keep Loading / Empty / Error / Refreshing and permission behavior explicit.','- Use the generated `*.test-plan.ts` as the minimum verification contract, then add screen-specific Vitest/Playwright evidence.','- Run `node scripts/validate-kbx.mjs` before merge.','']
fs.writeFileSync(path.join(target,'README.md'),lines.join('\n'))
const helpFile = path.join(root, 'apps/web/src/help/helpRegistry.ts')
let help = fs.readFileSync(helpFile, 'utf8')
if (!help.includes(`'${screenId}'`)) {
const insert = ` '${screenId}': {\n key: '${screenId}', title: '${title}', purpose: 'TODO: 이 화면의 업무 목적을 한 문장으로 작성하세요.',\n steps: ['TODO: 사용자가 실제로 수행하는 첫 번째 업무 단계를 작성하세요.'],\n },\n`
const pos = help.lastIndexOf('}')
help = help.slice(0, pos) + insert + help.slice(pos)
fs.writeFileSync(helpFile, help)
}
console.log(`created ${screenId} at ${path.relative(root, target)}`)
console.log(`recipe ${recipe.code} ${type} -> ${recipe.templateComponent}`)
console.log(`canonical scenarios: ${recipe.canonicalScenarioIds.join(', ')}`)
console.log('run: node scripts/generate-app-screen-registry.mjs && node scripts/validate-kbx.mjs')
@@ -0,0 +1,93 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
const sourcePath = 'contracts/api/kbx.api.json'
const sourceText = fs.readFileSync(sourcePath, 'utf8')
const source = JSON.parse(sourceText)
const sha = crypto.createHash('sha256').update(sourceText).digest('hex')
const operations = source.operations ?? []
const ids = new Set()
const routes = new Set()
for (const op of operations) {
if (!op.id || !op.method || !op.path) throw new Error(`Invalid API operation: ${JSON.stringify(op)}`)
if (ids.has(op.id)) throw new Error(`Duplicate operation id: ${op.id}`)
ids.add(op.id)
const routeKey = `${op.method} ${op.path}`
if (routes.has(routeKey)) throw new Error(`Duplicate API route: ${routeKey}`)
routes.add(routeKey)
if (!['GET','POST','PUT','PATCH','DELETE'].includes(op.method)) throw new Error(`Unsupported method: ${op.method}`)
if (op.idempotency === 'required' && op.method === 'GET') throw new Error(`GET cannot require idempotency: ${op.id}`)
}
const manifest = {
schemaVersion: source.schemaVersion,
apiVersion: source.apiVersion,
sourceSha256: sha,
operationCount: operations.length,
operations: operations.map(({ source: _source, ...op }) => op),
}
fs.mkdirSync('generated', { recursive: true })
fs.writeFileSync('generated/api-manifest.json', JSON.stringify(manifest, null, 2) + '\n')
const problemSchemaText = fs.readFileSync('contracts/problems/kbx.problem.schema.json','utf8')
const problemSchemaSha = crypto.createHash('sha256').update(problemSchemaText).digest('hex')
const problemSchema = JSON.parse(problemSchemaText)
const problemTypes = ['validation','businessRule','conflict','permission','notFound','integration','system']
.map(key => problemSchema.$defs?.[key]?.allOf?.[1]?.properties?.type?.const)
.filter(Boolean)
fs.writeFileSync('generated/problem-manifest.json', JSON.stringify({
schemaVersion: '1.0', sourceSha256: problemSchemaSha, types: problemTypes,
}, null, 2) + '\n')
const tsOps = operations.map(op => ` ${JSON.stringify(op.id)}: ${JSON.stringify({
id: op.id, method: op.method, path: op.path, permission: op.permission ?? null, kind: op.kind,
idempotency: op.idempotency, successStatuses: op.successStatuses ?? [200],
...(op.contentType ? { contentType: op.contentType } : {}),
...(op.responseType ? { responseType: op.responseType } : {}),
})},`).join('\n')
const ts = `// Generated from ${sourcePath}. Do not edit.\nimport type { KbxApiOperationDefinition } from '../api'\n\nexport const kbxApiSourceSha256 = '${sha}' as const\nexport const kbxApiCatalog = {\n${tsOps}\n} as const satisfies Record<string, KbxApiOperationDefinition>\n\nexport type KbxApiOperationId = keyof typeof kbxApiCatalog\nexport type KbxApiOperation = (typeof kbxApiCatalog)[KbxApiOperationId]\n`
fs.mkdirSync('packages/kbx-contracts/src/generated', { recursive: true })
fs.writeFileSync('packages/kbx-contracts/src/generated/apiCatalog.ts', ts)
const csItems = operations.map(op => ` new(${JSON.stringify(op.id)}, ${JSON.stringify(op.method)}, ${JSON.stringify(op.path)}, ${op.permission ? JSON.stringify(op.permission) : 'null'}, ${JSON.stringify(op.kind)}, ${JSON.stringify(op.idempotency)}, new[] { ${(op.successStatuses ?? [200]).join(', ')} })`).join(',\n')
const cs = `// Generated from ${sourcePath}. Do not edit.\nnamespace Shared.Contracts.Generated;\n\npublic sealed record KbxApiOperationContract(\n string Id, string Method, string Path, string? Permission, string Kind, string Idempotency, IReadOnlyList<int> SuccessStatuses);\n\npublic static class KbxApiCatalog\n{\n public const string SourceSha256 = "${sha}";\n public static readonly IReadOnlyList<KbxApiOperationContract> All = new KbxApiOperationContract[]\n {\n${csItems}\n };\n}\n`
fs.mkdirSync('backend/Shared/Contracts/Generated', { recursive: true })
fs.writeFileSync('backend/Shared/Contracts/Generated/KbxApiCatalog.g.cs', cs)
const paths = {}
for (const op of operations) {
const normalizedPath = op.path.replace(/\{([^}:]+):[^}]+\}/g, '{$1}')
const method = op.method.toLowerCase()
paths[normalizedPath] ??= {}
const parameters = [...normalizedPath.matchAll(/\{([^}]+)\}/g)].map(m => ({
name: m[1], in: 'path', required: true, schema: { type: 'string' }
}))
paths[normalizedPath][method] = {
operationId: op.id,
tags: [op.id.split('.')[0].toUpperCase()],
parameters,
responses: Object.fromEntries([
...(op.successStatuses ?? [200]).map(status => [String(status), { description: 'Success' }]),
['400', { description: 'Validation problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }],
['403', { description: 'Permission problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }],
['404', { description: 'Not found problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }],
['409', { description: 'Conflict problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }],
['422', { description: 'Business problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }],
['500', { description: 'System problem', content: { 'application/json': { schema: { '$ref': '#/components/schemas/KbxProblem' } } } }],
]),
'x-kbx-permission': op.permission ?? null,
'x-kbx-idempotency': op.idempotency,
'x-kbx-kind': op.kind,
}
}
const openapi = {
openapi: '3.1.0',
info: { title: 'KBX API Contract', version: source.apiVersion },
paths,
components: { schemas: { KbxProblem: problemSchema } },
'x-kbx-source-sha256': sha,
}
fs.writeFileSync('contracts/api/openapi.kbx.json', JSON.stringify(openapi, null, 2) + '\n')
console.log(`API contracts generated: ${operations.length} operations, SHA ${sha.slice(0,12)}`)
@@ -0,0 +1,34 @@
import fs from 'node:fs'
import path from 'node:path'
const root = process.cwd()
const sourceRoot = path.join(root, 'apps/web/src/modules')
const out = path.join(root, 'apps/web/src/registry/screens.generated.ts')
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name)
return entry.isDirectory() ? walk(full) : [full]
})
}
const entries = []
for (const file of walk(sourceRoot).filter(f => /definition\.ts$/.test(f))) {
const text = fs.readFileSync(file, 'utf8')
const match = text.match(/export\s+const\s+(\w+)\s*=\s*defineKbxScreen\s*\(\s*\{/)
if (!match) continue
const body = text.slice(match.index ?? 0)
const id = body.match(/\bid:\s*['"]([^'"]+)['"]/)?.[1]
if (!id) continue
let rel = path.relative(path.dirname(out), file).replaceAll('\\', '/')
if (!rel.startsWith('.')) rel = './' + rel
rel = rel.replace(/\.ts$/, '')
entries.push({ id, exportName: match[1], importPath: rel })
}
entries.sort((a, b) => a.id.localeCompare(b.id))
const imports = entries.map((entry, i) => `import { ${entry.exportName} as screen${i} } from '${entry.importPath}'`).join('\n')
const list = entries.map((_, i) => ` screen${i},`).join('\n')
const output = `// AUTO-GENERATED by scripts/generate-app-screen-registry.mjs. Do not edit.\n${imports}\n\nexport const generatedScreens = [\n${list}\n] as const\n`
fs.writeFileSync(out, output)
console.log(`generated app screen registry with ${entries.length} screens`)
@@ -0,0 +1,13 @@
import fs from 'node:fs'
const src=JSON.parse(fs.readFileSync('contracts/authorization/kbx.authorization.json','utf8'))
fs.mkdirSync('generated',{recursive:true})
fs.writeFileSync('generated/permission-manifest.json',JSON.stringify({schemaVersion:src.schemaVersion,permissionVersion:src.permissionVersion,permissions:src.permissions},null,2)+'\n')
fs.writeFileSync('generated/sensitive-data-manifest.json',JSON.stringify({schemaVersion:src.schemaVersion,policies:src.sensitiveDataPolicies,ai:src.ai},null,2)+'\n')
fs.mkdirSync('packages/kbx-contracts/src/generated',{recursive:true})
const ts=`// generated from contracts/authorization/kbx.authorization.json\nexport const kbxPermissionCatalog = ${JSON.stringify(src.permissions,null,2)} as const\nexport const kbxSensitiveDataPolicies = ${JSON.stringify(src.sensitiveDataPolicies,null,2)} as const\nexport const kbxAiAuthorizationPolicy = ${JSON.stringify(src.ai,null,2)} as const\nexport type KbxPermissionId = typeof kbxPermissionCatalog[number]['id']\n`
fs.writeFileSync('packages/kbx-contracts/src/generated/permissionCatalog.ts',ts)
fs.mkdirSync('backend/Shared/Authorization/Generated',{recursive:true})
const esc=s=>s.replace(/[^A-Za-z0-9]+/g,' ').trim().split(/\s+/).map(x=>x.charAt(0).toUpperCase()+x.slice(1)).join('')
const cs=['// <auto-generated />','namespace KBX.Shared.Authorization.Generated;','','public static class KbxPermissions','{',...src.permissions.map(p=>` public const string ${esc(p.id)} = "${p.id}";`),'}','','public static class KbxSensitivePolicies','{',...src.sensitiveDataPolicies.map(p=>` public const string ${esc(p.id)} = "${p.id}";`),'}',''].join('\n')
fs.writeFileSync('backend/Shared/Authorization/Generated/KbxPermissions.g.cs',cs)
console.log(`authorization contracts generated: permissions=${src.permissions.length}, sensitivePolicies=${src.sensitiveDataPolicies.length}`)
@@ -0,0 +1,18 @@
import fs from 'node:fs'
const source=fs.readFileSync('packages/kbx-ui/src/catalog/componentCatalog.ts','utf8')
const commonInputStates=['default','required','readonly','disabled','error','keyboard']
const entries=[]
for(const line of source.split(/\r?\n/)){
const component=line.match(/component:'([^']+)'/)?.[1]
if(!component) continue
const group=line.match(/group:'([^']+)'/)?.[1] ?? 'unknown'
const inline=[...line.matchAll(/state:'([^']+)'/g)].map(x=>x[1])
const states=line.includes('commonInputStates') ? commonInputStates : inline
entries.push({component,group,scenarioProfile:line.includes('commonInputStates')?'common-input':undefined,states:[...new Set(states)]})
}
const duplicate=entries.filter((x,i,a)=>a.findIndex(y=>y.component===x.component)!==i)
if(duplicate.length){console.error(`duplicate catalog entries: ${duplicate.map(x=>x.component).join(', ')}`);process.exit(1)}
fs.mkdirSync('generated',{recursive:true})
fs.writeFileSync('generated/catalog-manifest.json',JSON.stringify(entries,null,2)+'\n')
console.log(`generated component catalog manifest with ${entries.length} entries`)
@@ -0,0 +1,13 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd()
const src=path.join(root,'packages/kbx-ui/src/registry/componentManifest.ts')
const out=path.join(root,'generated/component-manifest.json')
const text=fs.readFileSync(src,'utf8')
const entries=[...text.matchAll(/\{\s*name:\s*'([^']+)'\s*,\s*category:\s*'([^']+)'\s*,\s*purpose:\s*'([^']+)'[\s\S]*?version:\s*'([^']+)'\s*\}/g)]
.map(m=>({name:m[1],category:m[2],purpose:m[3],version:m[4]}))
const duplicate=entries.filter((x,i,a)=>a.findIndex(y=>y.name===x.name)!==i)
if(duplicate.length){console.error(`duplicate component manifest entries: ${duplicate.map(x=>x.name).join(', ')}`);process.exit(1)}
fs.writeFileSync(out,JSON.stringify(entries,null,2)+'\n')
console.log(`generated ${entries.length} component definitions -> ${path.relative(root,out)}`)
@@ -0,0 +1,88 @@
import fs from 'node:fs'
import crypto from 'node:crypto'
const sourcePath='contracts/configuration/kbx.configuration.json'
const text=fs.readFileSync(sourcePath,'utf8')
const source=JSON.parse(text)
const sha=crypto.createHash('sha256').update(text).digest('hex')
const seenKeys=new Set(),seenEnv=new Set()
for(const s of source.settings??[]){
if(seenKeys.has(s.key))throw new Error(`duplicate configuration key ${s.key}`)
if(seenEnv.has(s.env))throw new Error(`duplicate environment variable ${s.env}`)
seenKeys.add(s.key);seenEnv.add(s.env)
}
const seenProfiles=new Set()
for(const e of source.environments??[]){if(seenProfiles.has(e.id))throw new Error(`duplicate environment profile ${e.id}`);seenProfiles.add(e.id)}
const manifest={
schemaVersion:source.schemaVersion,
contractVersion:source.contractVersion,
sourceSha256:sha,
principles:source.principles,
sourcePrecedence:source.sourcePrecedence,
settingCount:source.settings.length,
environmentCount:source.environments.length,
settings:source.settings,
environments:source.environments
}
fs.mkdirSync('generated',{recursive:true})
fs.writeFileSync('generated/configuration-manifest.json',JSON.stringify(manifest,null,2)+'\n')
const settingCatalog=Object.fromEntries(source.settings.map(s=>[s.key,s]))
const envCatalog=Object.fromEntries(source.environments.map(e=>[e.id,e]))
fs.mkdirSync('packages/kbx-contracts/src/generated',{recursive:true})
fs.writeFileSync('packages/kbx-contracts/src/generated/configurationCatalog.ts',`// generated from ${sourcePath}; do not edit.\nimport type { KbxConfigurationSettingDefinition, KbxEnvironmentProfileDefinition } from '../configuration'\nexport const kbxConfigurationSourceSha256='${sha}' as const\nexport const kbxConfigurationCatalog=${JSON.stringify(settingCatalog,null,2)} as const satisfies Record<string,KbxConfigurationSettingDefinition>\nexport const kbxEnvironmentProfiles=${JSON.stringify(envCatalog,null,2)} as const satisfies Record<string,KbxEnvironmentProfileDefinition>\nexport type KbxConfigurationKey=keyof typeof kbxConfigurationCatalog\nexport type KbxEnvironmentProfile=keyof typeof kbxEnvironmentProfiles\n`)
const esc=s=>String(s).replaceAll('\\','\\\\').replaceAll('"','\\"')
const csDefault=v=>v===undefined?'null':`"${esc(String(v))}"`
const csAllowed=v=>`new string[]{${(v??[]).map(x=>`"${esc(x)}"`).join(',')}}`
const csNullableInt=v=>v===undefined?'null':String(v)
const csSettings=source.settings.map(s=>` ["${esc(s.key)}"] = new("${esc(s.key)}","${esc(s.env)}","${esc(s.category)}","${esc(s.type)}",${s.secret?'true':'false'},${s.restartRequired?'true':'false'},${csDefault(s.default)},${csAllowed(s.allowedValues)},${csAllowed(s.requiredIn)},${s.requiredWhen?`"${esc(s.requiredWhen.key)}"`:'null'},${s.requiredWhen?`"${esc(String(s.requiredWhen.equals))}"`:'null'},${csNullableInt(s.minimum)},${csNullableInt(s.maximum)},${csAllowed(s.allowedSources)})`).join(',\n')
const csProfiles=source.environments.map(e=>` ["${esc(e.id)}"] = new("${esc(e.id)}",${e.artifactPromotion?'true':'false'},"${esc(e.migrationStrategy)}","${esc(e.providerNetwork)}",${e.requireHttps?'true':'false'},${e.providerEndpointOverrideAllowed?'true':'false'},${csAllowed(e.requiredGates)})`).join(',\n')
fs.mkdirSync('backend/Shared/Configuration/Generated',{recursive:true})
fs.writeFileSync('backend/Shared/Configuration/Generated/KbxConfigurationCatalog.g.cs',`// generated from ${sourcePath}; do not edit.\nnamespace Kbx.Shared.Configuration.Generated;\npublic sealed record KbxConfigurationSettingDefinition(string Key,string Env,string Category,string Type,bool Secret,bool RestartRequired,string? DefaultValue,string[] AllowedValues,string[] RequiredIn,string? RequiredWhenKey,string? RequiredWhenEquals,int? Minimum,int? Maximum,string[] AllowedSources);\npublic sealed record KbxEnvironmentProfileDefinition(string Id,bool ArtifactPromotion,string MigrationStrategy,string ProviderNetwork,bool RequireHttps,bool ProviderEndpointOverrideAllowed,string[] RequiredGates);\npublic static class KbxConfigurationCatalog {\n public const string SourceSha256="${sha}";\n public static readonly IReadOnlyDictionary<string,KbxConfigurationSettingDefinition> Settings=new Dictionary<string,KbxConfigurationSettingDefinition>(StringComparer.Ordinal) {\n${csSettings}\n };\n public static readonly IReadOnlyDictionary<string,KbxEnvironmentProfileDefinition> Environments=new Dictionary<string,KbxEnvironmentProfileDefinition>(StringComparer.Ordinal) {\n${csProfiles}\n };\n}\n`)
// Generate a secret-free env template. Secret values are intentionally blank.
const envLines=['# Generated KBX environment variable template. Do not commit real secret values.']
for(const s of source.settings){
const suffix=s.secret?' # secret: inject from environment/secret store':''
const value=s.secret?'':(s.default===undefined?'':String(s.default))
envLines.push(`${s.env}=${value}${suffix}`)
}
fs.mkdirSync('deploy/kbx',{recursive:true})
fs.writeFileSync('deploy/kbx/.env.kbx.example',envLines.join('\n')+'\n')
const publicDefaults={}
for(const s of source.settings){if(!s.secret&&s.default!==undefined)publicDefaults[s.key]=s.default}
fs.writeFileSync('deploy/kbx/nonsecret-defaults.generated.json',JSON.stringify(publicDefaults,null,2)+'\n')
fs.writeFileSync('deploy/kbx/environment-matrix.generated.json',JSON.stringify({sourceSha256:sha,environments:source.environments},null,2)+'\n')
fs.mkdirSync('deploy/kbx/environments',{recursive:true})
const profileOverrides={
Development:{'Kbx:Runtime:Environment':'Development','Kbx:Database:MigrationsMode':'startup-apply','Kbx:Security:RequireHttps':false},
Test:{'Kbx:Runtime:Environment':'Test','Kbx:Database:MigrationsMode':'startup-apply','Kbx:Security:RequireHttps':false,'ExternalProviders:Krx:Enabled':false,'ExternalProviders:OpenDart:Enabled':false,'ExternalProviders:Kis:Enabled':false},
Staging:{'Kbx:Runtime:Environment':'Staging','Kbx:Database:MigrationsMode':'predeploy','Kbx:Security:RequireHttps':true},
Production:{'Kbx:Runtime:Environment':'Production','Kbx:Database:MigrationsMode':'predeploy','Kbx:Security:RequireHttps':true,'ExternalProviders:Kis:Environment':'production'}
}
for(const e of source.environments){
const lines=[`# Generated ${e.id} example. Secret values intentionally blank.`]
const overrides=profileOverrides[e.id]??{}
for(const s of source.settings){
let value=s.secret?'':(Object.hasOwn(overrides,s.key)?String(overrides[s.key]):(s.default===undefined?'':String(s.default)))
lines.push(`${s.env}=${value}${s.secret?' # secret':''}`)
}
fs.writeFileSync(`deploy/kbx/environments/${e.id.toLowerCase()}.env.example`,lines.join('\n')+'\n')
}
fs.writeFileSync('deploy/kbx/release-artifact.contract.json',JSON.stringify({
schemaVersion:'1.0',configurationSourceSha256:sha,immutableArtifact:true,environmentSpecificConfigurationExternal:true,
promotionOrder:['Staging','Production'],
requiredEvidence:['generated/release-impact.json','generated/configuration-manifest.json','generated/test-scenario-manifest.json','generated/api-manifest.json','migration-dry-run-report','configuration-validation-report']
},null,2)+'\n')
const md=['# KBX Configuration Reference','',`Source SHA-256: \`${sha}\``,'',`Settings: **${source.settings.length}** · Environments: **${source.environments.length}**`,'','| Key | Env | Category | Type | Secret | Restart |','|---|---|---|---|---:|---:|']
for(const s of source.settings)md.push(`| \`${s.key}\` | \`${s.env}\` | ${s.category} | ${s.type} | ${s.secret?'yes':'no'} | ${s.restartRequired?'yes':'no'} |`)
md.push('','## Environment profiles','')
for(const e of source.environments)md.push(`### ${e.id}\n- Migration: \`${e.migrationStrategy}\`\n- Provider network: \`${e.providerNetwork}\`\n- HTTPS required: \`${e.requireHttps}\`\n- Endpoint override allowed: \`${e.providerEndpointOverrideAllowed}\`\n- Gates: ${e.requiredGates.map(x=>`\`${x}\``).join(', ')}`)
fs.writeFileSync('docs/configuration-reference.generated.md',md.join('\n')+'\n')
console.log(`configuration contracts generated: settings=${source.settings.length}, environments=${source.environments.length}, SHA ${sha.slice(0,12)}`)
@@ -0,0 +1,63 @@
import fs from 'node:fs'
import path from 'node:path'
const root = process.cwd()
const sourcePath = path.join(root, 'packages/kbx-ui/src/tokens/source/kbx.tokens.json')
const cssPath = path.join(root, 'packages/kbx-ui/src/tokens/kbx.css')
const manifestPath = path.join(root, 'generated/token-manifest.json')
const figmaPath = path.join(root, 'design/figma/variables.contract.json')
const source = JSON.parse(fs.readFileSync(sourcePath, 'utf8'))
const byPath = new Map(source.tokens.map(t => [t.path, t]))
function cssValue(value) {
const ref = /^\{(.+)\}$/.exec(value)
if (!ref) return value
const token = byPath.get(ref[1])
if (!token) throw new Error(`Unknown token reference: ${value}`)
return `var(${token.css})`
}
const roles = ['primitive', 'semantic', 'component']
const lines = [':root {']
for (const role of roles) {
lines.push(` /* ${role[0].toUpperCase()}${role.slice(1)} tokens */`)
for (const token of source.tokens.filter(t => t.role === role)) {
lines.push(` ${token.css}: ${cssValue(token.value)};`)
}
lines.push('')
}
lines.push('}')
lines.push('')
lines.push('html, body, button, input, select, textarea {')
lines.push(' font-family: var(--kbx-font-family);')
lines.push('}')
lines.push('')
for (const [mode, overrides] of Object.entries(source.modes ?? {})) {
if (mode === 'compact') continue
lines.push(`[data-kbx-density="${mode}"] {`)
for (const [tokenPath, value] of Object.entries(overrides)) {
const token = byPath.get(tokenPath)
if (!token) throw new Error(`Unknown mode token: ${tokenPath}`)
lines.push(` ${token.css}: ${value};`)
}
lines.push('}')
lines.push('')
}
lines.push(':focus-visible {')
lines.push(' outline: 2px solid var(--kbx-color-focus);')
lines.push(' outline-offset: 2px;')
lines.push('}')
lines.push('')
fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
fs.mkdirSync(path.dirname(figmaPath), { recursive: true })
fs.writeFileSync(cssPath, lines.join('\n'))
fs.writeFileSync(manifestPath, JSON.stringify({version: source.version, tokens: source.tokens, modes: source.modes}, null, 2) + '\n')
const collections = {
'KBX Primitive': source.tokens.filter(t => t.role === 'primitive').map(t => ({name:t.path, type:t.type, value:t.value, description:t.description ?? ''})),
'KBX Semantic': source.tokens.filter(t => t.role === 'semantic').map(t => ({name:t.path, type:t.type, value:t.value, description:t.description ?? ''})),
'KBX Component': source.tokens.filter(t => t.role === 'component').map(t => ({name:t.path, type:t.type, value:t.value, description:t.description ?? '', modes:Object.fromEntries(Object.entries(source.modes ?? {}).filter(([,o]) => t.path in o).map(([mode,o]) => [mode,o[t.path]]))}))
}
fs.writeFileSync(figmaPath, JSON.stringify({schemaVersion:'1.0', tokenVersion:source.version, collections}, null, 2) + '\n')
console.log(`Generated ${source.tokens.length} design tokens -> CSS / token manifest / Figma variable contract`)
@@ -0,0 +1,35 @@
import fs from 'node:fs'
import crypto from 'node:crypto'
const sourcePath='contracts/experiments/kbx.experiments.json'
const text=fs.readFileSync(sourcePath,'utf8'), src=JSON.parse(text)
const sha=crypto.createHash('sha256').update(text).digest('hex')
const telemetry=JSON.parse(fs.readFileSync('contracts/telemetry/kbx.telemetry.json','utf8'))
const metricKeys=new Set(telemetry.metrics.map(x=>x.key))
const flagIds=new Set(), expIds=new Set(), allowed=new Set(src.principles.allowedSurfaces)
for(const flag of src.featureFlags){
if(flagIds.has(flag.id))throw new Error(`duplicate feature flag: ${flag.id}`);flagIds.add(flag.id)
if(!allowed.has(flag.surface))throw new Error(`unsafe/unknown rollout surface: ${flag.id} -> ${flag.surface}`)
if(flag.killSwitch!==true)throw new Error(`kill switch is required: ${flag.id}`)
}
const runningByScreen=new Map()
for(const exp of src.experiments){
if(expIds.has(exp.id))throw new Error(`duplicate experiment: ${exp.id}`);expIds.add(exp.id)
if(!flagIds.has(exp.flagId))throw new Error(`experiment references unknown flag: ${exp.id}`)
if(exp.rolloutPercent<0||exp.rolloutPercent>100)throw new Error(`invalid rollout percent: ${exp.id}`)
if(exp.minExposurePerVariant<100)throw new Error(`minimum sample is too small for operational decision: ${exp.id}`)
if(exp.variants.filter(x=>x.key==='control').length!==1)throw new Error(`exactly one control variant required: ${exp.id}`)
if(exp.variants.reduce((n,x)=>n+x.weight,0)!==100)throw new Error(`variant weights must total 100: ${exp.id}`)
for(const metric of [exp.primaryMetric,...(exp.guardrails??[]),...(exp.globalGuardrails??[])])if(!metricKeys.has(metric.key))throw new Error(`unknown UX metric ${metric.key} in ${exp.id}`)
if(exp.state==='running')runningByScreen.set(exp.screenId,(runningByScreen.get(exp.screenId)??0)+1)
}
for(const [screen,count] of runningByScreen)if(count>src.principles.maxRunningExperimentsPerScreen)throw new Error(`too many concurrent experiments on ${screen}`)
const manifest={schemaVersion:src.schemaVersion,experimentVersion:src.experimentVersion,sourceSha256:sha,principles:src.principles,featureFlags:src.featureFlags,experiments:src.experiments}
fs.mkdirSync('generated',{recursive:true});fs.writeFileSync('generated/experiment-manifest.json',JSON.stringify(manifest,null,2)+'\n')
const ts=`// generated from ${sourcePath}; do not edit.\nexport const kbxFeatureFlags = ${JSON.stringify(Object.fromEntries(src.featureFlags.map(x=>[x.id,x])),null,2)} as const\nexport const kbxExperiments = ${JSON.stringify(Object.fromEntries(src.experiments.map(x=>[x.id,x])),null,2)} as const\nexport type KbxFeatureFlagId = keyof typeof kbxFeatureFlags\nexport type KbxExperimentId = keyof typeof kbxExperiments\nexport const kbxExperimentSourceSha256 = '${sha}' as const\n`
fs.mkdirSync('packages/kbx-contracts/src/generated',{recursive:true});fs.writeFileSync('packages/kbx-contracts/src/generated/experimentCatalog.ts',ts)
const esc=s=>s.replaceAll('"','\\"')
const csFlags=src.featureFlags.map(x=>` ["${x.id}"] = new("${x.id}", "${x.screenId}", "${x.surface}", "${x.defaultVariant}", ${x.killSwitch?'true':'false'})`).join(',\n')
const csExps=src.experiments.map(x=>` ["${x.id}"] = new("${x.id}", "${x.flagId}", "${x.screenId}", "${x.state}", ${x.rolloutPercent}, ${x.minExposurePerVariant}, new[] { ${x.variants.map(v=>`new KbxVariantWeight("${v.key}", ${v.weight})`).join(', ')} }, "${x.primaryMetric.key}", "${x.primaryMetric.direction}", ${x.primaryMetric.minimumImprovementPercent})`).join(',\n')
const cs=`// generated from ${sourcePath}; do not edit.\nnamespace Kbx.Shared.Experiments.Generated;\n\npublic sealed record KbxFeatureFlagDefinition(string Id,string ScreenId,string Surface,string DefaultVariant,bool KillSwitch);\npublic sealed record KbxVariantWeight(string Key,int Weight);\npublic sealed record KbxExperimentDefinition(string Id,string FlagId,string ScreenId,string State,int RolloutPercent,int MinExposurePerVariant,IReadOnlyList<KbxVariantWeight> Variants,string PrimaryMetric,string PrimaryDirection,double MinimumImprovementPercent);\npublic static class KbxExperimentCatalog\n{\n public const string SourceSha256 = "${sha}";\n public static readonly IReadOnlyDictionary<string,KbxFeatureFlagDefinition> FeatureFlags = new Dictionary<string,KbxFeatureFlagDefinition>(StringComparer.Ordinal)\n {\n${csFlags}\n };\n public static readonly IReadOnlyDictionary<string,KbxExperimentDefinition> Experiments = new Dictionary<string,KbxExperimentDefinition>(StringComparer.Ordinal)\n {\n${csExps}\n };\n}\n`
fs.mkdirSync('backend/Shared/Experiments/Generated',{recursive:true});fs.writeFileSync('backend/Shared/Experiments/Generated/KbxExperimentCatalog.g.cs',cs)
console.log(`generated experiment contracts: flags=${src.featureFlags.length}, experiments=${src.experiments.length}`)
@@ -0,0 +1,18 @@
import fs from 'node:fs'
import crypto from 'node:crypto'
const sourcePath='contracts/external-data/kbx.external-data.json'
const text=fs.readFileSync(sourcePath,'utf8')
const source=JSON.parse(text)
const sha=crypto.createHash('sha256').update(text).digest('hex')
const ids=new Set()
for(const d of source.datasets??[]){if(ids.has(d.id))throw new Error(`duplicate external dataset ${d.id}`);ids.add(d.id)}
const manifest={schemaVersion:source.schemaVersion,contractVersion:source.contractVersion,sourceSha256:sha,principles:source.principles,datasets:source.datasets}
fs.mkdirSync('generated',{recursive:true});fs.writeFileSync('generated/external-data-manifest.json',JSON.stringify(manifest,null,2)+'\n')
const catalog=Object.fromEntries(source.datasets.map(x=>[x.id,x]))
fs.mkdirSync('packages/kbx-contracts/src/generated',{recursive:true})
fs.writeFileSync('packages/kbx-contracts/src/generated/externalDataCatalog.ts',`// generated from ${sourcePath}; do not edit.\nimport type { KbxExternalDataDatasetDefinition } from '../externalData'\nexport const kbxExternalDataSourceSha256='${sha}' as const\nexport const kbxExternalDataCatalog=${JSON.stringify(catalog,null,2)} as const satisfies Record<string,KbxExternalDataDatasetDefinition>\nexport type KbxExternalDataDatasetId=keyof typeof kbxExternalDataCatalog\n`)
const esc=s=>String(s).replaceAll('\\','\\\\').replaceAll('"','\\"')
fs.mkdirSync('backend/Shared/ExternalData/Generated',{recursive:true})
const rows=source.datasets.map(d=>` ["${esc(d.id)}"] = new("${esc(d.id)}", "${esc(d.providerId)}", "${esc(d.providerOperationId)}", "${esc(d.canonicalType)}", "${esc(d.normalizer)}", "${esc(d.normalizerVersion)}", "${esc(d.freshness.mode)}", ${d.freshness.freshForSeconds??'null'}, ${d.freshness.maxStaleSeconds??'null'}, ${d.freshness.backgroundRefresh?'true':'false'}, "${esc(d.snapshot.rawRetention)}", ${d.snapshot.normalizedRetentionDays}, "${esc(d.ui.sourceLabel)}")`).join(',\n')
fs.writeFileSync('backend/Shared/ExternalData/Generated/KbxExternalDataCatalog.g.cs',`// generated from ${sourcePath}; do not edit.\nnamespace Kbx.Shared.ExternalData.Generated;\npublic sealed record KbxExternalDataDatasetDefinition(string Id,string ProviderId,string ProviderOperationId,string CanonicalType,string Normalizer,string NormalizerVersion,string FreshnessMode,int? FreshForSeconds,int? MaxStaleSeconds,bool BackgroundRefresh,string RawRetention,int NormalizedRetentionDays,string SourceLabel);\npublic static class KbxExternalDataCatalog { public const string SourceSha256="${sha}"; public static readonly IReadOnlyDictionary<string,KbxExternalDataDatasetDefinition> All=new Dictionary<string,KbxExternalDataDatasetDefinition>(StringComparer.Ordinal) {\n${rows}\n }; }\n`)
console.log(`external-data contracts generated: ${source.datasets.length}, SHA ${sha.slice(0,12)}`)
@@ -0,0 +1,88 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
const sourcePath = 'contracts/fields/kbx.fields.json'
const source = JSON.parse(fs.readFileSync(sourcePath, 'utf8'))
const fields = [...source.fields].sort((a, b) => a.key.localeCompare(b.key))
const hash = crypto.createHash('sha256').update(JSON.stringify(source)).digest('hex')
const tsDir = 'packages/kbx-contracts/src/generated'
const csDir = 'backend/Shared/Contracts/Generated'
fs.mkdirSync(tsDir, { recursive: true })
fs.mkdirSync(csDir, { recursive: true })
fs.mkdirSync('generated', { recursive: true })
const q = value => JSON.stringify(value)
const ts = [
'// GENERATED FILE. DO NOT EDIT.',
`// Source: ${sourcePath}`,
`// Source SHA256: ${hash}`,
'',
`export const kbxFieldDictionaryVersion = ${q(source.version)} as const`,
'',
'export const kbxFieldCatalog = {',
...fields.map(f => ` ${q(f.key)}: ${JSON.stringify(f)},`),
'} as const',
'',
'export type KbxKnownFieldKey = keyof typeof kbxFieldCatalog',
'export type KbxKnownFieldDefinition = (typeof kbxFieldCatalog)[KbxKnownFieldKey]',
'',
'export function getKbxField<K extends KbxKnownFieldKey>(key: K): (typeof kbxFieldCatalog)[K] {',
' return kbxFieldCatalog[key]',
'}',
'',
].join('\n')
fs.writeFileSync(path.join(tsDir, 'fieldCatalog.ts'), ts)
const csString = value => value == null ? 'null' : `"${String(value).replaceAll('\\','\\\\').replaceAll('"','\\"')}"`
const csBool = value => value ? 'true' : 'false'
const pascal = s => s[0].toUpperCase() + s.slice(1)
const cs = [
'// <auto-generated />',
`// Source: ${sourcePath}`,
`// Source SHA256: ${hash}`,
'namespace Kbx.Contracts.Generated;',
'',
'public sealed record KbxFieldMetadata(',
' string Key, string Label, string DataType, IReadOnlyList<string> Aliases,',
' bool Required, int? MaxLength, int? Precision, int? Scale, string? LookupEntity,',
' bool Readonly, bool Importable, bool Exportable, bool Sensitive, string? Masking);',
'',
'public static class KbxFieldKeys',
'{',
...fields.map(f => ` public const string ${pascal(f.key)} = "${f.key}";`),
'}',
'',
'public static class KbxFieldCatalog',
'{',
` public const string Version = "${source.version}";`,
' private static readonly IReadOnlyDictionary<string, KbxFieldMetadata> Items =',
' new Dictionary<string, KbxFieldMetadata>(StringComparer.Ordinal)',
' {',
...fields.map(f => {
const aliases = (f.aliases ?? []).map(csString).join(', ')
return ` ["${f.key}"] = new("${f.key}", ${csString(f.label)}, "${f.dataType}", new[] { ${aliases} }, ${csBool(Boolean(f.required))}, ${f.maxLength ?? 'null'}, ${f.precision ?? 'null'}, ${f.scale ?? 'null'}, ${csString(f.lookupEntity)}, ${csBool(Boolean(f.readonly))}, ${csBool(Boolean(f.importable))}, ${csBool(Boolean(f.exportable))}, ${csBool(Boolean(f.sensitive))}, ${csString(f.masking)}),`
}),
' };',
'',
' public static KbxFieldMetadata Get(string key) => Items.TryGetValue(key, out var value)',
' ? value',
' : throw new KeyNotFoundException($"Unknown KBX field key: {key}");',
'',
' public static bool TryGet(string key, out KbxFieldMetadata? value) => Items.TryGetValue(key, out value);',
' public static IReadOnlyCollection<KbxFieldMetadata> All => Items.Values;',
'}',
'',
].join('\n')
fs.writeFileSync(path.join(csDir, 'KbxFieldCatalog.g.cs'), cs)
const manifest = {
version: source.version,
source: sourcePath,
sourceSha256: hash,
count: fields.length,
fields,
}
fs.writeFileSync('generated/field-manifest.json', JSON.stringify(manifest, null, 2) + '\n')
console.log(`field contracts generated: ${fields.length} fields, version ${source.version}`)
@@ -0,0 +1,33 @@
import fs from 'node:fs'
const components=JSON.parse(fs.readFileSync('generated/component-manifest.json','utf8'))
const catalog=JSON.parse(fs.readFileSync('generated/catalog-manifest.json','utf8'))
const catalogMap=new Map(catalog.map(x=>[x.component,x]))
const core=new Set([
'KbxButton','KbxInput','KbxNumberField','KbxMoneyField','KbxQuantityField','KbxDateField','KbxDateRange','KbxSelect','KbxLookup',
'KbxPageHeader','KbxCommandBar','KbxSearchPanel','KbxFormSection','KbxDataGrid','KbxBulkActionBar','KbxStatus',
'KbxDialog','KbxDrawer','KbxToast','KbxConfirm','KbxExcelMenu','KbxExcelImport','KbxJobProgress',
'KbxAuditTrail','KbxHelpPanel','KbxAiPanel','KbxProposalPanel','KbxListPage','KbxMasterPage','KbxTransactionPage','KbxMasterDetailPage','KbxQueuePage','KbxReconcilePage','KbxWmsMobilePage'
])
const groupName={primitive:'Primitive',business:'Business',template:'Template',wms:'WMS',shell:'Shell'}
const result={
schemaVersion:'1.0',
namingRule:'KBX/{Category}/{Component}',
componentCount:components.length,
coreComponentCount:core.size,
components:components.map(c=>{
const cat=catalogMap.get(c.name)
return {
codeName:c.name,
figmaName:`KBX/${groupName[c.category] ?? c.category}/${c.name}`,
category:c.category,
version:c.version,
designRequired:core.has(c.name),
designStatus:cat?'cataloged':'code-only',
variants:{state:cat?.states?.length?cat.states:['default'],density:['compact','comfortable',...(c.category==='wms'||c.name==='KbxWmsMobilePage'?['touch']:[])]}
}
})
}
fs.mkdirSync('design/figma',{recursive:true})
fs.writeFileSync('design/figma/components.contract.json',JSON.stringify(result,null,2)+'\n')
console.log(`generated Figma component contract for ${components.length} components (${core.size} core)`)
@@ -0,0 +1,21 @@
import fs from 'node:fs'
import crypto from 'node:crypto'
const sourcePath='contracts/integrations/kbx.integrations.json'
const text=fs.readFileSync(sourcePath,'utf8')
const source=JSON.parse(text)
const sha=crypto.createHash('sha256').update(text).digest('hex')
const ids=new Set()
for(const item of source.integrations??[]){
if(ids.has(item.id)) throw new Error(`duplicate integration id: ${item.id}`)
ids.add(item.id)
}
const manifest={schemaVersion:source.schemaVersion,integrationVersion:source.integrationVersion,sourceSha256:sha,principles:source.principles,states:source.states,integrations:source.integrations}
fs.mkdirSync('generated',{recursive:true});fs.writeFileSync('generated/integration-manifest.json',JSON.stringify(manifest,null,2)+'\n')
const catalog=Object.fromEntries(source.integrations.map(x=>[x.id,x]))
fs.mkdirSync('packages/kbx-contracts/src/generated',{recursive:true})
fs.writeFileSync('packages/kbx-contracts/src/generated/integrationCatalog.ts',`// generated from ${sourcePath}; do not edit.\nimport type { KbxIntegrationDefinition } from '../integration'\nexport const kbxIntegrationSourceSha256 = '${sha}' as const\nexport const kbxIntegrationCatalog = ${JSON.stringify(catalog,null,2)} as const satisfies Record<string,KbxIntegrationDefinition>\nexport type KbxIntegrationId = keyof typeof kbxIntegrationCatalog\n`)
const esc=s=>String(s).replaceAll('\\','\\\\').replaceAll('"','\\"')
const rows=source.integrations.map(x=>` new("${esc(x.id)}", "${esc(x.title)}", "${x.ownerModule}", "${x.direction}", "${x.transport}", "${x.criticality}", "${esc(x.sourceEvent)}", "${esc(x.target)}", "${x.delivery}", "${x.ordering}", "${x.idempotency}", ${x.timeoutMs}, ${x.shortRetry.maxRetryAttempts}, ${x.shortRetry.baseDelayMs}, "${x.shortRetry.backoff}", ${x.longRetry.maxAttempts}, new[] { ${x.longRetry.scheduleSeconds.join(', ')} }, ${x.circuitBreaker.failureRatio}, ${x.circuitBreaker.samplingSeconds}, ${x.circuitBreaker.minimumThroughput}, ${x.circuitBreaker.breakSeconds}, "${x.terminalAction}", ${x.userVisible?'true':'false'})`).join(',\n')
fs.mkdirSync('backend/Shared/Integrations/Generated',{recursive:true})
fs.writeFileSync('backend/Shared/Integrations/Generated/KbxIntegrationCatalog.g.cs',`// generated from ${sourcePath}; do not edit.\nnamespace Kbx.Shared.Integrations.Generated;\npublic sealed record KbxIntegrationDefinition(string Id,string Title,string OwnerModule,string Direction,string Transport,string Criticality,string SourceEvent,string Target,string Delivery,string Ordering,string Idempotency,int TimeoutMs,int ShortRetryAttempts,int ShortRetryBaseDelayMs,string ShortRetryBackoff,int LongRetryAttempts,IReadOnlyList<int> LongRetryScheduleSeconds,double CircuitFailureRatio,int CircuitSamplingSeconds,int CircuitMinimumThroughput,int CircuitBreakSeconds,string TerminalAction,bool UserVisible);\npublic static class KbxIntegrationCatalog\n{\n public const string SourceSha256 = "${sha}";\n public static readonly IReadOnlyDictionary<string,KbxIntegrationDefinition> All = new Dictionary<string,KbxIntegrationDefinition>(StringComparer.Ordinal)\n {\n${source.integrations.map(x=>` ["${esc(x.id)}"] = new("${esc(x.id)}", "${esc(x.title)}", "${x.ownerModule}", "${x.direction}", "${x.transport}", "${x.criticality}", "${esc(x.sourceEvent)}", "${esc(x.target)}", "${x.delivery}", "${x.ordering}", "${x.idempotency}", ${x.timeoutMs}, ${x.shortRetry.maxRetryAttempts}, ${x.shortRetry.baseDelayMs}, "${x.shortRetry.backoff}", ${x.longRetry.maxAttempts}, new[] { ${x.longRetry.scheduleSeconds.join(', ')} }, ${x.circuitBreaker.failureRatio}, ${x.circuitBreaker.samplingSeconds}, ${x.circuitBreaker.minimumThroughput}, ${x.circuitBreaker.breakSeconds}, "${x.terminalAction}", ${x.userVisible?'true':'false'})`).join(',\n')}\n };\n}\n`)
console.log(`integration contracts generated: ${source.integrations.length}, SHA ${sha.slice(0,12)}`)
@@ -0,0 +1,5 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd();const source=path.join(root,'apps/web/src/shell/navigationCatalog.ts');const out=path.join(root,'generated/navigation-manifest.json');const text=fs.readFileSync(source,'utf8')
const items=[...text.matchAll(/\{\s*screenId:'([^']+)',\s*path:'([^']+)',\s*section:'([^']+)'[\s\S]*?order:(\d+)/g)].map(m=>({screenId:m[1],path:m[2],section:m[3],order:Number(m[4])}))
fs.writeFileSync(out,JSON.stringify(items,null,2)+'\n');console.log(`generated ${items.length} navigation entries -> ${path.relative(root,out)}`)
@@ -0,0 +1,18 @@
import fs from 'node:fs'
import crypto from 'node:crypto'
const sourcePath='contracts/providers/kbx.providers.json'
const text=fs.readFileSync(sourcePath,'utf8')
const source=JSON.parse(text)
const sha=crypto.createHash('sha256').update(text).digest('hex')
const seen=new Set()
for(const p of source.providers??[]){if(seen.has(p.id))throw new Error(`duplicate provider ${p.id}`);seen.add(p.id)}
const manifest={schemaVersion:source.schemaVersion,providerVersion:source.providerVersion,sourceSha256:sha,principles:source.principles,providers:source.providers}
fs.mkdirSync('generated',{recursive:true});fs.writeFileSync('generated/provider-manifest.json',JSON.stringify(manifest,null,2)+'\n')
const catalog=Object.fromEntries(source.providers.map(x=>[x.id,x]))
fs.mkdirSync('packages/kbx-contracts/src/generated',{recursive:true})
fs.writeFileSync('packages/kbx-contracts/src/generated/providerCatalog.ts',`// generated from ${sourcePath}; do not edit.\nimport type { KbxExternalProviderDefinition } from '../provider'\nexport const kbxProviderSourceSha256='${sha}' as const\nexport const kbxExternalProviderCatalog=${JSON.stringify(catalog,null,2)} as const satisfies Record<string,KbxExternalProviderDefinition>\nexport type KbxExternalProviderId=keyof typeof kbxExternalProviderCatalog\n`)
const esc=s=>String(s).replaceAll('\\','\\\\').replaceAll('"','\\"')
fs.mkdirSync('backend/Shared/Providers/Generated',{recursive:true})
const rows=source.providers.map(p=>` ["${esc(p.id)}"] = new("${esc(p.id)}", "${esc(p.title)}", "${p.ownerModule}", "${p.purpose}", ${p.mutationAllowed?'true':'false'}, new[] { ${p.officialSources.map(x=>`"${esc(x)}"`).join(', ')} })`).join(',\n')
fs.writeFileSync('backend/Shared/Providers/Generated/KbxExternalProviderCatalog.g.cs',`// generated from ${sourcePath}; do not edit.\nnamespace Kbx.Shared.Providers.Generated;\npublic sealed record KbxExternalProviderDefinition(string Id,string Title,string OwnerModule,string Purpose,bool MutationAllowed,IReadOnlyList<string> OfficialSources);\npublic static class KbxExternalProviderCatalog { public const string SourceSha256="${sha}"; public static readonly IReadOnlyDictionary<string,KbxExternalProviderDefinition> All=new Dictionary<string,KbxExternalProviderDefinition>(StringComparer.Ordinal) {\n${rows}\n }; }\n`)
console.log(`provider contracts generated: ${source.providers.length}, SHA ${sha.slice(0,12)}`)
@@ -0,0 +1,43 @@
import fs from 'node:fs'
import path from 'node:path'
const root = process.cwd()
const sourceRoot = path.join(root, 'apps/web/src/modules')
const out = path.join(root, 'generated/screen-manifest.json')
function files(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name)
return entry.isDirectory() ? files(full) : [full]
})
}
const definitions = files(sourceRoot).filter(f => /definition\.ts$/.test(f))
const screens = []
for (const file of definitions) {
const text = fs.readFileSync(file, 'utf8')
const start = text.search(/defineKbxScreen\s*\(/)
if (start < 0) continue
const block = text.slice(start)
const get = key => block.match(new RegExp(`\\b${key}:\\s*['\"]([^'\"]+)['\"]`))?.[1]
const id = get('id')
if (!id) continue
const permissionsRaw = block.match(/permissions:\s*\[([^\]]*)\]/)?.[1] ?? ''
const requiredPermissions = [...permissionsRaw.matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1])
screens.push({
id,
version: get('version') ?? 'unknown',
module: get('module') ?? 'unknown',
type: get('type') ?? 'unknown',
templateCode: get('templateCode') ?? null,
title: get('title') ?? id,
helpKey: get('helpKey') ?? null,
requiredPermissions,
source: path.relative(root, file).replaceAll('\\\\','/'),
})
}
screens.sort((a,b) => a.id.localeCompare(b.id))
fs.mkdirSync(path.dirname(out), { recursive: true })
fs.writeFileSync(out, JSON.stringify(screens, null, 2) + '\n')
console.log(`generated ${screens.length} screen definitions -> ${path.relative(root,out)}`)
@@ -0,0 +1,40 @@
import fs from 'node:fs'
import crypto from 'node:crypto'
const source='contracts/screens/kbx.screen-recipes.json'
const scenarioSource='contracts/testing/kbx.test-scenarios.json'
const data=JSON.parse(fs.readFileSync(source,'utf8'))
const scenarios=JSON.parse(fs.readFileSync(scenarioSource,'utf8'))
const scenarioById=new Map(scenarios.scenarios.map(scenario=>[scenario.id,scenario]))
const sha=crypto.createHash('sha256').update(fs.readFileSync(source)).digest('hex')
const scenarioSha=crypto.createHash('sha256').update(fs.readFileSync(scenarioSource)).digest('hex')
const byCode=Object.fromEntries(data.recipes.map(recipe=>[recipe.code,recipe]))
const verification=data.recipes.map(recipe=>{
const canonical=recipe.canonicalScenarioIds.flatMap(id=>scenarioById.has(id)?[scenarioById.get(id)]:[])
const kinds=[...new Set(canonical.map(item=>item.kind))].sort()
const tags=[...new Set(canonical.flatMap(item=>item.tags??[]))].sort()
const evidence=[...new Set(canonical.flatMap(item=>item.evidence??[]))].sort()
const missingScenarioKinds=recipe.testProfile.requiredScenarioKinds.filter(item=>!kinds.includes(item))
const missingTags=recipe.testProfile.requiredTags.filter(item=>!tags.includes(item))
const missingEvidence=recipe.testProfile.requiredEvidence.filter(item=>!evidence.includes(item))
return {
code:recipe.code,
type:recipe.type,
canonicalScenarioIds:recipe.canonicalScenarioIds,
scenarioKinds:kinds,
tags,
evidence,
requiredChecks:recipe.testProfile.requiredChecks,
missingScenarioKinds,
missingTags,
missingEvidence,
complete:!missingScenarioKinds.length&&!missingTags.length&&!missingEvidence.length,
}
})
fs.writeFileSync('generated/screen-recipe-manifest.json',JSON.stringify({schemaVersion:data.schemaVersion,contractVersion:data.contractVersion,sourceSha256:sha,recipes:data.recipes},null,2)+'\n')
fs.writeFileSync('generated/screen-recipe-verification-manifest.json',JSON.stringify({schemaVersion:1,recipeContractVersion:data.contractVersion,scenarioContractVersion:scenarios.contractVersion,recipeSourceSha256:sha,scenarioSourceSha256:scenarioSha,recipes:verification},null,2)+'\n')
const ts=`// generated from ${source}; do not edit.\n// SHA256: ${sha}\nimport type { KbxScreenRecipeDefinition } from '../screen'\n\nexport const kbxScreenRecipeContractVersion=${JSON.stringify(data.contractVersion)} as const\nexport const kbxScreenRecipeCatalog=${JSON.stringify(byCode,null,2)} as const satisfies Record<string,KbxScreenRecipeDefinition>\nexport const kbxScreenRecipeVerificationCatalog=${JSON.stringify(Object.fromEntries(verification.map(item=>[item.code,item])),null,2)} as const\n`
fs.writeFileSync('packages/kbx-contracts/src/generated/screenRecipeCatalog.ts',ts)
console.log(`screen recipe contracts generated: recipes=${data.recipes.length}, verification=${verification.filter(item=>item.complete).length}/${verification.length}`)
@@ -0,0 +1,23 @@
import fs from 'node:fs'
import crypto from 'node:crypto'
const sourcePath='contracts/telemetry/kbx.telemetry.json'
const source=fs.readFileSync(sourcePath,'utf8')
const data=JSON.parse(source)
const sha=crypto.createHash('sha256').update(source).digest('hex')
const names=new Set(), allowedName=/^[a-z][a-z0-9_.-]+$/
for(const event of data.events){
if(!allowedName.test(event.name)) throw new Error(`invalid telemetry event name: ${event.name}`)
if(names.has(event.name)) throw new Error(`duplicate telemetry event: ${event.name}`)
names.add(event.name)
const attrs=new Set()
for(const key of event.allowedAttributes??[]){if(attrs.has(key))throw new Error(`duplicate attribute ${event.name}.${key}`);attrs.add(key)}
}
const manifest={schemaVersion:data.schemaVersion,telemetryVersion:data.telemetryVersion,sourceSha256:sha,principles:data.principles,events:data.events,metrics:data.metrics}
fs.mkdirSync('generated',{recursive:true});fs.writeFileSync('generated/telemetry-manifest.json',JSON.stringify(manifest,null,2)+'\n')
const ts=`// generated from ${sourcePath}; do not edit.\nexport const kbxTelemetryCatalog = ${JSON.stringify(Object.fromEntries(data.events.map(e=>[e.name,e])),null,2)} as const\nexport type KbxTelemetryEventName = keyof typeof kbxTelemetryCatalog\nexport const kbxUxMetricCatalog = ${JSON.stringify(Object.fromEntries(data.metrics.map(m=>[m.key,m])),null,2)} as const\nexport type KbxUxMetricKey = keyof typeof kbxUxMetricCatalog\nexport const kbxTelemetrySourceSha256 = '${sha}' as const\n`
fs.mkdirSync('packages/kbx-contracts/src/generated',{recursive:true});fs.writeFileSync('packages/kbx-contracts/src/generated/telemetryCatalog.ts',ts)
const esc=s=>s.replaceAll('"','\\"')
const csEvents=data.events.map(e=>` ["${e.name}"] = new("${e.name}", "${e.category}", new[] { ${(e.allowedAttributes??[]).map(x=>`"${x}"`).join(', ')} }, ${e.requiresDuration?'true':'false'})`).join(',\n')
const cs=`// generated from ${sourcePath}; do not edit.\nnamespace Kbx.Shared.Telemetry.Generated;\n\npublic sealed record KbxTelemetryEventDefinition(string Name,string Category,IReadOnlyList<string> AllowedAttributes,bool RequiresDuration);\n\npublic static class KbxTelemetryCatalog\n{\n public const string SourceSha256 = "${sha}";\n public static readonly IReadOnlyDictionary<string,KbxTelemetryEventDefinition> Events = new Dictionary<string,KbxTelemetryEventDefinition>(StringComparer.Ordinal)\n {\n${csEvents}\n };\n}\n`
fs.mkdirSync('backend/Shared/Telemetry/Generated',{recursive:true});fs.writeFileSync('backend/Shared/Telemetry/Generated/KbxTelemetryCatalog.g.cs',cs)
console.log(`generated telemetry contracts: ${data.events.length} events, ${data.metrics.length} metrics`)
@@ -0,0 +1,69 @@
import fs from 'node:fs'
import crypto from 'node:crypto'
const read = p => JSON.parse(fs.readFileSync(p, 'utf8'))
const scenarioPath = 'contracts/testing/kbx.test-scenarios.json'
const fixturePath = 'contracts/testing/kbx.test-fixtures.json'
const scenarios = read(scenarioPath)
const fixtures = read(fixturePath)
const sha = p => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex')
const scenarioSha = sha(scenarioPath)
const fixtureSha = sha(fixturePath)
const manifest = {
schemaVersion: scenarios.schemaVersion,
contractVersion: scenarios.contractVersion,
sourceSha256: scenarioSha,
fixtureVersion: fixtures.fixtureVersion,
fixtureSourceSha256: fixtureSha,
fixedReferenceClock: scenarios.principles.fixedReferenceClock,
scenarios: scenarios.scenarios.map(s => ({
id: s.id,
title: s.title,
screenId: s.screenId,
kind: s.kind,
tags: s.tags,
fixtureSets: s.fixtureSets,
requiredPermissions: s.requiredPermissions,
apiOperations: s.apiOperations,
isolation: s.isolation,
assertionCount: s.assertions.length,
evidence: s.evidence,
})),
}
fs.writeFileSync('generated/test-scenario-manifest.json', JSON.stringify(manifest, null, 2) + '\n')
const fixtureManifest = {
schemaVersion: fixtures.schemaVersion,
fixtureVersion: fixtures.fixtureVersion,
sourceSha256: fixtureSha,
syntheticOnly: fixtures.syntheticOnly,
fixedReferenceClock: fixtures.fixedReferenceClock,
testTenant: fixtures.testTenant,
fixtureSets: fixtures.fixtureSets,
refCount: Object.keys(fixtures.refs ?? {}).length,
}
fs.writeFileSync('generated/test-fixture-manifest.json', JSON.stringify(fixtureManifest, null, 2) + '\n')
const scenarioCatalog = Object.fromEntries(scenarios.scenarios.map(s => [s.id, s]))
const fixtureSetCatalog = Object.fromEntries(fixtures.fixtureSets.map(s => [s.id, s]))
const ts = `// generated from KBX test contracts; do not edit.\n` +
`// Scenario SHA256: ${scenarioSha}\n// Fixture SHA256: ${fixtureSha}\n\n` +
`import type { KbxTestScenarioDefinition, KbxTestFixtureSetDefinition } from '../testing'\n\n` +
`export const kbxTestScenarioContractVersion = ${JSON.stringify(scenarios.contractVersion)} as const\n` +
`export const kbxTestFixtureVersion = ${JSON.stringify(fixtures.fixtureVersion)} as const\n` +
`export const kbxTestReferenceClock = ${JSON.stringify(scenarios.principles.fixedReferenceClock)} as const\n\n` +
`export const kbxTestScenarioCatalog = ${JSON.stringify(scenarioCatalog, null, 2)} as const satisfies Record<string, KbxTestScenarioDefinition>\n\n` +
`export const kbxTestFixtureSetCatalog = ${JSON.stringify(fixtureSetCatalog, null, 2)} as const satisfies Record<string, KbxTestFixtureSetDefinition>\n` +
`export const kbxTestFixtureRefs = ${JSON.stringify(fixtures.refs, null, 2)} as const\n`
fs.writeFileSync('packages/kbx-contracts/src/generated/testScenarioCatalog.ts', ts)
const csString = s => `"${String(s).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
const csList = a => `new[] { ${(a ?? []).map(csString).join(', ')} }`
const rows = scenarios.scenarios.map(s =>
` new(${csString(s.id)}, ${csString(s.title)}, ${csString(s.screenId)}, ${csString(s.kind)}, ${csList(s.tags)}, ${csList(s.fixtureSets)}, ${csList(s.requiredPermissions)}, ${csList(s.apiOperations)}, ${csString(s.isolation)})`
).join(',\n')
const cs = `// GENERATED. DO NOT EDIT.\n// Scenario SHA256: ${scenarioSha}\n// Fixture SHA256: ${fixtureSha}\nnamespace Shared.Testing.Generated;\n\npublic sealed record KbxTestScenarioContract(\n string Id, string Title, string ScreenId, string Kind,\n IReadOnlyList<string> Tags, IReadOnlyList<string> FixtureSets,\n IReadOnlyList<string> RequiredPermissions, IReadOnlyList<string> ApiOperations,\n string Isolation);\n\npublic static class KbxTestScenarioCatalog\n{\n public const string ContractVersion = ${csString(scenarios.contractVersion)};\n public const string FixtureVersion = ${csString(fixtures.fixtureVersion)};\n public const string ReferenceClock = ${csString(scenarios.principles.fixedReferenceClock)};\n public static IReadOnlyList<KbxTestScenarioContract> All { get; } = new KbxTestScenarioContract[]\n {\n${rows}\n };\n}\n`
fs.writeFileSync('backend/Shared/Testing/Generated/KbxTestScenarioCatalog.g.cs', cs)
console.log(`test contracts generated: scenarios=${scenarios.scenarios.length}, fixtureSets=${fixtures.fixtureSets.length}, refs=${Object.keys(fixtures.refs ?? {}).length}`)
@@ -0,0 +1,33 @@
import fs from 'node:fs'
import path from 'node:path'
const root=path.resolve('packages/kbx-ui/src')
const baselinePath=path.resolve('governance/design-debt-baseline.json')
const write=process.argv.includes('--write-baseline')
const files=[]
function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())walk(p);else if(/\.(vue|ts|css)$/.test(e.name))files.push(p)}}
walk(root)
const report={}
for(const file of files){
const rel=path.relative(process.cwd(),file).replaceAll('\\','/')
if(rel.includes('/tokens/source/')||rel.endsWith('/tokens/kbx.css')) continue
const text=fs.readFileSync(file,'utf8')
const hex=(text.match(/#[0-9A-Fa-f]{3,8}\b/g)||[]).length
const px=(text.match(/(?<![-\w])\d+(?:\.\d+)?px\b/g)||[]).length
if(hex||px) report[rel]={hex,px,total:hex+px}
}
const totals=Object.values(report).reduce((a,x)=>({hex:a.hex+x.hex,px:a.px+x.px,total:a.total+x.total}),{hex:0,px:0,total:0})
const payload={generatedFrom:'packages/kbx-ui/src',policy:'ratchet-only: existing hardcoded literals may decrease but must not increase; new files start at zero',totals,files:report}
fs.mkdirSync('generated',{recursive:true})
fs.writeFileSync('generated/design-debt-report.json',JSON.stringify(payload,null,2)+'\n')
if(write){fs.mkdirSync(path.dirname(baselinePath),{recursive:true});fs.writeFileSync(baselinePath,JSON.stringify(payload,null,2)+'\n');console.log(`wrote design debt baseline: ${totals.total} literals`);process.exit(0)}
if(!fs.existsSync(baselinePath)){console.error('missing design debt baseline; run measure-design-debt.mjs --write-baseline intentionally');process.exit(1)}
const baseline=JSON.parse(fs.readFileSync(baselinePath,'utf8'))
let failed=false
for(const [file,current] of Object.entries(report)){
const allowed=baseline.files[file]?.total ?? 0
if(current.total>allowed){console.error(`design debt increased: ${file} ${current.total} > baseline ${allowed}`);failed=true}
}
if(totals.total>baseline.totals.total){console.error(`design debt total increased: ${totals.total} > ${baseline.totals.total}`);failed=true}
if(failed) process.exit(1)
console.log(`design debt ratchet PASS: ${totals.total} <= ${baseline.totals.total}`)
@@ -0,0 +1,55 @@
import fs from 'node:fs'
import path from 'node:path'
const source = JSON.parse(fs.readFileSync('contracts/fields/kbx.fields.json', 'utf8'))
const known = new Set(source.fields.map(x => x.key))
const deprecated = new Map(source.fields.filter(x => x.deprecated).map(x => [x.key, x.replacementKey]))
const files = []
function walk(dir) {
if (!fs.existsSync(dir)) return
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else if (/\.(ts|vue)$/.test(entry.name)) files.push(full)
}
}
walk('apps/web/src')
const occurrences = []
const patterns = [
/\bfield\s*:\s*['"]([A-Za-z][A-Za-z0-9]*)['"]/g,
/kbxImportField\(\s*['"]([A-Za-z][A-Za-z0-9]*)['"]\s*\)/g,
]
for (const file of files) {
const text = fs.readFileSync(file, 'utf8')
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
const key = match[1]
occurrences.push({ file, key, canonical: known.has(key), deprecated: deprecated.has(key), replacementKey: deprecated.get(key) ?? null })
}
}
}
const counts = new Map()
for (const x of occurrences) counts.set(x.key, (counts.get(x.key) ?? 0) + 1)
const unique = [...counts.keys()].sort()
const canonical = unique.filter(x => known.has(x))
const unknown = unique.filter(x => !known.has(x))
const candidates = unknown
.map(key => ({ key, occurrences: counts.get(key) ?? 0 }))
.filter(x => x.occurrences >= 2)
.sort((a,b) => b.occurrences-a.occurrences || a.key.localeCompare(b.key))
const deprecatedUses = occurrences.filter(x => x.deprecated)
const report = {
dictionaryVersion: source.version,
canonicalFieldCount: known.size,
scannedFiles: files.length,
fieldOccurrences: occurrences.length,
uniqueFieldKeysObserved: unique.length,
canonicalKeysUsed: canonical,
localOrUnknownKeys: unknown,
promotionCandidates: candidates,
deprecatedUses,
note: 'Unknown keys are not automatically errors: local read-model fields may remain local. Repeated business concepts are promotion candidates for the canonical dictionary.',
}
fs.writeFileSync('generated/field-adoption-report.json', JSON.stringify(report, null, 2) + '\n')
console.log(`field adoption report: canonical-used=${canonical.length}, local/unknown=${unknown.length}, promotion-candidates=${candidates.length}, deprecated-uses=${deprecatedUses.length}`)
@@ -0,0 +1,92 @@
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}`)
@@ -0,0 +1,13 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd();const fail=[]
const read=p=>fs.readFileSync(path.join(root,p),'utf8')
const required=[
'packages/kbx-ui/src/shell/KbxApplicationShell.vue','packages/kbx-ui/src/shell/KbxGlobalHeader.vue','packages/kbx-ui/src/shell/KbxSideNavigation.vue','packages/kbx-ui/src/shell/KbxWorkspaceTabs.vue','packages/kbx-ui/src/shell/KbxMenuSearch.vue','packages/kbx-ui/src/shell/KbxUnsavedChangesDialog.vue','packages/kbx-ui/src/shell/KbxHomePage.vue','packages/kbx-ui/src/shell/KbxAccessDenied.vue','apps/web/src/shell/KbxAppFrame.vue','apps/web/src/shell/navigationCatalog.ts','apps/web/src/shell/workspaceStore.ts','apps/web/src/router/appRoutes.ts','apps/web/src/modules/wms/work/work.definition.ts']
for(const file of required)if(!fs.existsSync(path.join(root,file)))fail.push(`missing ${file}`)
const catalog=read('apps/web/src/shell/navigationCatalog.ts');const ids=[...catalog.matchAll(/screenId:'([^']+)'/g)].map(x=>x[1]);const paths=[...catalog.matchAll(/path:'([^']+)'/g)].map(x=>x[1]);if(new Set(ids).size!==ids.length)fail.push('duplicate navigation screenId');if(new Set(paths).size!==paths.length)fail.push('duplicate navigation path')
const generated=read('apps/web/src/registry/screens.generated.ts');for(const id of ids){if(!generated.includes(`id: '${id}'`)&&!read('generated/screen-manifest.json').includes(`\"id\": \"${id}\"`))fail.push(`navigation unknown screen ${id}`)}
const routes=read('apps/web/src/router/appRoutes.ts');for(const [i,id] of ids.entries()){if(!routes.includes(`path:'${paths[i]}'`)||!routes.includes(`screenId:'${id}'`))fail.push(`route/nav mismatch ${id} ${paths[i]}`)}
const frame=read('apps/web/src/shell/KbxAppFrame.vue');if(!frame.includes('useKbxShortcuts')||!frame.includes("key:'Ctrl+K'")||!frame.includes("scope:'application'"))fail.push('Ctrl+K menu search must use central application-scope shortcut');if(!frame.includes('pendingClose'))fail.push('dirty close handling missing');if(!frame.includes('activeScreenAllowed'))fail.push('direct URL permission guard missing');if(!frame.includes('KbxHomePage'))fail.push('home composition missing')
if(!routes.includes("{path:'/',redirect:'/home'}"))fail.push('root does not open home');const tabs=read('packages/kbx-ui/src/shell/KbxWorkspaceTabs.vue');if(!tabs.includes('더보기 {{ overflowTabs.length }}'))fail.push('workspace overflow missing')
if(fail.length){console.error(fail.join('\n'));process.exit(1)}console.log(`Application shell validation passed for ${ids.length} menu entries.`)
@@ -0,0 +1,45 @@
import fs from 'node:fs'
import path from 'node:path'
const root = process.cwd()
const errors = []
function walk(dir) {
if (!fs.existsSync(dir)) return []
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name)
return entry.isDirectory() ? walk(full) : [full]
})
}
const moduleFiles = walk(path.join(root, 'apps/web/src/modules')).filter(f => /\.(ts|vue)$/.test(f))
for (const file of moduleFiles) {
const text = fs.readFileSync(file, 'utf8')
if (/from\s+['\"]primevue\//.test(text) || /from\s+['\"]ag-grid-(vue3|community)/.test(text)) {
errors.push(`${path.relative(root,file)}: business module imports PrimeVue/AG Grid directly`)
}
}
const manifestFile = path.join(root, 'generated/screen-manifest.json')
if (!fs.existsSync(manifestFile)) errors.push('generated/screen-manifest.json is missing')
else {
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'))
const ids = new Map()
for (const screen of manifest) {
if (ids.has(screen.id)) errors.push(`duplicate screen id ${screen.id}: ${ids.get(screen.id)} / ${screen.source}`)
ids.set(screen.id, screen.source)
if (!/^((OMS|ERP|WMS|COMMON)-[A-Z0-9-]+)$/.test(screen.id)) errors.push(`invalid screen id: ${screen.id}`)
if (screen.version === 'unknown' || screen.type === 'unknown') errors.push(`incomplete screen definition: ${screen.id}`)
}
}
const migrations = walk(path.join(root, 'backend/Database/Migrations')).filter(f => f.endsWith('.sql')).map(f => path.basename(f))
const sorted = [...migrations].sort()
if (migrations.join('|') !== sorted.join('|')) errors.push('migration files are not lexicographically ordered')
if (errors.length) {
console.error('KBX architecture validation failed:')
for (const error of errors) console.error(`- ${error}`)
process.exit(1)
}
console.log(`KBX architecture validation passed (${moduleFiles.length} module source files, ${migrations.length} migrations).`)
@@ -0,0 +1,37 @@
import fs from 'node:fs'
import path from 'node:path'
const auth=JSON.parse(fs.readFileSync('contracts/authorization/kbx.authorization.json','utf8'))
const fields=JSON.parse(fs.readFileSync('contracts/fields/kbx.fields.json','utf8')).fields
const api=JSON.parse(fs.readFileSync('contracts/api/kbx.api.json','utf8')).operations
const ids=new Set(); const errors=[]
for(const p of auth.permissions){
if(ids.has(p.id))errors.push(`duplicate permission: ${p.id}`); ids.add(p.id)
if(!/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/.test(p.id))errors.push(`invalid permission id: ${p.id}`)
if(!['low','medium','high'].includes(p.risk))errors.push(`invalid risk: ${p.id}`)
}
for(const op of api)if(op.permission && !ids.has(op.permission))errors.push(`API permission missing in catalog: ${op.id} -> ${op.permission}`)
const scanRoots=['apps/web/src','backend/Modules','packages/kbx-ui/src']
function walk(dir){if(!fs.existsSync(dir))return[];return fs.readdirSync(dir,{withFileTypes:true}).flatMap(e=>e.isDirectory()?walk(path.join(dir,e.name)):[path.join(dir,e.name)])}
const used=new Set()
for(const file of scanRoots.flatMap(walk)){
if(!/\.(ts|vue|cs)$/.test(file))continue
const text=fs.readFileSync(file,'utf8')
for(const m of text.matchAll(/permission\s*:\s*['"]([a-z][a-z0-9.-]+)['"]/g))used.add(m[1])
for(const m of text.matchAll(/permissions\s*:\s*\[([^\]]*)\]/gi))for(const x of m[1].matchAll(/['"]([a-z][a-z0-9.-]+)['"]/g))used.add(x[1])
for(const m of text.matchAll(/Permissions\(\s*['"]([a-z][a-z0-9.-]+)['"]\s*\)/g))used.add(m[1])
}
for(const p of used)if(!ids.has(p))errors.push(`used permission missing in catalog: ${p}`)
const fieldMap=new Map(fields.map(f=>[f.key,f]))
const covered=new Set()
for(const policy of auth.sensitiveDataPolicies){
for(const perm of [policy.viewPermission,policy.revealPermission,policy.unmaskedExportPermission])if(!ids.has(perm))errors.push(`sensitive policy permission missing: ${policy.id} -> ${perm}`)
for(const key of policy.fields){const f=fieldMap.get(key);if(!f)errors.push(`sensitive policy field missing: ${key}`);else if(!f.sensitive)errors.push(`policy field is not sensitive: ${key}`);covered.add(key)}
if(policy.aiExposure!=='masked-only'&&policy.aiExposure!=='deny')errors.push(`unsafe aiExposure: ${policy.id}`)
if(policy.telemetryExposure!=='never')errors.push(`sensitive telemetry must be never: ${policy.id}`)
}
for(const f of fields.filter(x=>x.sensitive))if(!covered.has(f.key))errors.push(`sensitive field has no policy: ${f.key}`)
if(!ids.has(auth.ai.usePermission)||!ids.has(auth.ai.executePermission))errors.push('AI authorization permissions are not cataloged')
const source=fs.readFileSync('apps/web/src/utility/useKbxScreenUtility.ts','utf8')
if(source.includes("allowedCapabilities: ['explain', 'suggest', 'draft']"))errors.push('AI capabilities are still hard-coded; use authorization policy helper')
if(errors.length){console.error(errors.join('\n'));process.exit(1)}
console.log(`authorization governance PASS: permissions=${ids.size}, used=${used.size}, sensitive=${covered.size}`)
@@ -0,0 +1,38 @@
import fs from 'node:fs'
import path from 'node:path'
const requiredCore=[
'KbxButton','KbxInput','KbxNumberField','KbxMoneyField','KbxQuantityField','KbxDateField','KbxDateRange','KbxSelect','KbxLookup',
'KbxPageHeader','KbxCommandBar','KbxSearchPanel','KbxFormSection','KbxDataGrid','KbxBulkActionBar','KbxStatus',
'KbxDialog','KbxDrawer','KbxToast','KbxConfirm','KbxExcelMenu','KbxExcelImport','KbxJobProgress','KbxAuditTrail','KbxHelpPanel','KbxAiPanel','KbxProposalPanel',
'KbxListPage','KbxMasterPage','KbxTransactionPage','KbxMasterDetailPage','KbxQueuePage','KbxReconcilePage','KbxWmsMobilePage',
]
const manifest=fs.readFileSync('packages/kbx-ui/src/registry/componentManifest.ts','utf8')
const index=fs.readFileSync('packages/kbx-ui/src/index.ts','utf8')
const catalog=JSON.parse(fs.readFileSync('generated/catalog-manifest.json','utf8'))
const generatedComponents=JSON.parse(fs.readFileSync('generated/component-manifest.json','utf8'))
const failures=[]
for(const entry of generatedComponents){ if(!/^\d+\.\d+\.\d+$/.test(entry.version)) failures.push(`invalid component semver ${entry.name}: ${entry.version}`) }
for(const name of requiredCore){
if(!manifest.includes(`name: '${name}'`)) failures.push(`component manifest missing ${name}`)
if(!generatedComponents.some(x=>x.name===name)) failures.push(`generated component manifest missing ${name}`)
if(!index.includes(`as ${name} }`) && !index.includes(`as ${name} } from`) && !index.includes(`default as ${name}`)) failures.push(`public API missing ${name}`)
}
for(const name of ['KbxInput','KbxLookup','KbxDataGrid','KbxCommandBar','KbxExcelImport','KbxWmsMobilePage','KbxApplicationShell']){
if(!catalog.some(x=>x.component===name)) failures.push(`catalog missing critical component ${name}`)
}
const css=fs.readFileSync('packages/kbx-ui/src/tokens/kbx.css','utf8')
if(!css.includes(':focus-visible')) failures.push('global focus-visible contract missing')
const uiFiles=[]
function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const f=path.join(dir,e.name);e.isDirectory()?walk(f):uiFiles.push(f)}}
walk('packages/kbx-ui/src')
for(const file of uiFiles.filter(x=>x.endsWith('.vue'))){
const text=fs.readFileSync(file,'utf8')
if(/outline\s*:\s*none/i.test(text)) failures.push(`${file}: outline:none is forbidden`)
}
const visual=fs.readFileSync('tests/e2e/kbx-component-catalog.visual.spec.ts','utf8')
for(const d of ['compact','comfortable','touch']) if(!visual.includes(`density:'${d}'`)) failures.push(`visual regression missing ${d} density`)
const keyboard=fs.readFileSync('tests/e2e/kbx-keyboard-regression.spec.ts','utf8')
for(const key of ['F2','F3','F8','Tab','Shift+Tab','Enter','Escape']) if(!keyboard.includes(`'${key}'`) && !keyboard.includes(`('${key}')`)) failures.push(`keyboard regression missing ${key}`)
if(failures.length){console.error('KBX component catalog validation failed:\n- '+failures.join('\n- '));process.exit(1)}
console.log(`KBX component catalog validation passed: ${requiredCore.length} core APIs, ${catalog.length} catalog entries.`)
@@ -0,0 +1,77 @@
import fs from 'node:fs'
import path from 'node:path'
const read=p=>JSON.parse(fs.readFileSync(p,'utf8'))
const src=read('contracts/configuration/kbx.configuration.json')
const providers=read('contracts/providers/kbx.providers.json')
const manifest=read('generated/configuration-manifest.json')
const errors=[]
const keys=new Set(),envs=new Set()
for(const s of src.settings??[]){
if(keys.has(s.key))errors.push(`duplicate configuration key ${s.key}`);keys.add(s.key)
if(envs.has(s.env))errors.push(`duplicate environment variable ${s.env}`);envs.add(s.env)
if(s.secret && s.default!==undefined)errors.push(`secret setting must not define a default: ${s.key}`)
if(s.secret && !(s.allowedSources??[]).every(x=>['environment','secret-store','user-secrets'].includes(x)))errors.push(`secret setting has unsafe source: ${s.key}`)
if(s.type==='enum' && !(s.allowedValues?.length))errors.push(`enum setting missing allowedValues: ${s.key}`)
if((s.minimum!==undefined||s.maximum!==undefined)&&s.type!=='integer')errors.push(`numeric bounds only supported for integer in v22: ${s.key}`)
}
for(const e of src.environments??[]){
if(!['Development','Test','Staging','Production'].includes(e.id))errors.push(`unsupported environment profile ${e.id}`)
}
const production=src.environments.find(x=>x.id==='Production')
if(!production||production.migrationStrategy!=='predeploy')errors.push('Production must use predeploy migration strategy')
if(!production?.requireHttps)errors.push('Production must require HTTPS')
if(production?.providerEndpointOverrideAllowed)errors.push('Production provider endpoint overrides must be forbidden')
const test=src.environments.find(x=>x.id==='Test')
if(!test||test.providerNetwork!=='forbidden')errors.push('Test environment must forbid live provider network access')
const providerCredentialKeys=[]
for(const p of providers.providers??[]){
const policy=p.kbxPolicy??{}
for(const prop of ['credentialKey','appKeyCredential','appSecretCredential','environmentKey'])if(policy[prop])providerCredentialKeys.push(policy[prop])
}
for(const key of providerCredentialKeys)if(!keys.has(key))errors.push(`provider configuration key missing from configuration catalog: ${key}`)
if(manifest.settingCount!==src.settings.length||manifest.environmentCount!==src.environments.length)errors.push('generated configuration manifest count drift')
const ts=fs.readFileSync('packages/kbx-contracts/src/generated/configurationCatalog.ts','utf8')
const cs=fs.readFileSync('backend/Shared/Configuration/Generated/KbxConfigurationCatalog.g.cs','utf8')
if(!ts.includes(manifest.sourceSha256)||!cs.includes(manifest.sourceSha256))errors.push('configuration generated source SHA parity missing')
const envTemplate=fs.readFileSync('deploy/kbx/.env.kbx.example','utf8')
for(const s of src.settings){
if(!envTemplate.includes(`${s.env}=`))errors.push(`env template missing ${s.env}`)
if(s.secret){
const line=envTemplate.split(/\r?\n/).find(x=>x.startsWith(`${s.env}=`))??''
const raw=line.slice(s.env.length+1).split(' # ')[0]
if(raw.trim())errors.push(`generated env template contains a secret value: ${s.env}`)
}
}
function walk(dir){
const out=[]
if(!fs.existsSync(dir))return out
for(const e of fs.readdirSync(dir,{withFileTypes:true})){
const p=path.join(dir,e.name)
if(e.isDirectory())out.push(...walk(p));else out.push(p)
}
return out
}
// Direct environment/config access outside the configuration boundary becomes hidden configuration debt.
for(const file of walk('backend').filter(x=>x.endsWith('.cs')&&!x.includes('/Shared/Configuration/'))){
const text=fs.readFileSync(file,'utf8')
if(/Environment\.GetEnvironmentVariable\s*\(/.test(text))errors.push(`direct environment read outside configuration boundary: ${file}`)
if(/IConfiguration\s*\[/.test(text))errors.push(`direct IConfiguration index usage outside configuration boundary: ${file}`)
}
// Heuristic committed-secret guard for known provider/alerting key names.
for(const file of walk('.').filter(x=>/\.(json|ya?ml|md|env|txt)$/i.test(x)&&!x.includes('/.git/')&&!x.includes('governance/baselines/'))){
if(file==='contracts/configuration/kbx.configuration.json'||file==='contracts/providers/kbx.providers.json'||file==='deploy/kbx/.env.kbx.example')continue
const text=fs.readFileSync(file,'utf8')
if(/(?:AUTH_KEY|crtfc_key|AppSecret|BotToken)\s*[=:]\s*["']?[A-Za-z0-9_\-]{16,}/i.test(text))errors.push(`possible committed secret material: ${file}`)
}
for(const f of ['backend/Shared/Configuration/KbxConfigurationStartupValidator.cs','backend/Shared/Configuration/KbxConfigurationFingerprint.cs','deploy/kbx/environment-matrix.generated.json','docs/configuration-reference.generated.md'])if(!fs.existsSync(f))errors.push(`missing v22 configuration artifact ${f}`)
if(errors.length){console.error('configuration governance FAIL');for(const e of errors)console.error(`- ${e}`);process.exit(1)}
console.log(`configuration governance PASS: settings=${src.settings.length}, environments=${src.environments.length}, provider credentials covered=${providerCredentialKeys.length}`)
@@ -0,0 +1,35 @@
import fs from 'node:fs'
const config=JSON.parse(fs.readFileSync('contracts/configuration/kbx.configuration.json','utf8'))
const artifact=JSON.parse(fs.readFileSync('deploy/kbx/release-artifact.contract.json','utf8'))
const errors=[]
if(!artifact.immutableArtifact)errors.push('release artifact must be immutable across promoted environments')
if(!artifact.environmentSpecificConfigurationExternal)errors.push('environment configuration must stay outside the promoted artifact')
if(JSON.stringify(artifact.promotionOrder)!==JSON.stringify(['Staging','Production']))errors.push('promotion order must remain Staging -> Production')
for(const evidence of ['generated/release-impact.json','generated/configuration-manifest.json','generated/test-scenario-manifest.json','generated/api-manifest.json'])if(!artifact.requiredEvidence.includes(evidence))errors.push(`release artifact evidence missing ${evidence}`)
const prod=config.environments.find(x=>x.id==='Production')
for(const gate of ['configuration-validation','migration-dry-run','release-governance'])if(!prod?.requiredGates.includes(gate))errors.push(`Production profile missing gate ${gate}`)
for(const env of config.environments){
const p=`deploy/kbx/environments/${env.id.toLowerCase()}.env.example`
if(!fs.existsSync(p)){errors.push(`missing environment example ${p}`);continue}
const text=fs.readFileSync(p,'utf8')
const envKey=`KBX__Runtime__Environment=${env.id}`
if(!text.includes(envKey))errors.push(`${p} must pin ${envKey}`)
if(['Staging','Production'].includes(env.id)&&!text.includes('KBX__Database__MigrationsMode=predeploy'))errors.push(`${p} must use predeploy migrations`)
if(env.id==='Production'&&!text.includes('KBX__Security__RequireHttps=true'))errors.push('production env example must require HTTPS')
}
const workflow=fs.readFileSync('.gitea/workflows/kbx-quality-gate.yaml','utf8')
if(!workflow.includes('generated/') && !workflow.includes('generated/configuration-manifest.json'))errors.push('quality gate drift list must include generated configuration manifest')
if(!workflow.includes('deploy/kbx/'))errors.push('quality gate drift list must include deployment contract outputs')
const readiness='.gitea/workflows/kbx-release-readiness.yaml'
if(!fs.existsSync(readiness))errors.push('missing release readiness workflow')
else {
const text=fs.readFileSync(readiness,'utf8')
for(const token of ['validate-kbx.mjs','migration','configuration'])if(!text.toLowerCase().includes(token.toLowerCase()))errors.push(`release readiness workflow missing ${token}`)
}
if(errors.length){console.error('deployment governance FAIL');for(const e of errors)console.error(`- ${e}`);process.exit(1)}
console.log(`deployment governance PASS: immutable artifact promotion, environments=${config.environments.length}`)
@@ -0,0 +1,28 @@
import fs from 'node:fs'
import { spawnSync } from 'node:child_process'
const tokens=JSON.parse(fs.readFileSync('generated/token-manifest.json','utf8'))
const figmaVars=JSON.parse(fs.readFileSync('design/figma/variables.contract.json','utf8'))
const figmaComponents=JSON.parse(fs.readFileSync('design/figma/components.contract.json','utf8'))
const catalog=JSON.parse(fs.readFileSync('generated/catalog-manifest.json','utf8'))
const requiredTokenPaths=[
'typography.size.xs','typography.size.sm','typography.size.md','typography.size.lg','typography.size.xl','typography.size.2xl',
'spacing.0','spacing.1','spacing.2','spacing.3','spacing.4','spacing.5','spacing.6','spacing.8','spacing.10','spacing.12',
'radius.sm','radius.md','radius.lg','semantic.surface.default','semantic.text.default','semantic.border.default','semantic.action.primary','semantic.status.success','semantic.status.warning','semantic.status.danger','semantic.focus',
'component.control.height','component.grid.rowHeight','component.grid.headerHeight','component.pageHeader.height','component.commandBar.height','component.form.labelWidth','component.touch.controlHeight'
]
const tokenSet=new Set(tokens.tokens.map(t=>t.path))
const missingTokens=requiredTokenPaths.filter(x=>!tokenSet.has(x))
if(missingTokens.length){console.error(`missing standard tokens: ${missingTokens.join(', ')}`);process.exit(1)}
for(const mode of ['compact','comfortable','touch'])if(!tokens.modes?.[mode]){console.error(`missing density mode: ${mode}`);process.exit(1)}
const catalogSet=new Set(catalog.map(x=>x.component))
const requiredDesign=figmaComponents.components.filter(x=>x.designRequired)
const missingCatalog=requiredDesign.filter(x=>!catalogSet.has(x.codeName))
if(missingCatalog.length){console.error(`core components missing catalog/design states: ${missingCatalog.map(x=>x.codeName).join(', ')}`);process.exit(1)}
const badNames=figmaComponents.components.filter(x=>x.figmaName!==`KBX/${x.figmaName.split('/')[1]}/${x.codeName}`)
if(badNames.length){console.error(`invalid Figma names: ${badNames.map(x=>x.codeName).join(', ')}`);process.exit(1)}
const varCount=Object.values(figmaVars.collections).reduce((n,a)=>n+a.length,0)
if(varCount!==tokens.tokens.length){console.error(`Figma variable count ${varCount} != token count ${tokens.tokens.length}`);process.exit(1)}
const debt=spawnSync(process.execPath,['scripts/measure-design-debt.mjs'],{stdio:'inherit'})
if(debt.status!==0)process.exit(debt.status??1)
console.log(`design-code parity PASS: ${tokens.tokens.length} tokens, ${requiredDesign.length} core components, ${figmaComponents.componentCount} total components`)
@@ -0,0 +1,22 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
const gen=spawnSync(process.execPath,['scripts/generate-experiment-contracts.mjs'],{stdio:'inherit'});if(gen.status!==0)process.exit(gen.status??1)
const source=JSON.parse(fs.readFileSync('contracts/experiments/kbx.experiments.json','utf8')), manifest=JSON.parse(fs.readFileSync('generated/experiment-manifest.json','utf8')), telemetry=JSON.parse(fs.readFileSync('contracts/telemetry/kbx.telemetry.json','utf8'))
const metrics=new Set(telemetry.metrics.map(x=>x.key)), errors=[]
if(source.principles.safeUxOnly!==true||source.principles.killSwitchRequired!==true||source.principles.exposureAfterRender!==true)errors.push('safe rollout principles are incomplete')
for(const x of ['permission','sensitive-data','domain-rule','validation','idempotency','workflow-safety','wms-scan-rule','inventory-truth'])if(!source.principles.prohibitedScopes.includes(x))errors.push(`missing prohibited experiment scope: ${x}`)
for(const exp of source.experiments){for(const m of [exp.primaryMetric,...exp.guardrails,...exp.globalGuardrails])if(!metrics.has(m.key))errors.push(`unknown metric ${m.key}`);if(exp.state==='running'&&exp.rolloutPercent===0)errors.push(`running experiment has zero rollout: ${exp.id}`)}
const running=new Map();for(const exp of source.experiments.filter(x=>x.state==='running'))running.set(exp.screenId,(running.get(exp.screenId)??0)+1);for(const [screen,count] of running)if(count>source.principles.maxRunningExperimentsPerScreen)errors.push(`concurrent experiment limit exceeded: ${screen}`)
const migration=fs.readFileSync('backend/Database/Migrations/20260808_012_kbx_experiment_rollout.sql','utf8');for(const table of ['kbx.experiment_runtime','kbx.experiment_assignments','kbx.experiment_audit'])if(!migration.includes(table))errors.push(`missing experiment table: ${table}`)
if(!migration.includes('experiment_id')||!migration.includes('experiment_variant'))errors.push('telemetry experiment context columns missing')
const assignment=fs.readFileSync('backend/Shared/Experiments/KbxExperimentAssignmentService.cs','utf8'), assignmentPolicy=fs.readFileSync('backend/Shared/Experiments/KbxExperimentAssignmentPolicy.cs','utf8');if(!assignmentPolicy.includes('SHA256')||!assignment.includes('experiment_assignments')||!assignment.includes('state!="running"')||!assignment.includes('continue;'))errors.push('stable/minimal server-authoritative assignment missing')
const runtime=fs.readFileSync('backend/Shared/Experiments/KbxExperimentRuntimeStore.cs','utf8');if(!runtime.includes("'rolled-back'")||!runtime.includes('experiment_audit')||!runtime.includes('publisher.PublishAsync'))errors.push('kill switch/audit/realtime rollback path missing')
const web=fs.readFileSync('apps/web/src/experiments/kbxExperimentClient.ts','utf8'), realtimeWeb=fs.readFileSync('apps/web/src/experiments/createExperimentRolloutConnection.ts','utf8');if(!web.includes("experiment.exposed")||!web.includes('assignment.enrolled')||!realtimeWeb.includes('ExperimentChanged'))errors.push('exposure/realtime frontend contract missing')
const telemetryRepo=fs.readFileSync('backend/Shared/Telemetry/KbxUxTelemetryRepository.cs','utf8');if(!telemetryRepo.includes('NormalizeExperimentContextAsync')||!telemetryRepo.includes('KbxExperimentAssignmentPolicy.IsEnrolled'))errors.push('server-side experiment telemetry attribution validation missing')
if(!fs.existsSync('backend/Shared/Experiments/KbxExperimentRealtime.cs'))errors.push('SignalR rollout invalidation missing')
const page=fs.readFileSync('apps/web/src/modules/oms/orders/search/OrderListPage.vue','utf8');if(!page.includes('exp.oms.order-list.exception-summary-v2'))errors.push('reference experiment integration missing')
if(!fs.existsSync('apps/web/src/modules/common/experiments/ExperimentsPage.vue'))errors.push('COMMON-EXP-001 missing')
if(manifest.featureFlags.length!==source.featureFlags.length||manifest.experiments.length!==source.experiments.length)errors.push('experiment manifest drift')
if(errors.length){console.error('KBX experiment governance FAILED');errors.forEach(x=>console.error(' - '+x));process.exit(1)}
console.log(`KBX experiment governance PASS (${manifest.featureFlags.length} flags, ${manifest.experiments.length} experiments, safe scopes enforced)`)
@@ -0,0 +1,36 @@
import fs from 'node:fs'
const read=p=>JSON.parse(fs.readFileSync(p,'utf8'))
const src=read('contracts/external-data/kbx.external-data.json')
const providers=read('contracts/providers/kbx.providers.json')
const api=read('contracts/api/kbx.api.json')
const auth=read('contracts/authorization/kbx.authorization.json')
const errors=[]
const providerMap=new Map(providers.providers.map(x=>[x.id,x]))
const operationIds=new Set(providers.providers.flatMap(p=>(p.operations??[]).map(o=>o.id)))
const ids=new Set()
for(const d of src.datasets??[]){
if(ids.has(d.id))errors.push(`duplicate dataset ${d.id}`);ids.add(d.id)
if(!providerMap.has(d.providerId))errors.push(`${d.id}: unknown provider ${d.providerId}`)
if(!operationIds.has(d.providerOperationId))errors.push(`${d.id}: unknown provider operation ${d.providerOperationId}`)
if(!['strict','stale-while-revalidate','provider-defined'].includes(d.freshness?.mode))errors.push(`${d.id}: invalid freshness mode`)
if(d.freshness?.mode==='strict' && (d.freshness.maxStaleSeconds??0)!==0)errors.push(`${d.id}: strict dataset cannot serve stale fallback`)
if(d.snapshot?.rawRetention!=='hash-only')errors.push(`${d.id}: v21 reference datasets must retain payload hash only unless licensing/security policy is explicitly reviewed`)
if(!d.normalizerVersion)errors.push(`${d.id}: normalizer version required`)
}
const kis=src.datasets.find(x=>x.id==='dataset.kis.domestic-stock.current-price')
if(!kis||kis.freshness.mode!=='strict'||kis.freshness.maxStaleSeconds!==0)errors.push('KIS current-price must stay strict/no stale fallback')
const krx=src.datasets.find(x=>x.id==='dataset.krx.approved-service')
if(!krx||krx.freshness.mode!=='provider-defined'||krx.freshness.freshForSeconds!==null||!krx.freshness.requireExplicitServicePolicy)errors.push('KRX approved-service must not invent a universal freshness TTL')
for(const p of ['common.externalData.status','common.externalData.refresh'])if(!api.operations.some(x=>x.id===p))errors.push(`missing external-data API ${p}`)
for(const p of ['common.external-data.read','common.external-data.refresh'])if(!auth.permissions.some(x=>x.id===p))errors.push(`missing external-data permission ${p}`)
for(const f of ['backend/Database/Migrations/20260808_014_kbx_external_data.sql','backend/Shared/ExternalData/KbxExternalDataRepository.cs','backend/Shared/ExternalData/KbxExternalDataFreshnessPolicy.cs','backend/Shared/ExternalData/KbxExternalDataRetentionJob.cs','packages/kbx-ui/src/components/KbxDataProvenance.vue','apps/web/src/modules/common/external-data/external-data.definition.ts'])if(!fs.existsSync(f))errors.push(`missing v21 artifact ${f}`)
const manifest=read('generated/external-data-manifest.json')
if(manifest.datasets.length!==src.datasets.length)errors.push('external-data manifest count drift')
const ts=fs.readFileSync('packages/kbx-contracts/src/generated/externalDataCatalog.ts','utf8'),cs=fs.readFileSync('backend/Shared/ExternalData/Generated/KbxExternalDataCatalog.g.cs','utf8')
if(!ts.includes(manifest.sourceSha256)||!cs.includes(manifest.sourceSha256))errors.push('external-data generated source SHA parity missing')
const migration=fs.readFileSync('backend/Database/Migrations/20260808_014_kbx_external_data.sql','utf8')
for(const token of ['request_descriptor','payload_sha256','provider_observed_at','received_at','ingested_at','fresh_until','usable_until','normalizer_version'])if(!migration.includes(token))errors.push(`external data migration missing ${token}`)
if(/(AUTH_KEY|crtfc_key|appsecret|access_token)/i.test(migration))errors.push('external data persistence must not contain provider secrets')
if(/raw_payload\s+(?:jsonb|text|bytea)/i.test(migration))errors.push('raw provider payload column is forbidden in v21 default persistence')
if(errors.length){console.error('external-data governance FAIL');for(const e of errors)console.error(`- ${e}`);process.exit(1)}
console.log(`external-data governance PASS: datasets=${src.datasets.length}, provenance/freshness/raw-retention boundaries verified`)
@@ -0,0 +1,61 @@
import fs from 'node:fs'
import { spawnSync } from 'node:child_process'
const source = JSON.parse(fs.readFileSync('contracts/fields/kbx.fields.json', 'utf8'))
const errors = []
const seen = new Set()
const aliasOwners = new Map()
const types = new Set(['text','code','integer','decimal','quantity','money','date','datetime','boolean','lookup','status'])
if (!/^\d+\.\d+\.\d+$/.test(source.version ?? '')) errors.push('field dictionary version must be semver')
for (const field of source.fields ?? []) {
if (!/^[a-z][A-Za-z0-9]*$/.test(field.key ?? '')) errors.push(`invalid field key: ${field.key}`)
if (seen.has(field.key)) errors.push(`duplicate field key: ${field.key}`)
seen.add(field.key)
if (!types.has(field.dataType)) errors.push(`invalid data type: ${field.key} ${field.dataType}`)
if ((field.dataType === 'money' || field.dataType === 'quantity' || field.dataType === 'decimal') && (field.precision == null || field.scale == null))
errors.push(`${field.key}: numeric field requires precision and scale`)
if (field.dataType === 'lookup' && !field.lookupEntity) errors.push(`${field.key}: lookup field requires lookupEntity`)
if (field.readonly && field.importable) errors.push(`${field.key}: readonly field cannot be importable`)
if (field.sensitive && !field.masking) errors.push(`${field.key}: sensitive field requires masking metadata`)
if (field.deprecated && !field.replacementKey) errors.push(`${field.key}: deprecated field requires replacementKey`)
for (const alias of [field.key, ...(field.aliases ?? [])]) {
const normalized = alias.trim().toLocaleLowerCase('ko-KR')
if (!normalized) continue
const owner = aliasOwners.get(normalized)
// Collision is only dangerous when both fields can participate in generic import mapping.
if (owner && owner !== field.key) {
const other = source.fields.find(x => x.key === owner)
if (field.importable && other?.importable) errors.push(`import alias collision: "${alias}" => ${owner}, ${field.key}`)
} else aliasOwners.set(normalized, field.key)
}
}
const result = spawnSync(process.execPath, ['scripts/generate-field-contracts.mjs'], { stdio: 'inherit' })
if (result.status !== 0) process.exit(result.status ?? 1)
const manifest = JSON.parse(fs.readFileSync('generated/field-manifest.json', 'utf8'))
if (manifest.count !== source.fields.length) errors.push('generated field manifest count drift')
const ts = fs.readFileSync('packages/kbx-contracts/src/generated/fieldCatalog.ts', 'utf8')
const cs = fs.readFileSync('backend/Shared/Contracts/Generated/KbxFieldCatalog.g.cs', 'utf8')
for (const field of source.fields) {
if (!ts.includes(JSON.stringify(field.key))) errors.push(`TS field missing: ${field.key}`)
if (!cs.includes(`"${field.key}"`)) errors.push(`C# field missing: ${field.key}`)
}
const orderImportTs = fs.readFileSync('apps/web/src/modules/oms/orders/import/order-import.definition.ts', 'utf8')
const orderImportCs = fs.readFileSync('backend/Modules/OMS/Orders/Import/OrderImportDefinition.cs', 'utf8')
if (!orderImportTs.includes("kbxImportField('orderQty')")) errors.push('OMS order import must consume canonical orderQty metadata on frontend')
if (!orderImportCs.includes('I(KbxFieldKeys.OrderQty)')) errors.push('OMS order import must consume canonical orderQty metadata on backend')
if (/\bfield:\s*['"]quantity['"]/.test(fs.readFileSync('apps/web/src/modules/oms/orders/register/order-register.definition.ts','utf8'))) errors.push('OMS order register still uses deprecated quantity field key')
if (!fs.existsSync('backend/Shared/Contracts/KbxFieldOpenApiSchemaFilter.cs')) errors.push('OpenAPI field contract filter is missing')
if (errors.length) {
console.error('field governance FAIL')
for (const e of errors) console.error(`- ${e}`)
process.exit(1)
}
const adoption = spawnSync(process.execPath, ['scripts/report-field-adoption.mjs'], { stdio: 'inherit' })
if (adoption.status !== 0) process.exit(adoption.status ?? 1)
console.log(`field governance PASS: ${source.fields.length} canonical fields`)
@@ -0,0 +1,14 @@
import fs from 'node:fs'
const read=p=>fs.readFileSync(p,'utf8');const failures=[]
const fast=read('apps/web/src/modules/erp/item-prices/ItemPriceFastEntryPage.vue')
for(const t of ['KbxFastEntryPage','KbxDataGrid','KbxLookupDialog','allowRowAdd:true','allowRowDuplicate:true','fillDown:true','paste:true','errorNavigation:true'])if(!fast.includes(t))failures.push(`ERP-PRICE-001 missing ${t}`)
const priceDef=read('apps/web/src/modules/erp/item-prices/item-price.definition.ts');for(const t of ["type: 'fast-entry'","id: 'ERP-PRICE-001'","shortcut: 'F8'"])if(!priceDef.includes(t))failures.push(`price screen definition missing ${t}`)
const priceApi=read('backend/Modules/ERP/ItemPrices/BulkSave/Endpoint.cs');for(const t of ['Idempotency-Key','erp.item_prices','ITEM_PRICE_SAVED','ErpItemPriceChanged','command_receipts','DUPLICATE_ITEM_DATE'])if(!priceApi.includes(t))failures.push(`price backend missing ${t}`)
const master=read('packages/kbx-ui/src/components/KbxMasterDetailPage.vue');for(const t of ['masterSize','master-header','detail-header','bottom-header','contextText'])if(!master.includes(t))failures.push(`MasterDetail template missing ${t}`)
const grid=read('packages/kbx-ui/src/components/KbxDataGrid.vue');for(const t of ['activeRowKey','syncActiveRow','ensureNodeVisible'])if(!grid.includes(t))failures.push(`DataGrid active-row contract missing ${t}`)
const inv=read('apps/web/src/modules/erp/inventory/InventoryPage.vue');for(const t of ['historyQuery','active-row-key','drill-down-requested','KbxDrawer','previous=selectedItemId.value'])if(!inv.includes(t))failures.push(`ERP-INV-001 context/history missing ${t}`)
const history=read('backend/Modules/ERP/Inventory/Search/HistoryEndpoint.cs');for(const t of ['erp_inventory_ledger_projection','occurred_at desc','erp.inventory.read'])if(!history.includes(t))failures.push(`inventory history endpoint missing ${t}`)
const routes=read('apps/web/src/router/appRoutes.ts');if(!routes.includes("screenId:'ERP-PRICE-001'"))failures.push('ERP-PRICE-001 route missing')
const nav=read('apps/web/src/shell/navigationCatalog.ts');if(!nav.includes("screenId:'ERP-PRICE-001'"))failures.push('ERP-PRICE-001 navigation missing')
if(failures.length){console.error('KBX v26 Golden Screen validation failed:\n- '+failures.join('\n- '));process.exit(1)}
console.log('Golden T04/T05 PASS: independent Fast Entry, server-safe bulk save, Master/Detail context, history and drill-down.')
@@ -0,0 +1,41 @@
import fs from 'node:fs'
const read=p=>fs.readFileSync(p,'utf8')
const failures=[]
const contract=read('packages/kbx-contracts/src/grid.ts')
for(const token of ['KbxGridEditingPolicy','KbxGridPasteResult','KbxBulkSelectionRequest',"'all-filtered'",'drilldown'])if(!contract.includes(token))failures.push(`grid contract missing ${token}`)
const utility=read('packages/kbx-ui/src/grid/editing.ts')
for(const token of ['normalizeKbxGridClipboardData','normalizeKbxGridClipboardValue','toKbxBulkSelectionRequest','INVALID_NUMBER','INVALID_DATE','INVALID_BOOLEAN'])if(!utility.includes(token))failures.push(`grid editing utility missing ${token}`)
const grid=read('packages/kbx-ui/src/components/KbxDataGrid.vue')
for(const token of ['allowAllFilteredSelection','selectionStateChanged','rowAddRequested','rowDuplicateRequested','fillDownApplied','pasteProcessed','errorFocusChanged','contextRequested','drillDownRequested','processClipboardData','검색결과','첫/다음 오류'])if(!grid.includes(token))failures.push(`KbxDataGrid productivity missing ${token}`)
const fast=read('packages/kbx-ui/src/components/KbxFastEntryPage.vue')
for(const token of ['Ctrl+V','Enter','F2','오류는 Grid에서 바로 이동'])if(!fast.includes(token))failures.push(`KbxFastEntryPage guide missing ${token}`)
const orderRegister=read('apps/web/src/modules/oms/orders/register/OrderRegisterPage.vue')
for(const token of ['selection="multiple"','allowRowAdd:true','allowRowDuplicate:true','fillDown:true','paste:true','errorNavigation:true','row-add-requested','row-duplicate-requested'])if(!orderRegister.includes(token))failures.push(`OMS-ORD-002 fast-entry adoption missing ${token}`)
const orderSearchVm=read('apps/web/src/modules/oms/orders/search/useOrderSearch.ts')
for(const token of ['appliedSearch','currentBulkFilter','appliedSearch.value={...search}'])if(!orderSearchVm.includes(token))failures.push(`OMS-ORD-001 applied-filter safety missing ${token}`)
const orderList=read('apps/web/src/modules/oms/orders/search/OrderListPage.vue')
for(const token of ['allow-all-filtered-selection','selection-state','total-count','selection-state-changed'])if(!orderList.includes(token))failures.push(`OMS-ORD-001 all-filtered adoption missing ${token}`)
const searchRequest=read('backend/Modules/OMS/Orders/Search/Request.cs')
const searchHandler=read('backend/Modules/OMS/Orders/Search/Handler.cs')
if(!searchRequest.includes('bool ExceptionOnly'))failures.push('SearchOrdersRequest must carry ExceptionOnly')
if(!searchHandler.includes('@ExceptionOnly = false or o.exception_count > 0'))failures.push('SearchOrdersHandler must enforce ExceptionOnly')
const ship=read('backend/Modules/OMS/Orders/Ship/Endpoint.cs')
for(const token of ['ShipOrdersFilter','mode is not ("ids" or "filter")','oms_order_search_projection','ExcludedIds','target as materialized','accepted as materialized','audit_insert','outbox_insert'])if(!ship.includes(token))failures.push(`ship server-side bulk contract missing ${token}`)
const catalog=read('packages/kbx-ui/src/catalog/componentCatalog.ts')
for(const token of ["id:'fast-entry'","id:'all-filtered'"])if(!catalog.includes(token))failures.push(`catalog missing ${token}`)
const tests=read('tests/unit/kbx-grid-productivity.contract.spec.ts')+read('tests/e2e/kbx-grid-fast-entry.spec.ts')
for(const token of ['normalizes Excel-like pasted values','does not expand all-filtered selection','server-side whole-result selection'])if(!tests.includes(token))failures.push(`grid productivity test evidence missing ${token}`)
if(failures.length){console.error('KBX grid productivity validation failed:\n- '+failures.join('\n- '));process.exit(1)}
console.log('Grid productivity PASS: Fast Entry, paste normalization, Fill Down, error navigation, drill-down/context hooks, all-filtered server bulk selection.')
@@ -0,0 +1,46 @@
import fs from 'node:fs'
const read=p=>JSON.parse(fs.readFileSync(p,'utf8'))
const src=read('contracts/integrations/kbx.integrations.json')
const manifest=read('generated/integration-manifest.json')
const api=read('generated/api-manifest.json')
const perms=read('generated/permission-manifest.json')
const scenarios=read('generated/test-scenario-manifest.json')
const errors=[]
const ids=new Set()
const allowedTransport=new Set(['outbox-http','outbox-event','inbox-event'])
const allowedTerminal=new Set(['operations-exception','dead-letter'])
for(const x of src.integrations??[]){
if(ids.has(x.id))errors.push(`duplicate integration ${x.id}`);ids.add(x.id)
if(!/^integration\.[a-z0-9.-]+$/.test(x.id))errors.push(`invalid integration id ${x.id}`)
if(!allowedTransport.has(x.transport))errors.push(`${x.id}: invalid transport ${x.transport}`)
if(!allowedTerminal.has(x.terminalAction))errors.push(`${x.id}: invalid terminalAction`)
if(x.delivery==='at-least-once'&&x.idempotency==='none')errors.push(`${x.id}: at-least-once requires idempotency`)
if(x.shortRetry.maxRetryAttempts>3)errors.push(`${x.id}: short retry must stay bounded; use Hangfire for durable retry`)
if(x.longRetry.scheduler!=='hangfire')errors.push(`${x.id}: durable retry must use Hangfire`)
if(x.longRetry.scheduleSeconds.length!==x.longRetry.maxAttempts)errors.push(`${x.id}: durable schedule/attempt count mismatch`)
if(x.criticality==='high'&&x.terminalAction!=='operations-exception')errors.push(`${x.id}: high criticality must surface in operations exception`)
for(const code of x.retryable??[])if((x.permanent??[]).includes(code))errors.push(`${x.id}: failure classification overlaps ${code}`)
}
if(manifest.integrations.length!==src.integrations.length)errors.push('generated integration manifest count drift')
const ts=fs.readFileSync('packages/kbx-contracts/src/generated/integrationCatalog.ts','utf8')
const cs=fs.readFileSync('backend/Shared/Integrations/Generated/KbxIntegrationCatalog.g.cs','utf8')
if(!ts.includes(manifest.sourceSha256)||!cs.includes(manifest.sourceSha256))errors.push('integration source SHA parity missing')
for(const id of ['common.integrations.getAttempt','common.integrations.retryAttempt'])if(!(api.operations??[]).some(x=>x.id===id))errors.push(`missing integration API ${id}`)
for(const id of ['common.integration.read','common.integration.retry'])if(!(perms.permissions??[]).some(x=>x.id===id))errors.push(`missing integration permission ${id}`)
for(const id of ['scenario.integration.outbox.transient-retry','scenario.integration.outbox.permanent-failure','scenario.integration.inbox.duplicate','scenario.integration.manual-retry.idempotent'])if(!(scenarios.scenarios??[]).some(x=>x.id===id))errors.push(`missing integration scenario ${id}`)
const migration=fs.readFileSync('backend/Database/Migrations/20260808_013_kbx_integration_resilience.sql','utf8')
for(const required of ['integration_attempts','integration_receipts','unique (tenant_id, integration_id, message_id, attempt_no)'])if(!migration.includes(required))errors.push(`migration missing ${required}`)
const dispatcher=fs.readFileSync('backend/Shared/Integrations/KbxOutboxIntegrationDispatcher.cs','utf8')
if(/while\s*\(true\)|RetryForever|Thread\.Sleep/.test(dispatcher))errors.push('integration dispatcher contains unbounded in-process retry')
const component=fs.readFileSync('packages/kbx-ui/src/components/KbxIntegrationState.vue','utf8')
const catalog=fs.readFileSync('packages/kbx-ui/src/catalog/componentCatalog.ts','utf8')
for(const label of ['전송 대기','자동 재시도','연계 실패'])if(!component.includes(label))errors.push(`KbxIntegrationState missing label ${label}`)
if(!catalog.includes("component:'KbxIntegrationState'"))errors.push('KbxIntegrationState missing from component catalog')
const polly=fs.readFileSync('backend/Shared/Integrations/KbxPollyIntegrationPipeline.cs','utf8')
for(const token of ['ResiliencePipelineBuilder','AddRetry','AddTimeout','AddCircuitBreaker'])if(!polly.includes(token))errors.push(`Polly pipeline missing ${token}`)
const projector=fs.readFileSync('backend/Shared/Integrations/KbxIntegrationOperationsProjector.cs','utf8')
if(!projector.includes('INTEGRATION_FAILED')||!projector.includes('OperationsProjectionWriter'))errors.push('permanent integration failure is not projected to Operations Center')
const retryEndpoint=fs.readFileSync('backend/Modules/Common/Integrations/Attempts/Retry/Endpoint.cs','utf8')
if(!retryEndpoint.includes('kbx.command_receipts')||!retryEndpoint.includes('Idempotency-Key'))errors.push('manual integration retry lacks command-receipt idempotency')
if(errors.length){console.error('integration governance FAIL');for(const e of errors)console.error(`- ${e}`);process.exit(1)}
console.log(`integration governance PASS: integrations=${src.integrations.length}, scenarios=4, bounded short retry + durable Hangfire retry`)
@@ -0,0 +1,46 @@
import fs from 'node:fs'
const failures=[]
const read=p=>fs.readFileSync(p,'utf8')
const ui=read('packages/kbx-contracts/src/ui.ts')
for(const token of ["'changed'","'warning'","'ai-suggested'","'application'","'page'","'grid'","'dialog'","'editor'"])if(!ui.includes(token))failures.push(`ui interaction contract missing ${token}`)
const stateful=[
'KbxInput.vue','KbxNumberField.vue','KbxDateField.vue','KbxDateRange.vue','KbxSelect.vue','KbxTextarea.vue','KbxLookup.vue','KbxCheckbox.vue','KbxRadio.vue','KbxTextField.vue','KbxBarcodeField.vue','KbxMoneyField.vue','KbxQuantityField.vue',
]
for(const file of stateful){
const text=read(`packages/kbx-ui/src/components/${file}`)
if(!text.includes('KbxFieldState'))failures.push(`${file} must consume KbxFieldState`)
}
const searchContract=read('packages/kbx-contracts/src/search.ts')
if(!searchContract.includes("'checkbox'"))failures.push('KbxSearchFieldType must include checkbox')
const search=read('packages/kbx-ui/src/components/KbxSearchPanel.vue')
for(const token of ['rememberChecked','savedSearchRequested',"field.type==='checkbox'",'field.defaultValue'])if(!search.includes(token))failures.push(`KbxSearchPanel missing ${token}`)
const grid=read('packages/kbx-ui/src/components/KbxDataGrid.vue')
for(const token of ['KbxDataState','changedCells','personalization','savedPreference','preferenceChanged','exportable','emptyText','errorText','savePreference','KbxSummaryBar'])if(!grid.includes(token))failures.push(`KbxDataGrid missing ${token}`)
const keyboard=read('packages/kbx-ui/src/keyboard/manager.ts')
for(const token of ['scopeRank','protectedBrowserShortcuts','F5','CTRL+L','CTRL+T','CTRL+W','CTRL+R','registerKbxShortcuts'])if(!keyboard.includes(token))failures.push(`keyboard manager missing ${token}`)
const composable=read('packages/kbx-ui/src/composables/useKbxShortcuts.ts')
if(!composable.includes('registerKbxShortcuts'))failures.push('useKbxShortcuts must delegate to central keyboard manager')
for(const file of ['apps/web/src/shell/KbxAppFrame.vue','packages/kbx-ui/src/components/KbxExceptionDetailDrawer.vue']){
if(read(file).includes("addEventListener('keydown'"))failures.push(`${file} bypasses central keyboard manager`)
}
const tabs=read('packages/kbx-ui/src/components/KbxTabs.vue')
for(const token of ['ArrowRight','ArrowLeft','Home','End','aria-controls','tabpanel'])if(!tabs.includes(token))failures.push(`KbxTabs missing ${token}`)
const index=read('packages/kbx-ui/src/index.ts')
if(!index.includes('KbxDataState'))failures.push('public API missing KbxDataState')
const manifest=read('packages/kbx-ui/src/registry/componentManifest.ts')
if(!manifest.includes("name: 'KbxDataState'"))failures.push('component manifest missing KbxDataState')
const catalog=read('packages/kbx-ui/src/catalog/componentCatalog.ts')
if(!catalog.includes("component:'KbxDataState'"))failures.push('component catalog missing KbxDataState')
for(const state of ["state:'changed'","state:'warning'","state:'ai-suggested'","state:'empty'"])if(!catalog.includes(state))failures.push(`component catalog missing ${state}`)
if(failures.length){console.error('KBX interaction contract validation failed:\n- '+failures.join('\n- '));process.exit(1)}
console.log(`Interaction contracts PASS: ${stateful.length} stateful inputs, SearchPanel, DataGrid runtime states, scoped keyboard manager, accessible Tabs.`)
@@ -0,0 +1,61 @@
import { spawnSync } from 'node:child_process'
for (const script of [
'generate-design-tokens.mjs',
'generate-screen-recipe-contracts.mjs',
'generate-api-contracts.mjs',
'generate-integration-contracts.mjs',
'generate-provider-contracts.mjs',
'generate-external-data-contracts.mjs',
'generate-configuration-contracts.mjs',
'generate-authorization-contracts.mjs',
'generate-telemetry-contracts.mjs',
'generate-experiment-contracts.mjs',
'generate-test-contracts.mjs',
'generate-field-contracts.mjs',
'generate-screen-manifest.mjs',
'generate-component-manifest.mjs',
'generate-navigation-manifest.mjs',
'generate-app-screen-registry.mjs',
'generate-catalog-manifest.mjs',
'generate-figma-contract.mjs',
'validate-screen-governance.mjs',
'validate-field-governance.mjs',
'validate-api-governance.mjs',
'validate-integration-governance.mjs',
'validate-provider-governance.mjs',
'validate-external-data-governance.mjs',
'validate-configuration-governance.mjs',
'validate-migration-safety.mjs',
'validate-deployment-governance.mjs',
'validate-authorization-governance.mjs',
'validate-telemetry-governance.mjs',
'validate-experiment-governance.mjs',
'validate-test-governance.mjs',
'validate-scaffolder.mjs',
'validate-typescript-syntax.mjs',
'validate-architecture.mjs',
'validate-operations.mjs',
'validate-process-coverage.mjs',
'validate-production-readiness.mjs',
'validate-application-shell.mjs',
'validate-template-completeness.mjs',
'validate-interaction-contracts.mjs',
'validate-grid-productivity.mjs',
'validate-golden-fast-master-detail.mjs',
'validate-record-lifecycle.mjs',
'validate-template-navigation-v29.mjs',
'validate-template-navigation-v30.mjs',
'validate-template-navigation-v31.mjs',
'validate-template-navigation-v32.mjs',
'validate-template-navigation-v33.mjs',
'validate-template-navigation-v34.mjs',
'validate-template-navigation-v35.mjs',
'validate-template-navigation-v36.mjs',
'validate-component-catalog.mjs',
'validate-design-code-parity.mjs',
'validate-release-governance.mjs',
]) {
const result = spawnSync(process.execPath, [`scripts/${script}`], { stdio: 'inherit' })
if (result.status !== 0) process.exit(result.status ?? 1)
}
@@ -0,0 +1,28 @@
import fs from 'node:fs'
const livePath=process.env.KBX_LIVE_OPENAPI || 'artifacts/openapi.json'
if(!fs.existsSync(livePath)){
console.log(`Live OpenAPI snapshot not found at ${livePath}; host DTO/schema gate is deferred until the .NET host is available.`)
process.exit(0)
}
const contract=JSON.parse(fs.readFileSync('generated/api-manifest.json','utf8'))
const live=JSON.parse(fs.readFileSync(livePath,'utf8'))
const actual=new Map()
for(const [path,item] of Object.entries(live.paths??{})){
for(const method of ['get','post','put','patch','delete']){
const op=item?.[method];if(!op)continue
if(op.operationId)actual.set(op.operationId,{method:method.toUpperCase(),path,op})
}
}
const errors=[]
const normalize=p=>p.replace(/\{([^}:]+):[^}]+\}/g,'{$1}').toLowerCase()
for(const expected of contract.operations){
const got=actual.get(expected.id)
if(!got){errors.push(`live OpenAPI missing operationId ${expected.id}`);continue}
if(got.method!==expected.method || normalize(got.path)!==normalize(expected.path))errors.push(`live route drift ${expected.id}: ${got.method} ${got.path}`)
if((got.op['x-kbx-permission']??null)!==(expected.permission??null))errors.push(`live permission drift ${expected.id}`)
if((got.op['x-kbx-idempotency']??'none')!==expected.idempotency)errors.push(`live idempotency drift ${expected.id}`)
for(const status of ['400','403','404','409','422','500'])if(!got.op.responses?.[status])errors.push(`live Problem response ${status} missing: ${expected.id}`)
}
if(errors.length){console.error(errors.map(x=>`LIVE OPENAPI ERROR: ${x}`).join('\n'));process.exit(1)}
console.log(`Live OpenAPI contract PASS: ${contract.operationCount} operations with KBX metadata/problem responses.`)
@@ -0,0 +1,16 @@
import fs from 'node:fs'
import path from 'node:path'
const dir='backend/Database/Migrations'
const errors=[]
const destructive=/\b(?:drop\s+(?:table|column|schema|index|constraint)|truncate\s+(?:table\s+)?|alter\s+table[\s\S]{0,120}?\bdrop\b)\b/i
const approval=/KBX-DESTRUCTIVE-MIGRATION-APPROVED:\s*\S+/i
for(const name of fs.readdirSync(dir).filter(x=>x.endsWith('.sql')).sort()){
const p=path.join(dir,name),text=fs.readFileSync(p,'utf8')
if(destructive.test(text)&&!approval.test(text))errors.push(`destructive migration requires explicit approval marker + migration guide: ${name}`)
}
const config=JSON.parse(fs.readFileSync('contracts/configuration/kbx.configuration.json','utf8'))
const prod=config.environments.find(x=>x.id==='Production')
if(prod?.migrationStrategy!=='predeploy')errors.push('Production migration strategy must remain predeploy')
if(errors.length){console.error('migration safety FAIL');for(const e of errors)console.error(`- ${e}`);process.exit(1)}
console.log(`migration safety PASS: ${fs.readdirSync(dir).filter(x=>x.endsWith('.sql')).length} migrations, destructive operations guarded`)
@@ -0,0 +1,45 @@
import fs from 'node:fs'
import path from 'node:path'
const root = process.cwd()
const errors = []
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'generated/screen-manifest.json'), 'utf8'))
for (const required of ['COMMON-OPS-001', 'COMMON-REC-001']) {
if (!manifest.some(x => x.id === required)) errors.push(`missing required operations screen ${required}`)
}
const migration = fs.readFileSync(path.join(root, 'backend/Database/Migrations/20260808_005_kbx_operations_reconcile.sql'), 'utf8')
for (const token of ['kbx.work_items', 'kbx.work_item_audit', 'kbx.reconcile_items', 'identity_key', 'reference_no', 'source_screen_id', 'source_version']) {
if (!migration.includes(token)) errors.push(`operations migration missing ${token}`)
}
function walk(dir) {
if (!fs.existsSync(dir)) return []
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name)
return entry.isDirectory() ? walk(full) : [full]
})
}
const operationalBackend = [
...walk(path.join(root, 'backend/Modules/Common/Operations')),
...walk(path.join(root, 'backend/Modules/Common/Reconcile')),
].filter(x => x.endsWith('.cs'))
const forbiddenDomainWrite = /\b(?:update|insert\s+into|delete\s+from)\s+(?:oms_|wms_|erp_)/i
for (const file of operationalBackend) {
const text = fs.readFileSync(file, 'utf8')
if (forbiddenDomainWrite.test(text)) errors.push(`${path.relative(root, file)} writes a source-module table directly`)
}
const projection = fs.readFileSync(path.join(root, 'backend/Shared/Operations/OperationsProjectionWriter.cs'), 'utf8')
if (!projection.includes('source_version')) errors.push('work-item projection lacks source_version out-of-order protection')
const reconcile = fs.readFileSync(path.join(root, 'backend/Shared/Operations/ReconcileProjectionWriter.cs'), 'utf8')
if (!reconcile.includes('excluded.occurred_at >= kbx.reconcile_items.occurred_at')) errors.push('reconcile projection lacks stale-observation protection')
if (errors.length) {
console.error('KBX operations validation failed:')
for (const error of errors) console.error(`- ${error}`)
process.exit(1)
}
console.log(`KBX operations validation passed (${operationalBackend.length} operational backend files).`)
@@ -0,0 +1,10 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd(); const required=['OMS-CLM-001','ERP-PUR-001','ERP-INV-MOVE-001','WMS-REC-001','WMS-PUT-001','WMS-COUNT-001'];
const manifest=JSON.parse(fs.readFileSync(path.join(root,'generated/screen-manifest.json'),'utf8')); const ids=new Set(manifest.map(x=>x.id)); const errors=[];
for(const id of required) if(!ids.has(id)) errors.push(`${id}: missing`);
const wf=fs.readFileSync(path.join(root,'packages/kbx-contracts/src/workflow.ts'),'utf8'); if(!wf.includes('KbxWorkflowDefinition')) errors.push('workflow contract missing');
const ui=fs.readFileSync(path.join(root,'packages/kbx-ui/src/index.ts'),'utf8'); if(!ui.includes('KbxWorkflowBar')) errors.push('KbxWorkflowBar export missing');
const migration=fs.readFileSync(path.join(root,'backend/Database/Migrations/20260808_007_process_coverage.sql'),'utf8'); for(const token of ['oms.claims','erp.purchase_orders','erp.inventory_moves']) if(!migration.includes(token)) errors.push(`${token}: migration missing`);
if(errors.length){console.error('KBX process coverage FAILED'); errors.forEach(x=>console.error(' - '+x)); process.exit(1)} console.log('KBX process coverage PASS ('+required.length+' reference screens)')
@@ -0,0 +1,46 @@
import fs from 'node:fs'
import path from 'node:path'
const root = process.cwd()
const required = [
'packages/kbx-contracts/src/runtime.ts',
'packages/kbx-contracts/src/performance.ts',
'packages/kbx-ui/src/components/KbxRuntimeBanner.vue',
'packages/kbx-ui/src/components/KbxFreshnessIndicator.vue',
'packages/kbx-ui/src/components/KbxConflictResolver.vue',
'packages/kbx-ui/src/components/KbxOperationCenter.vue',
'packages/kbx-ui/src/components/KbxNotificationCenter.vue',
'apps/web/src/runtime/KbxRuntimeShell.vue',
'apps/web/src/http/kbxHttpClient.ts',
'backend/Database/Migrations/20260808_008_kbx_runtime_operations.sql',
'backend/Shared/Runtime/KbxOperationPolicy.cs',
'backend/Shared/Runtime/KbxTelemetry.cs',
'backend/Shared/Runtime/KbxCorrelationMiddleware.cs',
'docs/production-readiness-v9.md',
'docs/failure-recovery-matrix-v9.md',
'docs/performance-budget-v9.md',
'docs/observability-runtime-v9.md',
]
const missing = required.filter(file => !fs.existsSync(path.join(root, file)))
if (missing.length) {
console.error('KBX v9 production readiness files missing:\n' + missing.join('\n'))
process.exit(1)
}
const migration = fs.readFileSync(path.join(root, 'backend/Database/Migrations/20260808_008_kbx_runtime_operations.sql'), 'utf8')
for (const token of ['kbx.operation_runs', 'kbx.user_notifications', 'kbx.runtime_incidents', 'idempotency_key', 'correlation_id']) {
if (!migration.includes(token)) throw new Error(`runtime migration missing ${token}`)
}
const http = fs.readFileSync(path.join(root, 'apps/web/src/http/kbxHttpClient.ts'), 'utf8')
if (/interceptors\.response[\s\S]*axios\([^)]*\)/.test(http)) {
throw new Error('response interceptor must not implement hidden mutation retries')
}
if (!http.includes('Idempotency-Key')) throw new Error('idempotency helper missing')
if (!http.includes('X-Correlation-Id')) throw new Error('correlation id propagation missing')
const policy = fs.readFileSync(path.join(root, 'backend/Shared/Runtime/KbxOperationPolicy.cs'), 'utf8')
if (!policy.includes('hasIdempotencyKey')) throw new Error('server retry policy must require idempotency for mutation')
console.log('production readiness guard PASS')
@@ -0,0 +1,39 @@
import fs from 'node:fs'
const read=p=>JSON.parse(fs.readFileSync(p,'utf8'))
const src=read('contracts/providers/kbx.providers.json')
const manifest=read('generated/provider-manifest.json')
const errors=[]
const ids=new Set()
const officialHosts=['openapi.krx.co.kr','opendart.fss.or.kr','apiportal.koreainvestment.com','github.com']
for(const p of src.providers??[]){
if(ids.has(p.id))errors.push(`duplicate provider ${p.id}`);ids.add(p.id)
if(p.mutationAllowed)errors.push(`${p.id}: v20 providers must remain read-only`)
for(const u of p.officialSources??[]){const host=new URL(u).host;if(!officialHosts.includes(host))errors.push(`${p.id}: non-official source host ${host}`)}
}
const krx=src.providers.find(x=>x.id==='provider.krx.openapi')
if(!krx)errors.push('missing KRX provider')
else {if(krx.official.authentication?.headerName!=='AUTH_KEY')errors.push('KRX AUTH_KEY header drift');if(krx.official.numericRateLimit!==null)errors.push('KRX universal numeric rate limit must stay unknown unless officially verified');if(krx.kbxPolicy.allowedHostSuffix!=='.krx.co.kr')errors.push('KRX approved host suffix guard missing')}
const dart=src.providers.find(x=>x.id==='provider.opendart')
if(!dart)errors.push('missing OPENDART provider')
else {if(dart.official.baseUri!=='https://opendart.fss.or.kr/api/')errors.push('OPENDART base URI drift');if(dart.official.authentication?.parameterName!=='crtfc_key'||dart.official.authentication?.length!==40)errors.push('OPENDART auth contract drift');for(const c of ['000','013','020','800','900','901'])if(!dart.official.statusCodes?.[c])errors.push(`OPENDART status missing ${c}`);for(const op of ['list.json','company.json','corpCode.xml'])if(!(dart.operations??[]).some(x=>x.path===op))errors.push(`OPENDART operation missing ${op}`)}
const kis=src.providers.find(x=>x.id==='provider.kis.market-data')
if(!kis)errors.push('missing KIS provider')
else {if(kis.official.authentication?.tokenPath!=='/oauth2/tokenP'||kis.official.authentication?.grantType!=='client_credentials')errors.push('KIS OAuth contract drift');if(kis.official.productionRequestsPerSecond!==18||kis.official.sandboxRequestsPerSecond!==1||kis.official.tokenRequestsPerSecond!==1)errors.push('KIS official rate-limit facts drift');const price=(kis.operations??[]).find(x=>x.id==='kis.domestic-stock.current-price');if(!price||price.method!=='GET'||price.path!=='/uapi/domestic-stock/v1/quotations/inquire-price'||price.trId!=='FHKST01010100')errors.push('KIS current-price official sample contract drift')}
if(manifest.providers.length!==src.providers.length)errors.push('provider manifest count drift')
const ts=fs.readFileSync('packages/kbx-contracts/src/generated/providerCatalog.ts','utf8'),cs=fs.readFileSync('backend/Shared/Providers/Generated/KbxExternalProviderCatalog.g.cs','utf8')
if(!ts.includes(manifest.sourceSha256)||!cs.includes(manifest.sourceSha256))errors.push('provider source SHA parity missing')
const krxCode=fs.readFileSync('backend/Shared/Providers/KrxOpenApiAdapter.cs','utf8')
for(const token of ['AUTH_KEY','.krx.co.kr','IKbxProviderSecretStore'])if(!krxCode.includes(token))errors.push(`KRX adapter missing ${token}`)
const dartCode=fs.readFileSync('backend/Shared/Providers/OpenDartAdapter.cs','utf8')
for(const token of ['crtfc_key','"013"','"020"','"800"','"900"'])if(!dartCode.includes(token))errors.push(`OPENDART adapter missing ${token}`)
const kisCode=fs.readFileSync('backend/Shared/Providers/KisMarketDataAdapter.cs','utf8')
for(const token of ['HttpMethod.Get','FHKST01010100','authorization','appkey','appsecret','tr_id'])if(!kisCode.includes(token))errors.push(`KIS adapter missing ${token}`)
if(/HttpMethod\.Post/.test(kisCode))errors.push('KIS market-data adapter must not contain POST trading calls')
for(const f of ['KrxOpenApiAdapter.cs','OpenDartAdapter.cs','KisAccessTokenProvider.cs','KisMarketDataAdapter.cs']){
const code=fs.readFileSync(`backend/Shared/Providers/${f}`,'utf8')
if(/Console\.Write/i.test(code)) errors.push(`${f}: console logging is forbidden in provider adapters`)
if(/LogInformation\([^)]*(appsecret|AuthKey|access_token)/i.test(code)) errors.push(`${f}: possible secret logging`)
}
for(const f of ['tests/fixtures/providers/opendart-no-data.json','tests/fixtures/providers/opendart-rate-limit.json','tests/fixtures/providers/kis-current-price-success.json','backend/tests/Providers/KrxProviderPolicyTests.cs','backend/tests/Providers/OpenDartProviderPolicyTests.cs','backend/tests/Providers/KisProviderPolicyTests.cs'])if(!fs.existsSync(f))errors.push(`missing provider test artifact ${f}`)
if(errors.length){console.error('provider governance FAIL');for(const e of errors)console.error(`- ${e}`);process.exit(1)}
console.log(`provider governance PASS: providers=${src.providers.length}, official-only sources, read-only boundary, secret isolation`)
@@ -0,0 +1,16 @@
import fs from 'node:fs'
const read=p=>fs.readFileSync(p,'utf8');const failures=[]
const command=read('packages/kbx-contracts/src/command.ts');for(const t of ['allowedStatuses','requiresDirty','requiresClean','permissionByStatus','confirm'])if(!command.includes(t))failures.push(`command lifecycle missing ${t}`)
const tx=read('packages/kbx-contracts/src/transaction.ts');for(const t of ['KbxRecordStatePolicy','resolveKbxRecordStatePolicy','kbxFieldReadonly'])if(!tx.includes(t))failures.push(`record state policy missing ${t}`)
const commandBar=read('packages/kbx-ui/src/components/KbxCommandBar.vue');for(const t of ['KbxConfirm','effectivePermission','permissionByStatus'])if(!commandBar.includes(t))failures.push(`command safety missing ${t}`)
const lifecycle=read('packages/kbx-ui/src/components/KbxRecordLifecycle.vue');for(const t of ['KbxConflictResolver','KbxWorkflowBar','KbxAuditTrail','Version'])if(!lifecycle.includes(t))failures.push(`record lifecycle component missing ${t}`)
for(const f of ['packages/kbx-ui/src/components/KbxMasterPage.vue','packages/kbx-ui/src/components/KbxTransactionPage.vue'])if(!read(f).includes('KbxRecordLifecycle'))failures.push(`${f} does not integrate lifecycle`)
const item=read('apps/web/src/modules/erp/items/ItemMasterPage.vue');for(const t of ['itemWorkflow','itemStatePolicies','reloadConflict','auditEntries','itemApi.deactivate','useKbxDirtyState'])if(!item.includes(t))failures.push(`ERP-MST-ITEM-001 lifecycle missing ${t}`)
const itemBackend=read('backend/Modules/ERP/Items/Save/Handler.cs')+read('backend/Modules/ERP/Items/Deactivate/Endpoint.cs');for(const t of ['catalog.item_details','KbxConflictProblem.Version','audit.entries','integration.outbox','erp_item_search_projection'])if(!itemBackend.includes(t))failures.push(`item backend lifecycle missing ${t}`)
const order=read('apps/web/src/modules/oms/orders/register/OrderRegisterPage.vue')+read('apps/web/src/modules/oms/orders/register/useOrderRegistration.ts');for(const t of ['orderWorkflow','orderStatePolicies','vm.conflict','vm.auditEntries','vm.confirm','reloadConflict'])if(!order.includes(t))failures.push(`OMS-ORD-002 lifecycle missing ${t}`)
const orderBackend=read('backend/Modules/OMS/Orders/Confirm/Endpoint.cs')+read('backend/Modules/OMS/Orders/Get/Endpoint.cs')+read('backend/Modules/OMS/Orders/Audit/Endpoint.cs');for(const t of ['ORDER_CONFIRMED','Idempotency-Key','command_receipts','KbxConflictProblem.Version','OrderConfirmed','audit.entries','작성','확정'])if(!orderBackend.includes(t))failures.push(`order backend lifecycle missing ${t}`)
const migration=read('backend/Database/Migrations/20260808_016_record_lifecycle.sql');if(!migration.includes('catalog.item_details'))failures.push('normalized item detail write model missing')
const route=read('apps/web/src/router/appRoutes.ts');const nav=read('apps/web/src/shell/navigationCatalog.ts');if(!nav.includes('entry.permissions ?? screen.permissions'))failures.push('navigation permission override missing')
if(!route.includes("'/oms/orders/:orderId/edit'"))failures.push('order edit route missing')
if(failures.length){console.error('Record lifecycle validation failed:\n- '+failures.join('\n- '));process.exit(1)}
console.log('Record lifecycle PASS: state-aware commands, Dirty, workflow, concurrency recovery and audit integrated into T02/T03.')
@@ -0,0 +1,14 @@
import fs from 'node:fs'
import { spawnSync } from 'node:child_process'
const result=spawnSync(process.execPath,['scripts/analyze-kbx-release.mjs'],{stdio:'inherit'})
if(result.status!==0)process.exit(result.status??1)
const impact=JSON.parse(fs.readFileSync('generated/release-impact.json','utf8'))
if(impact.requiredChangeLevel==='major' && !fs.existsSync('docs/release/migration-guide.md')){
console.error('major KBX change requires docs/release/migration-guide.md')
process.exit(1)
}
for(const change of impact.changes.filter(x=>x.kind==='component'&&x.change==='version')){
if(!/^\d+\.\d+\.\d+$/.test(change.to)){console.error(`invalid component semver: ${change.key} ${change.to}`);process.exit(1)}
}
console.log(`release governance PASS: ${impact.requiredChangeLevel}`)
@@ -0,0 +1,68 @@
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<string, KbxHelpContent> = {\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 })
@@ -0,0 +1,44 @@
import fs from 'node:fs'
import path from 'node:path'
const root = process.cwd()
const modulesRoot = path.join(root, 'apps/web/src/modules')
const helpText = fs.readFileSync(path.join(root, 'apps/web/src/help/helpRegistry.ts'), 'utf8')
const recipeContract = JSON.parse(fs.readFileSync(path.join(root, 'contracts/screens/kbx.screen-recipes.json'), 'utf8'))
const recipeByCode = new Map(recipeContract.recipes.map(recipe => [recipe.code, recipe]))
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => e.isDirectory() ? walk(path.join(dir,e.name)) : [path.join(dir,e.name)])
}
const errors = []
const warnings = []
const ids = new Set()
for (const file of walk(modulesRoot).filter(f => /definition\.ts$/.test(f))) {
const text = fs.readFileSync(file, 'utf8')
if (!/defineKbxScreen\s*\(/.test(text)) continue
const match = text.match(/defineKbxScreen\s*\(\s*\{([\s\S]*?)\}\s*\)/)
const body = match?.[1] ?? text
const id = body.match(/\bid:\s*['"]([^'"]+)['"]/)?.[1]
if (!id) { errors.push(`${file}: missing screen id`); continue }
if (ids.has(id)) errors.push(`${id}: duplicated screen id`)
ids.add(id)
const type = body.match(/\btype:\s*['"]([^'"]+)['"]/)?.[1]
const templateCode = body.match(/\btemplateCode:\s*['"]([^'"]+)['"]/)?.[1]
if (!templateCode) errors.push(`${id}: templateCode is required`)
else { const recipe=recipeByCode.get(templateCode); if(!recipe) errors.push(`${id}: unknown templateCode ${templateCode}`); else if(recipe.type!==type) errors.push(`${id}: templateCode ${templateCode} expects ${recipe.type}, found ${type}`) }
const helpKey = body.match(/helpKey:\s*['"]([^'"]+)['"]/)?.[1]
if (helpKey !== id) errors.push(`${id}: helpKey must equal screen id`)
if (!new RegExp(`['"]${id.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}['"]\\s*:`).test(helpText)) errors.push(`${id}: helpRegistry entry missing`)
if (!/permissions:\s*\[[^\]]+\]/.test(body)) errors.push(`${id}: at least one screen permission is required`)
if (!/telemetry:\s*\{\s*enabled:\s*true\s*\}/.test(body)) warnings.push(`${id}: telemetry enabled is recommended`)
if (/group:\s*['"]workflow['"][\s\S]{0,180}?\}/.test(body) && !/group:\s*['"]workflow['"][\s\S]{0,150}?permission:/.test(body)) warnings.push(`${id}: review workflow commands without explicit permission`)
}
if (errors.length) {
console.error('KBX screen governance FAILED')
errors.forEach(x => console.error(` - ${x}`))
process.exit(1)
}
console.log(`KBX screen governance PASS (${ids.size} screens)`)
warnings.forEach(x => console.warn(` warning: ${x}`))
@@ -0,0 +1,25 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
const gen=spawnSync(process.execPath,['scripts/generate-telemetry-contracts.mjs'],{stdio:'inherit'});if(gen.status!==0)process.exit(gen.status??1)
const manifest=JSON.parse(fs.readFileSync('generated/telemetry-manifest.json','utf8'))
const source=JSON.parse(fs.readFileSync('contracts/telemetry/kbx.telemetry.json','utf8'))
const errors=[]
if(manifest.events.length<20)errors.push('telemetry catalog unexpectedly small')
if(!manifest.metrics.some(x=>x.key==='manual_intervention_rate'))errors.push('Manual Intervention Rate metric missing')
if(source.principles.noSensitiveData!==true||source.principles.noRawBusinessData!==true)errors.push('privacy principles must prohibit sensitive/raw business data')
const forbidden=/(phone|address|name|orderNo|customer|itemCode|barcode|keyword|query|email|entityId)/i
for(const e of manifest.events)for(const a of e.allowedAttributes??[])if(forbidden.test(a))errors.push(`forbidden telemetry attribute: ${e.name}.${a}`)
for(const e of manifest.events.filter(x=>x.requiresDuration))if(!['task.complete','task.abandon','command.succeeded','command.failed','excel.import.completed','excel.import.failed','exception.resolved'].includes(e.name))errors.push(`review duration event: ${e.name}`)
const migration=fs.readFileSync('backend/Database/Migrations/20260808_011_kbx_ux_telemetry.sql','utf8')
for(const table of ['kbx.ux_events','kbx.ux_business_outcomes'])if(!migration.includes(table))errors.push(`migration missing ${table}`)
const appFiles=[]
function walk(d){for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);e.isDirectory()?walk(p):appFiles.push(p)}}walk('apps/web/src')
for(const file of appFiles.filter(x=>/\.(ts|vue)$/.test(x))){const text=fs.readFileSync(file,'utf8');if(/kbxTelemetry\.track\([^'\"]/.test(text))errors.push(`${file}: dynamic telemetry event names are forbidden`)}
const ts=fs.readFileSync('packages/kbx-contracts/src/generated/telemetryCatalog.ts','utf8'),cs=fs.readFileSync('backend/Shared/Telemetry/Generated/KbxTelemetryCatalog.g.cs','utf8')
const tsha=ts.match(/kbxTelemetrySourceSha256 = '([a-f0-9]+)'/)?.[1], cssha=cs.match(/SourceSha256 = "([a-f0-9]+)"/)?.[1]
if(!tsha||tsha!==cssha)errors.push('TS/C# telemetry source SHA mismatch')
if(!manifest.events.some(x=>x.name==='experiment.exposed'))errors.push('experiment exposure event missing')
if(!fs.existsSync('apps/web/src/modules/common/ux-metrics/UxMetricsPage.vue'))errors.push('COMMON-UX-001 reference screen missing')
if(errors.length){console.error('KBX telemetry governance FAILED');errors.forEach(x=>console.error(' - '+x));process.exit(1)}
console.log(`KBX telemetry governance PASS (${manifest.events.length} events, ${manifest.metrics.length} metrics)`)
@@ -0,0 +1,66 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd();const failures=[]
const read=file=>fs.readFileSync(path.join(root,file),'utf8')
const exists=file=>fs.existsSync(path.join(root,file))
const templates=[
'packages/kbx-ui/src/components/KbxListPage.vue',
'packages/kbx-ui/src/components/KbxMasterPage.vue',
'packages/kbx-ui/src/components/KbxTransactionPage.vue',
'packages/kbx-ui/src/components/KbxFastEntryPage.vue',
'packages/kbx-ui/src/components/KbxMasterDetailPage.vue',
'packages/kbx-ui/src/components/KbxQueuePage.vue',
'packages/kbx-ui/src/components/KbxReconcilePage.vue',
'packages/kbx-ui/src/components/KbxImportPage.vue',
]
for(const file of templates){
if(!exists(file)){failures.push(`missing desktop template ${file}`);continue}
if(!read(file).includes('KbxScreenFrame'))failures.push(`${file} must compose KbxScreenFrame`)
}
const mobileTemplate='packages/kbx-ui/src/wms/KbxWmsMobilePage.vue'
if(!exists(mobileTemplate))failures.push(`missing mobile template ${mobileTemplate}`)
else{
const text=read(mobileTemplate)
for(const contract of ['KbxNetworkIndicator','data-screen-type="wms-mobile"','name="actions"'])if(!text.includes(contract))failures.push(`KbxWmsMobilePage missing ${contract}`)
}
const frame='packages/kbx-ui/src/components/KbxScreenFrame.vue'
if(!exists(frame))failures.push('missing KbxScreenFrame')
else{
const text=read(frame)
for(const contract of ['KbxPageHeader','KbxCommandBar','data-screen-type','is-sticky'])if(!text.includes(contract))failures.push(`KbxScreenFrame missing ${contract}`)
}
const workQueue=read('packages/kbx-ui/src/components/KbxWorkQueuePage.vue')
if(!workQueue.includes('KbxQueuePage'))failures.push('KbxWorkQueuePage must be a compatibility wrapper over KbxQueuePage')
const index=read('packages/kbx-ui/src/index.ts')
for(const component of ['KbxCheckbox','KbxRadio','KbxTextarea','KbxTabs','KbxBadge','KbxTooltip','KbxBarcodeField','KbxSectionHeader','KbxQuickFilterBar','KbxSummaryBar','KbxHomePage','KbxAccessDenied']){
if(!index.includes(`as ${component}`))failures.push(`public API missing ${component}`)
}
const frameApp=read('apps/web/src/shell/KbxAppFrame.vue')
for(const contract of ['KbxHomePage','KbxAccessDenied','activeScreenAllowed','allowedEntries'])if(!frameApp.includes(contract))failures.push(`KbxAppFrame missing ${contract}`)
const routes=read('apps/web/src/router/appRoutes.ts')
if(!routes.includes("{path:'/',redirect:'/home'}"))failures.push('root route must redirect to /home')
if(!routes.includes("path:'/home'"))failures.push('/home route missing')
const side=read('packages/kbx-ui/src/shell/KbxSideNavigation.vue')
for(const contract of ["emit('home')",'collapsed-actions','즐겨찾기','최근메뉴'])if(!side.includes(contract))failures.push(`side navigation missing ${contract}`)
const golden=[
'apps/web/src/modules/oms/orders/search/OrderListPage.vue',
'apps/web/src/modules/oms/orders/register/OrderRegisterPage.vue',
'apps/web/src/modules/erp/items/ItemMasterPage.vue',
'apps/web/src/modules/wms/picking/WmsPickingPage.vue',
]
for(const file of golden){
const text=read(file)
if(/<button\b|<input\b|<select\b/.test(text))failures.push(`${file} bypasses KBX primitives/business components`)
}
if(failures.length){console.error(failures.join('\n'));process.exit(1)}
console.log(`Template completeness validation passed for ${templates.length} desktop templates + T09 mobile, Home, navigation, and Golden Screens.`)
@@ -0,0 +1,21 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd();const fail=[];const read=p=>fs.readFileSync(path.join(root,p),'utf8')
const manifest=read('packages/kbx-ui/src/registry/templateManifest.ts')
for(const code of ['T01','T02','T03','T04','T05','T06','T07','T08','T09'])if(!manifest.includes(`code:'${code}'`))fail.push(`template manifest missing ${code}`)
for(const type of ['list','master','transaction','fast-entry','master-detail','queue','reconcile','import','wms-mobile'])if(!manifest.includes(`type:'${type}'`))fail.push(`template manifest missing type ${type}`)
for(const file of ['KbxListPage.vue','KbxMasterPage.vue','KbxTransactionPage.vue','KbxFastEntryPage.vue','KbxMasterDetailPage.vue','KbxQueuePage.vue','KbxReconcilePage.vue','KbxImportPage.vue']){
const text=read(`packages/kbx-ui/src/components/${file}`)
if(!text.includes('KbxTemplateContextBar'))fail.push(`${file} missing shared template context surface`)
}
const reconcile=read('packages/kbx-ui/src/components/KbxReconcilePage.vue');for(const x of ['name="resolution"','name="audit"','name="detail"'])if(!reconcile.includes(x))fail.push(`T07 missing ${x}`)
const imp=read('packages/kbx-ui/src/components/KbxImportPage.vue');if(!imp.includes('name="result"'))fail.push('T08 missing result surface')
const wms=read('packages/kbx-ui/src/wms/KbxWmsMobilePage.vue');for(const x of ['taskContext','instruction','name="context"','name="actions"'])if(!wms.includes(x))fail.push(`T09 missing ${x}`)
const home=read('packages/kbx-ui/src/shell/KbxHomePage.vue');for(const x of ['이어서 작업','resumeTab','selectedModule','모듈별 업무'])if(!home.includes(x))fail.push(`Home missing ${x}`)
const side=read('packages/kbx-ui/src/shell/KbxSideNavigation.vue');for(const x of ['module-switcher','menuSearch','selectedModule','최근메뉴'])if(!side.includes(x))fail.push(`Side navigation missing ${x}`)
const search=read('packages/kbx-ui/src/shell/KbxMenuSearch.vue');if(!search.includes('priorityScreenIds'))fail.push('Menu search missing favorite/recent priority')
const store=read('apps/web/src/shell/workspaceStore.ts');for(const x of ['setPreferenceScope','STORAGE_PREFIX','normalizePreference','encodeURIComponent','if(scopeKey.value','syncRoute(screenId:string,title:string,path:string,recent?:'])if(!store.includes(x))fail.push(`Preference security missing ${x}`)
const frame=read('apps/web/src/shell/KbxAppFrame.vue');for(const x of ['preferenceScope','recentPolicy','navEntry.path','setPreferenceScope','item.path.startsWith(nav.path)','allowedTabs'])if(!frame.includes(x))fail.push(`App frame missing secure navigation contract ${x}`)
const tabs=read('packages/kbx-ui/src/shell/KbxWorkspaceTabs.vue');for(const x of ["role=\"tablist\"","ArrowLeft","ArrowRight","Delete",':max-tabs']){if(x===':max-tabs')continue;if(!tabs.includes(x))fail.push(`Workspace tabs missing ${x}`)}
if(fail.length){console.error(fail.join('\n'));process.exit(1)}
console.log('v28 template/navigation hardening validation passed for T01-T09, Home, secure preferences, menu search, and workspace tabs.')
@@ -0,0 +1,26 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd();const fail=[];const read=p=>fs.readFileSync(path.join(root,p),'utf8')
const manifest=read('packages/kbx-ui/src/registry/templateManifest.ts')
for(const code of ['T01','T02','T03','T04','T05','T06','T07','T08','T09']){
if(!manifest.includes(`code:'${code}'`))fail.push(`template manifest missing ${code}`)
}
for(const x of ['defaultDensity','runtimeStates','utilitySurfaces','accessibility'])if((manifest.match(new RegExp(x,'g'))??[]).length<9)fail.push(`all T01-T09 manifest entries must declare ${x}`)
for(const state of ['loading','empty','error','ready'])if(!manifest.includes(`'${state}'`))fail.push(`template runtime state missing ${state}`)
const boundary=read('packages/kbx-ui/src/components/KbxTemplateStateBoundary.vue');for(const x of ['KbxDataState','refreshing','현재 화면은 유지됩니다','@action="emit(\'retry\')"'])if(!boundary.includes(x))fail.push(`Template state boundary missing ${x}`)
for(const file of ['KbxListPage.vue','KbxMasterPage.vue','KbxTransactionPage.vue','KbxFastEntryPage.vue','KbxMasterDetailPage.vue','KbxQueuePage.vue','KbxReconcilePage.vue','KbxImportPage.vue']){
const text=read(`packages/kbx-ui/src/components/${file}`);if(!text.includes('KbxTemplateStateBoundary'))fail.push(`${file} missing runtime state boundary`);if(!text.includes('suppress-default-utility'))fail.push(`${file} missing default utility override contract`)
}
const wms=read('packages/kbx-ui/src/wms/KbxWmsMobilePage.vue');for(const x of ['KbxTemplateStateBoundary','KbxScreenUtilityHostKey','helpAvailable'])if(!wms.includes(x))fail.push(`T09 missing ${x}`)
const frame=read('packages/kbx-ui/src/components/KbxScreenFrame.vue');for(const x of ['KbxScreenUtilityHostKey','defaultUtilities','도움말','suggestion'])if(!frame.includes(x))fail.push(`ScreenFrame missing global utility contract ${x}`)
const app=read('apps/web/src/shell/KbxAppFrame.vue');for(const x of ['provide(KbxScreenUtilityHostKey','KbxUtilityRail','resolveKbxSafeRecentPath','pruneTabs','previous!==undefined&&scope!==previous','runtimePanel.value=null','shellNotice'])if(!app.includes(x))fail.push(`AppFrame hardening missing ${x}`)
const store=read('apps/web/src/shell/workspaceStore.ts');for(const x of ['clearWorkspaceSession','evictForCapacity','!tab.dirty&&!tab.pinned','pruneTabs','workspaceMaxTabs','catch{shellNotice.value='])if(!store.includes(x))fail.push(`Workspace hardening missing ${x}`)
const security=read('packages/kbx-ui/src/shell/navigationSecurity.ts');for(const x of ['candidate.origin!==base.origin','candidatePath.startsWith','recentPolicy'])if(!security.includes(x))fail.push(`Navigation security missing ${x}`)
const homeLogic=read('packages/kbx-ui/src/shell/homeNavigation.ts');for(const x of ['favorites.forEach','lastActivatedAt','recents.forEach','homePriority','seen'])if(!homeLogic.includes(x))fail.push(`Home quick-start policy missing ${x}`)
const home=read('packages/kbx-ui/src/shell/KbxHomePage.vue');if(!(home.includes('주요 업무')||home.includes('바로 시작')))fail.push('Home completion missing primary work surface');if(!(home.includes('buildKbxHomeQuickStart')||home.includes('buildKbxHomeWorkbench')))fail.push('Home completion missing quick-start/workbench policy');for(const x of ['미저장 업무','homeGroup'])if(!home.includes(x))fail.push(`Home completion missing ${x}`)
const nav=read('apps/web/src/shell/navigationCatalog.ts');if((nav.match(/homePriority:/g)??[]).length<7)fail.push('navigation catalog needs business quick-start priorities')
const menu=read('packages/kbx-ui/src/shell/KbxMenuSearch.vue');for(const x of ['trapTab','role="combobox"','role="listbox"','aria-activedescendant'])if(!menu.includes(x))fail.push(`Menu search accessibility missing ${x}`)
const shell=read('packages/kbx-ui/src/shell/KbxApplicationShell.vue');for(const x of ['workspaceMaxTabs?:number','shellNotice?:string','dismissShellNotice','shell-notice'])if(!shell.includes(x))fail.push(`Application shell completion missing ${x}`)
for(const file of ['apps/web/src/modules/oms/orders/search/OrderListPage.vue','apps/web/src/modules/common/operations/OperationsQueuePage.vue','apps/web/src/modules/common/reconcile/ReconcilePage.vue','apps/web/src/modules/erp/inventory/InventoryPage.vue'])if(!read(file).includes('content-state'))fail.push(`${file} must demonstrate template runtime state contract`)
if(fail.length){console.error(fail.join('\n'));process.exit(1)}
console.log('v29 template/home navigation validation passed: T01-T09 runtime states, global utilities, quick-start IA, scoped workspace isolation, safe recents, max-tab enforcement, and search accessibility.')
@@ -0,0 +1,67 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd(); const failures=[]
const read=p=>fs.readFileSync(path.join(root,p),'utf8')
const must=(text,needles,scope)=>needles.forEach(n=>{if(!text.includes(n))failures.push(`${scope} missing ${n}`)})
const manifest=read('packages/kbx-ui/src/registry/templateManifest.ts')
for(const code of ['T01','T02','T03','T04','T05','T06','T07','T08','T09']){
if(!manifest.includes(`code:'${code}'`)) failures.push(`template manifest missing ${code}`)
}
for(const key of ['coreComponents','completionChecks']){
if((manifest.match(new RegExp(key,'g'))??[]).length<9) failures.push(`all T01-T09 must declare ${key}`)
}
must(manifest,['KbxFormGrid','KbxRecordLifecycle','KbxDataGrid','KbxJobProgress','KbxBarcodeCapture'],'template manifest core coverage')
const formSection=read('packages/kbx-ui/src/components/KbxFormSection.vue')
must(formSection,['--kbx-form-section-gap','--kbx-form-section-heading-gap','<slot name="actions"'],'KbxFormSection')
const formGrid=read('packages/kbx-ui/src/components/KbxFormGrid.vue')
must(formGrid,["columns?: 1 | 2",'--kbx-form-grid-column-gap','grid-template-columns:repeat(2,minmax(0,1fr))'],'KbxFormGrid')
const formSpan=read('packages/kbx-ui/src/components/KbxFormSpan.vue')
must(formSpan,["span?: 'cell' | 'full'",'grid-column:1/-1'],'KbxFormSpan')
for(const file of [
'apps/web/src/modules/erp/items/ItemMasterPage.vue',
'apps/web/src/modules/oms/orders/register/OrderRegisterPage.vue',
'apps/web/src/modules/erp/purchases/PurchasePage.vue',
'apps/web/src/modules/erp/inventory-move/InventoryMovePage.vue',
]) if(!read(file).includes('KbxFormGrid')) failures.push(`${file} must use KbxFormGrid`)
const scaffold=read('scripts/create-kbx-screen.mjs')
for(const pair of [
["'list'",'KbxListPage'],["'master'",'KbxMasterPage'],["'transaction'",'KbxTransactionPage'],
["'fast-entry'",'KbxFastEntryPage'],["'master-detail'",'KbxMasterDetailPage'],["'queue'",'KbxQueuePage'],
["'reconcile'",'KbxReconcilePage'],["'import'",'KbxImportPage'],["'wms-mobile'",'KbxWmsMobilePage'],
]) if(!(scaffold.includes(pair[0])&&scaffold.includes(pair[1]))) failures.push(`scaffolder canonical mapping missing ${pair.join(' -> ')}`)
const scaffoldTest=read('scripts/validate-scaffolder.mjs')
must(scaffoldTest,['fast-entry','KbxFastEntryPage','queue','KbxQueuePage','wms-mobile','KbxWmsMobilePage'],'scaffolder validator')
const homeLogic=read('packages/kbx-ui/src/shell/homeNavigation.ts')
must(homeLogic,['buildKbxHomeWorkbench','sourceLabel','dirty','favorite','recent','homePriority','seen'],'Home workbench policy')
const home=read('packages/kbx-ui/src/shell/KbxHomePage.vue')
must(home,['바로 시작','buildKbxHomeWorkbench','미저장 업무','모듈별 업무','sourceLabel'],'Home IA')
if(home.includes('kbx-home__personal')) failures.push('Home must not restore duplicate legacy personal navigation blocks')
const security=read('packages/kbx-ui/src/shell/navigationSecurity.ts')
must(security,['matchKbxRoutePattern','resolveKbxSafeWorkspacePath','candidate.origin!==base.origin',"segment.startsWith(':')"],'navigation capability security')
const routes=read('apps/web/src/router/appRoutes.ts')
must(routes,['screenRoutePatterns','/:pathMatch(.*)*','NotFoundRoutePage.vue'],'router recovery')
const app=read('apps/web/src/shell/KbxAppFrame.vue')
if(app.includes(':screen-title=')||app.includes(':screen-id='))failures.push('AppFrame must not reveal denied screen metadata by default')
must(app,['safeWorkspacePath','screenRoutePatterns','reconcilePreference','allowedEntries.value.find(item=>item.screenId===requested.screenId)','frameEmit(\'profile\')','권한 변경으로'],'AppFrame security/recovery')
const access=read('packages/kbx-ui/src/shell/KbxAccessDenied.vue')
must(access,['menuSearch','홈으로 이동','다른 업무 찾기','revealDetails?:boolean','revealDetails:false'],'AccessDenied recovery')
const notFound=read('packages/kbx-ui/src/shell/KbxRouteNotFound.vue')
must(notFound,['menuSearch','홈으로 이동','화면을 찾을 수 없습니다'],'RouteNotFound recovery')
const header=read('packages/kbx-ui/src/shell/KbxGlobalHeader.vue')
must(header,['modules?: string[]','moduleSelect','<select',"emit('profile')"],'GlobalHeader')
const shell=read('packages/kbx-ui/src/shell/KbxApplicationShell.vue')
must(shell,['moduleOptions','focusedModule','@module-select="selectModule"','preferred-module="focusedModule"',"@profile=\"emit('profile')\""],'ApplicationShell module/profile contract')
const side=read('packages/kbx-ui/src/shell/KbxSideNavigation.vue')
must(side,['preferredModule','moduleSelected'],'SideNavigation module synchronization')
const publicApi=read('packages/kbx-ui/src/index.ts')
for(const name of ['KbxFormGrid','KbxFormSpan','KbxRouteNotFound','buildKbxHomeWorkbench','resolveKbxSafeWorkspacePath']) if(!publicApi.includes(name)) failures.push(`public API missing ${name}`)
if(failures.length){console.error(failures.join('\n'));process.exit(1)}
console.log('v30 template/component/home navigation validation passed: form composition, nine-type scaffolding, workbench IA, route capability recovery, module synchronization, and profile propagation.')
@@ -0,0 +1,48 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd(); const failures=[]
const read=p=>fs.readFileSync(path.join(root,p),'utf8')
const must=(text,needles,scope)=>needles.forEach(n=>{if(!text.includes(n))failures.push(`${scope} missing ${n}`)})
const screenFrame=read('packages/kbx-ui/src/components/KbxScreenFrame.vue')
must(screenFrame,['expectedType?: KbxScreenType','templateMismatch','화면 템플릿 구성이 올바르지 않습니다.','data-template="templateCode"'],'ScreenFrame template contract')
const templateMap={
KbxListPage:['list','T01'],KbxMasterPage:['master','T02'],KbxTransactionPage:['transaction','T03'],KbxFastEntryPage:['fast-entry','T04'],
KbxMasterDetailPage:['master-detail','T05'],KbxQueuePage:['queue','T06'],KbxReconcilePage:['reconcile','T07'],KbxImportPage:['import','T08'],
}
for(const [name,[type,code]] of Object.entries(templateMap)){
const text=read(`packages/kbx-ui/src/components/${name}.vue`)
must(text,[`expected-type="${type}"`,`template-code="${code}"`],name)
}
const mobile=read('packages/kbx-ui/src/wms/KbxWmsMobilePage.vue')
must(mobile,["props.screen.type!=='wms-mobile'",'현장 화면 구성이 올바르지 않습니다.'],'T09 template contract')
const searchPanel=read('packages/kbx-ui/src/components/KbxSearchPanel.vue')
const searchField=read('packages/kbx-ui/src/components/KbxSearchFieldControl.vue')
must(searchPanel,['KbxSearchFieldControl','v-for="field in primary"','v-for="field in secondary"','role="search"'],'SearchPanel composition')
must(searchField,["field.type==='date-range'","field.type==='lookup'",'`${field.label} 시작일`','`${field.label} 종료일`'],'SearchFieldControl')
if(searchPanel.includes("field.type==='text'")||searchPanel.includes("field.type==='select'")||searchPanel.includes("field.type==='lookup'"))failures.push('SearchPanel must delegate field-type rendering to KbxSearchFieldControl')
const homeLogic=read('packages/kbx-ui/src/shell/homeNavigation.ts')
must(homeLogic,['launchKey:tab.key','seenLaunchKeys','coveredScreenIds','addTab(tab','instanceLabel:routeInstanceLabel','Boolean(b.pinned)'],'Home instance workbench')
const home=read('packages/kbx-ui/src/shell/KbxHomePage.vue')
must(home,[':key="item.launchKey"','item.instanceLabel','firstDirty','class="resume"'],'Home workbench UI')
if(home.includes('<code>{{item.screenId}}</code>')||home.includes('<small>{{entry.screenId}}</small>'))failures.push('Home must not expose ScreenId in normal browse surfaces')
const tabs=read('packages/kbx-ui/src/shell/KbxWorkspaceTabs.vue')
must(tabs,['tabButtons','selectAndFocus','ArrowLeft','ArrowRight','togglePin','aria-pressed="Boolean(tab.pinned)"'],'Workspace tabs')
if(tabs.includes("event.key==='Delete'"))failures.push('Workspace tabs must not overload Delete as close')
const store=read('apps/web/src/shell/workspaceStore.ts')
must(store,['togglePinned(tabKey:string)','!tab.dirty&&!tab.pinned'],'workspace pin policy')
const shell=read('packages/kbx-ui/src/shell/KbxApplicationShell.vue')
must(shell,['본문 바로가기','id="kbx-main-workspace"','toggleTabPin','@toggle-pin'],'ApplicationShell accessibility/pin contract')
const app=read('apps/web/src/shell/KbxAppFrame.vue')
must(app,["@toggle-tab-pin=\"tab=>store.togglePinned(tab.key)\"","resolveKbxSafeRecentPath(navEntry,safe)"],'AppFrame pin/recent persistence')
const menu=read('packages/kbx-ui/src/shell/KbxMenuSearch.vue')
must(menu,['restoreFocus','document.activeElement','검색 결과 {{results.length}}건'],'MenuSearch focus restoration')
const security=read('packages/kbx-ui/src/shell/navigationSecurity.ts')
must(security,['hasUnsafeEncodedPath','%2f','candidate.username','candidate.pathname:entry.path','Query/hash are deliberately stripped'],'Navigation security')
if(failures.length){console.error(failures.join('\n'));process.exit(1)}
console.log('v31 template/component/home navigation validation passed: runtime template-type guards, SearchPanel de-duplication, multi-instance Home workbench, roving tab focus/pinning, focus recovery, skip navigation, and persisted-route hardening.')
@@ -0,0 +1,65 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd(); const failures=[]
const read=p=>fs.readFileSync(path.join(root,p),'utf8')
const must=(text,needles,scope)=>needles.forEach(n=>{if(!text.includes(n))failures.push(`${scope} missing ${n}`)})
const ui=read('packages/kbx-contracts/src/ui.ts')
must(ui,['export interface KbxSummaryItem','export interface KbxQuickFilterItem'],'shared template surface contracts')
const screenFrame=read('packages/kbx-ui/src/components/KbxScreenFrame.vue')
must(screenFrame,['data-kbx-surface="page-header"','data-kbx-surface="command-bar"'],'ScreenFrame surface markers')
const list=read('packages/kbx-ui/src/components/KbxListPage.vue')
must(list,['quickFilters?: KbxQuickFilterItem[]','summaryItems?: KbxSummaryItem[]','<KbxQuickFilterBar :items="quickFilters"','<KbxSummaryBar :items="summaryItems"','data-kbx-surface="search"','data-kbx-surface="content"','data-kbx-surface="summary"'],'T01 first-class surfaces')
const tx=read('packages/kbx-ui/src/components/KbxTransactionPage.vue')
must(tx,['summaryItems?:KbxSummaryItem[]','<KbxSummaryBar :items="summaryItems" align="end"','data-kbx-surface="header-form"','data-kbx-surface="detail-grid"','data-kbx-surface="summary"','data-kbx-surface="record-lifecycle"'],'T03 first-class surfaces')
const fastEntry=read('packages/kbx-ui/src/components/KbxFastEntryPage.vue')
must(fastEntry,['errors?:KbxValidationError[]','summaryItems?:KbxSummaryItem[]','<KbxValidationSummary :errors="errors"','<KbxSummaryBar :items="summaryItems" align="end"','data-kbx-surface="keyboard-guide"','data-kbx-surface="editable-grid"','data-kbx-surface="validation"'],'T04 first-class surfaces')
const queue=read('packages/kbx-ui/src/components/KbxQueuePage.vue')
must(queue,['summaryItems?:KbxSummaryItem[]','exceptionCounters?:KbxWorkQueueCounter[]','<KbxExceptionSummary :counters="exceptionCounters"','exceptionFilter:[string|null]','data-kbx-surface="work-summary"','data-kbx-surface="exception-summary"','data-kbx-surface="queue/content"'],'T06 exception-driven surfaces')
const otherTemplates={
'T02': ['packages/kbx-ui/src/components/KbxMasterPage.vue',['data-kbx-surface="detail"','data-kbx-surface="record-lifecycle"']],
'T05': ['packages/kbx-ui/src/components/KbxMasterDetailPage.vue',['summaryItems?:KbxSummaryItem[]','<KbxSummaryBar :items="summaryItems"','data-kbx-surface="master"','data-kbx-surface="detail"','data-kbx-surface="bottom/history"']],
'T07': ['packages/kbx-ui/src/components/KbxReconcilePage.vue',['data-kbx-surface="criteria/search"','data-kbx-surface="summary"','data-kbx-surface="comparison-grid"','data-kbx-surface="resolution-action"']],
'T08': ['packages/kbx-ui/src/components/KbxImportPage.vue',['data-kbx-surface="import-content"']],
'T09': ['packages/kbx-ui/src/wms/KbxWmsMobilePage.vue',['data-kbx-surface="mobile-header"','data-kbx-surface="network-state"','data-kbx-surface="instruction/content"','data-kbx-surface="sticky-actions"']],
}
for(const [code,[file,needles]] of Object.entries(otherTemplates)) must(read(file),needles,`${code} surface markers`)
const excel=read('packages/kbx-ui/src/components/KbxExcelImport.vue')
must(excel,['data-kbx-surface="progress-steps"','data-kbx-surface="file"','data-kbx-surface="mapping"','data-kbx-surface="validation"','data-kbx-surface="result"'],'T08 Excel workflow surfaces')
const homeLogic=read('packages/kbx-ui/src/shell/homeNavigation.ts')
must(homeLogic,["pinned:'고정'","tab.pinned?'pinned':'open'",'Boolean(b.pinned)'],'Home pinned priority')
const home=read('packages/kbx-ui/src/shell/KbxHomePage.vue')
must(home,['toggleFavorite:[screenId:string]','favoriteIds','openCounts','aria-keyshortcuts="Control+K"','class="favorite"',':aria-pressed="isFavorite(entry.screenId)"','열림 {{openCount(entry.screenId)}}'],'Home actionable navigation')
const appFrame=read('apps/web/src/shell/KbxAppFrame.vue')
must(appFrame,['@toggle-favorite="store.toggleFavorite"'],'Home favorite persistence wiring')
const side=read('packages/kbx-ui/src/shell/KbxSideNavigation.vue')
must(side,['aria-keyshortcuts="Control+K"',':aria-pressed="isFavorite(entry.screenId)"'],'Side navigation shortcut/favorite state')
const globalHeader=read('packages/kbx-ui/src/shell/KbxGlobalHeader.vue')
must(globalHeader,['aria-keyshortcuts="Control+K"'],'Global header shortcut discoverability')
const itemPrice=read('apps/web/src/modules/erp/item-prices/ItemPriceFastEntryPage.vue')
must(itemPrice,[':errors="vm.errors.value"',':summary-items="summary"'],'T04 golden adoption')
const work=read('apps/web/src/modules/wms/work/WmsWorkPage.vue')
must(work,[':summary-items="summaryItems"',':exception-counters="exceptionCounters"','@exception-filter="filterException"'],'T06 golden adoption')
const order=read('apps/web/src/modules/oms/orders/register/OrderRegisterPage.vue')
must(order,[':summary-items="summaryItems"'],'T03 golden adoption')
const inventory=read('apps/web/src/modules/erp/inventory/InventoryPage.vue')
must(inventory,[':summary-items="summaryItems"'],'T05 golden adoption')
const validation=read('packages/kbx-ui/src/components/KbxValidationSummary.vue')
must(validation,['var(--kbx-color-danger-border)','var(--kbx-color-danger-surface)','var(--kbx-font-sm)'],'ValidationSummary token normalization')
const exceptions=read('packages/kbx-ui/src/components/KbxExceptionSummary.vue')
must(exceptions,['var(--kbx-space-2)','var(--kbx-control-lg)','var(--kbx-font-sm)'],'ExceptionSummary token normalization')
const manifest=read('packages/kbx-ui/src/registry/componentManifest.ts')
must(manifest,["KbxListPage', category: 'template'","KbxTransactionPage', category: 'template'","KbxFastEntryPage', category: 'template'","KbxQueuePage', category: 'template'","KbxHomePage', category: 'shell'"],'v32 component manifest coverage')
if(failures.length){console.error(failures.join('\n'));process.exit(1)}
console.log('v32 template/component/home navigation validation passed: first-class standard surfaces, T01/T03/T04/T06 composition contracts, T01-T09 surface observability, exception-driven queue adoption, pinned/favorite Home navigation, shortcut accessibility, and token normalization.')
@@ -0,0 +1,63 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd(); const failures=[]
const read=p=>fs.readFileSync(path.join(root,p),'utf8')
const must=(text,needles,scope)=>needles.forEach(n=>{if(!text.includes(n))failures.push(`${scope} missing ${n}`)})
const ui=read('packages/kbx-contracts/src/ui.ts')
must(ui,["export type KbxAsyncState = 'idle' | 'ready' | 'loading' | 'empty' | 'error'"],'async state contract')
const screen=read('packages/kbx-contracts/src/screen.ts')
must(screen,['export type KbxTemplateStateCapability',"| 'permission'","| 'dirty'","| 'conflict'","| 'validation'","| 'job'","| 'network'",'stateCapabilities: KbxTemplateStateCapability[]'],'template state capability contract')
const manifest=read('packages/kbx-ui/src/registry/templateManifest.ts')
for(const code of ['T01','T02','T03','T04','T05','T06','T07','T08','T09']){
const line=manifest.split('\n').find(value=>value.includes(`code:'${code}'`))??''
must(line,['stateCapabilities:','\'permission\''],`${code} state capability matrix`)
}
must(manifest,["const commonStates=['idle','loading','empty','error','ready']", "stateCapabilities:['idle','loading','refreshing','empty','error','permission']", "stateCapabilities:['loading','refreshing','error','permission','dirty','conflict','validation']", "stateCapabilities:['loading','refreshing','error','permission','validation','job']", "stateCapabilities:['loading','refreshing','error','permission','network']"],'state matrix applicability')
const dataState=read('packages/kbx-ui/src/components/KbxDataState.vue')
must(dataState,["idle: { title:'조회 전입니다.'", "state==='idle' ? '○'"],'idle data state')
const boundary=read('packages/kbx-ui/src/components/KbxTemplateStateBoundary.vue')
must(boundary,['idleActionLabel?: string','idleAction:[]','data-template-state','state===\'idle\'','@action="emit(\'idleAction\')"'],'template idle boundary')
for(const file of ['KbxListPage.vue','KbxMasterPage.vue','KbxMasterDetailPage.vue','KbxQueuePage.vue','KbxReconcilePage.vue']){
const text=read(`packages/kbx-ui/src/components/${file}`)
must(text,['idle-action-label','@idle-action'],`${file} idle recovery wiring`)
}
const host=read('packages/kbx-ui/src/permission/host.ts')
must(host,['export interface KbxPermissionHost','export const KbxPermissionHostKey'],'permission host')
const appFrame=read('apps/web/src/shell/KbxAppFrame.vue')
must(appFrame,['KbxPermissionHostKey','provide(KbxPermissionHostKey,{has:','props.grantedPermissions.includes(permission)'],'permission provider')
for(const [file,needles] of [
['packages/kbx-ui/src/components/KbxScreenFrame.vue',['KbxPermissionHostKey','permissionHost=inject','canPermission(permission:string)','data-access','permissionDenied','이 화면을 사용할 권한이 없습니다.',':can="canPermission"']],
['packages/kbx-ui/src/components/KbxCommandBar.vue',['KbxPermissionHostKey','permissionHost=inject','canPermission(permission:string)','!permission||canPermission(permission)']],
['packages/kbx-ui/src/components/KbxWorkflowBar.vue',['KbxPermissionHostKey','permissionHost=inject','canPermission(permission:string)','!x.permission||canPermission(x.permission)']],
['packages/kbx-ui/src/components/KbxBulkActionBar.vue',['KbxPermissionHostKey','permissionHost=inject','canPermission(permission:string)','!a.permission||canPermission(a.permission)']],
['packages/kbx-ui/src/wms/KbxWmsMobilePage.vue',['KbxPermissionHostKey','permissionHost=inject','permissionDenied','data-access','이 현장 업무를 사용할 권한이 없습니다.']],
]) must(read(file),needles,`${file} injected permission enforcement`)
for(const file of [
'apps/web/src/modules/oms/orders/search/OrderListPage.vue',
'apps/web/src/modules/oms/claims/ClaimsPage.vue',
'apps/web/src/modules/erp/items/ItemMasterPage.vue',
'apps/web/src/modules/erp/inventory/InventoryPage.vue',
'apps/web/src/modules/common/ux-metrics/UxMetricsPage.vue',
'apps/web/src/modules/wms/work/WmsWorkPage.vue',
]) must(read(file),["?'idle'"],`${file} initial-state adoption`)
const home=read('packages/kbx-ui/src/shell/KbxHomePage.vue')
must(home,['operationFailureCount?:number','urgentNotificationCount?:number','실패·부분완료 작업','중요 알림','regularNotificationCount','button.critical'],'Home attention workbench')
must(appFrame,['failedOperationCount','partially-completed','urgentUnreadCount','urgent-notification-count'],'runtime attention wiring')
const store=read('apps/web/src/shell/workspaceStore.ts')
must(store,["STORAGE_PREFIX='kbx.navigation.preference.v3'","LEGACY_STORAGE_PREFIX='kbx.navigation.preference.v2'",'MAX_STORED_PREFERENCE_BYTES=64_000','MAX_SCOPE_LENGTH=160','MAX_RECENT_PATH_LENGTH=1024','parseStoredPreference','Date.parse(visitedAt)','localStorage.getItem(storageKey(scope,LEGACY_STORAGE_PREFIX))','normalizePreference({...preference.value,recents:'],'persisted preference hardening/migration')
const navSecurity=read('packages/kbx-ui/src/shell/navigationSecurity.ts')
must(navSecurity,['MAX_WORKSPACE_URL_LENGTH=2048','[\\u0000-\\u001f\\u007f]'],'workspace URL size/control-character hardening')
const componentManifest=read('packages/kbx-ui/src/registry/componentManifest.ts')
must(componentManifest,["KbxDataState', category: 'business', purpose: 'Idle·Loading·Empty·Error", "version:'1.1.0'", "KbxHomePage', category: 'shell', purpose: '미저장·실패 작업·중요 알림", "KbxListPage', category: 'template'", "version: '1.6.0'", "KbxWmsMobilePage', category: 'wms'", "version: '1.5.0'", "KbxCommandBar', category: 'business', purpose: 'Permission Host", "version: '1.3.0'", "KbxWorkflowBar', category: 'business', purpose: 'Permission Host", "KbxBulkActionBar', category: 'business', purpose: 'Permission Host"],'v33 component versions')
const catalog=read('packages/kbx-ui/src/catalog/componentCatalog.ts')
must(catalog,["id:'idle',label:'조회 전',state:'idle'","id:'attention',label:'실패 작업·중요 알림 우선',state:'error'"],'catalog state scenarios')
if(failures.length){console.error(failures.join('\n'));process.exit(1)}
console.log('v33 template/component/home navigation validation passed: explicit idle state, template state-capability matrix, injected permission enforcement, actionable Home attention, hardened preference migration/storage, and workspace URL bounds.')
@@ -0,0 +1,62 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd();const failures=[]
const read=p=>fs.readFileSync(path.join(root,p),'utf8')
const must=(text,needles,scope)=>needles.forEach(needle=>{if(!text.includes(needle))failures.push(`${scope} missing ${needle}`)})
const lookupContract=read('packages/kbx-contracts/src/lookup.ts')
must(lookupContract,['KbxLookupColumnDefinition','metadata.${string}','columns?: KbxLookupColumnDefinition[]','pageSize?: number'],'lookup presentation contract')
const lookup=read('packages/kbx-ui/src/components/KbxLookup.vue')
must(lookup,['resolveSequence','조회 공급자가 구성되지 않았습니다.','선택 정보를 불러오지 못했습니다.','코드를 확인하지 못했습니다.',':columns="columns"',':page-size="pageSize"'],'lookup async guard')
const lookupDialog=read('packages/kbx-ui/src/components/KbxLookupDialog.vue')
must(lookupDialog,["import KbxDialog from './KbxDialog.vue'",'requestSequence','pageCount','조회하지 못했습니다.','조회된 항목이 없습니다.','aria-selected','scrollSelectedIntoView'],'lookup dialog maturity')
if(/primevue\//.test(lookupDialog))failures.push('KbxLookupDialog must compose KBX primitives instead of importing PrimeVue directly')
const excelContract=read('packages/kbx-contracts/src/excel.ts')
must(excelContract,['export interface KbxImportFailure','failure?: KbxImportFailure'],'import failure contract')
const importGuard=read('packages/kbx-ui/src/excel/importGuard.ts')
must(importGuard,['validateKbxImportMappings','duplicate-target','required-missing','validateKbxImportFileCandidate',"endsWith('.xlsx')"],'import guard')
const excel=read('packages/kbx-ui/src/components/KbxExcelImport.vue')
must(excel,['localMapping = ref<KbxImportMapping[] | null>(null)','watch(() => props.session?.id','mappingIssues','KbxConfirm','requestCommit','isFailed','isCancelled','isPartial','새 파일로 다시 시작'],'Excel import recovery/mapping maturity')
const operations=read('packages/kbx-contracts/src/operations.ts')
must(operations,["permissionMode?: 'hide' | 'disable'"],'exception permission UX contract')
const exceptionDrawer=read('packages/kbx-ui/src/components/KbxExceptionDetailDrawer.vue')
must(exceptionDrawer,['KbxPermissionHostKey','KbxDrawer','permissionMode===\'disable\'','이 작업을 실행할 권한이 없습니다.','data-kbx-component="exception-detail-drawer"'],'exception drawer hardening')
const aiContract=read('packages/kbx-contracts/src/ai.ts')
must(aiContract,['KbxAiProposalValidationState','validation?: KbxAiProposalValidation','KbxAiAnswerAction','requiredCapability?: KbxAiCapability','requiredPermission?: string'],'AI action/validation contract')
const assistant=read('packages/kbx-ui/src/components/KbxAiAssistant.vue')
must(assistant,['KbxPermissionHostKey','actionAllowed','proposalAllowed','lastSubmitted','다시 질문','currentScreenLabel','현재 사용자 권한 또는 AI 허용 범위를 벗어난'],'AI assistant guard/recovery')
if(assistant.includes('{{ context.screenId }}'))failures.push('AI assistant must not expose ScreenId as the default user-facing current-screen label')
const proposal=read('packages/kbx-ui/src/components/KbxProposalPanel.vue')
must(proposal,['KbxPermissionHostKey','permissionAllowed','validationAllowed','data-validation','서버에서 대상·권한·업무규칙','대상 {{proposal.targets.length.toLocaleString()}}건'],'AI proposal enforcement')
const appFrame=read('apps/web/src/shell/KbxAppFrame.vue')
must(appFrame,['utilityAiError','네트워크 상태를 확인한 후 다시 질문하세요.',':ai-error="utilityAiError"',':ai-current-screen-label="utilityScreen?.title"'],'AI shell error recovery')
const barcodeGuard=read('packages/kbx-ui/src/wms/barcodeGuard.ts')
must(barcodeGuard,['normalizeKbxBarcode','isKbxBarcodeDuplicate'],'barcode guard')
const barcode=read('packages/kbx-ui/src/wms/KbxBarcodeCapture.vue')
must(barcode,['maxLength?: number','duplicateDebounceMs?: number','duplicateIgnored','isKbxBarcodeDuplicate','defineExpose({submitCamera,reset})','visibilitychange'],'barcode capture hardening')
const home=read('packages/kbx-ui/src/shell/KbxHomePage.vue')
must(home,['regularOpenCount','open:regularOpenCount.value','watch(modules','visibleScreenCount','aria-live="polite"'],'Home navigation resilience')
const templateManifest=read('packages/kbx-ui/src/registry/templateManifest.ts')
const componentManifest=read('packages/kbx-ui/src/registry/componentManifest.ts')
const catalog=read('packages/kbx-ui/src/catalog/componentCatalog.ts')
const coreGroups=[...templateManifest.matchAll(/coreComponents:\[([^\]]+)\]/g)]
const coreComponents=new Set(coreGroups.flatMap(match=>[...match[1].matchAll(/'([^']+)'/g)].map(item=>item[1])))
const manifestNames=new Set([...componentManifest.matchAll(/name: '([^']+)'/g)].map(match=>match[1]))
const catalogNames=new Set([...catalog.matchAll(/component:'([^']+)'/g)].map(match=>match[1]))
for(const component of coreComponents){
if(!manifestNames.has(component))failures.push(`template core component missing manifest: ${component}`)
if(!catalogNames.has(component))failures.push(`template core component missing catalog scenarios: ${component}`)
}
for(const component of ['KbxLookupDialog','KbxValidationSummary','KbxExceptionSummary','KbxExceptionDetailDrawer','KbxRecordLifecycle','KbxBarcodeCapture','KbxNetworkIndicator','KbxWmsActionButton']){
if(!catalogNames.has(component))failures.push(`v34 required catalog entry missing: ${component}`)
}
must(componentManifest,["KbxLookup', category: 'business', purpose: '비동기 race/error", "KbxLookupDialog', category: 'business', purpose: 'Provider 오류", "KbxExcelImport', category: 'business', purpose: '파일 preflight", "KbxExceptionDetailDrawer', category: 'business', purpose: '표준 Drawer", "KbxAiAssistant', category: 'business', purpose: 'Capability/Permission guard", "KbxBarcodeCapture', category: 'wms', purpose: 'Keyboard-wedge/Camera", "KbxHomePage', category: 'shell', purpose: '미저장·실패 작업"],'v34 component versions/descriptions')
if(failures.length){console.error(failures.join('\n'));process.exit(1)}
console.log(`v34 template/component/home validation passed: ${coreComponents.size} template core components have manifest+catalog coverage; Lookup/Import/Exception/AI/Barcode recovery and permission boundaries hardened; Home counts/filter recovery corrected.`)
@@ -0,0 +1,55 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd();const failures=[]
const read=p=>fs.readFileSync(path.join(root,p),'utf8')
const json=p=>JSON.parse(read(p))
const must=(text,needles,scope)=>needles.forEach(needle=>{if(!text.includes(needle))failures.push(`${scope} missing ${needle}`)})
const recipes=json('contracts/screens/kbx.screen-recipes.json')
if(recipes.recipes.length!==9)failures.push(`screen recipes expected 9, found ${recipes.recipes.length}`)
const expected={T01:'list',T02:'master',T03:'transaction',T04:'fast-entry',T05:'master-detail',T06:'queue',T07:'reconcile',T08:'import',T09:'wms-mobile'}
const scenarioIds=new Set(json('contracts/testing/kbx.test-scenarios.json').scenarios.map(item=>item.id))
for(const [code,type] of Object.entries(expected)){
const recipe=recipes.recipes.find(item=>item.code===code)
if(!recipe)continue
if(recipe.type!==type)failures.push(`${code} recipe type must be ${type}`)
for(const key of ['templateComponent','defaultCommands','requiredPolicies','recoveryPolicies','securityPolicies','canonicalScenarioIds','scaffoldSurfaces'])if(!recipe[key]||!recipe[key].length&&key!=='defaultCommands')failures.push(`${code} recipe missing ${key}`)
for(const scenarioId of recipe.canonicalScenarioIds)if(!scenarioIds.has(scenarioId))failures.push(`${code} recipe references unknown scenario ${scenarioId}`)
}
const screenContract=read('packages/kbx-contracts/src/screen.ts')
must(screenContract,['KbxScreenTemplateCode','KbxScreenRecipeDefinition','templateCode: KbxScreenTemplateCode'],'screen recipe/type contract')
const generator=read('scripts/generate-screen-recipe-contracts.mjs')
must(generator,['generated/screen-recipe-manifest.json','screenRecipeCatalog.ts'],'screen recipe generator')
const generated=json('generated/screen-recipe-manifest.json')
if(generated.recipes.length!==9)failures.push('generated screen recipe manifest must contain 9 recipes')
const screenManifest=json('generated/screen-manifest.json')
for(const screen of screenManifest){
const recipe=recipes.recipes.find(item=>item.code===screen.templateCode)
if(!recipe)failures.push(`${screen.id} missing/unknown templateCode ${screen.templateCode}`)
else if(recipe.type!==screen.type)failures.push(`${screen.id} ${screen.templateCode}/${screen.type} mismatch`)
}
const frame=read('packages/kbx-ui/src/components/KbxScreenFrame.vue')
must(frame,['KbxScreenTemplateCode','props.screen.templateCode!==props.templateCode'],'runtime templateCode guard')
const wms=read('packages/kbx-ui/src/wms/KbxWmsMobilePage.vue')
must(wms,["props.screen.templateCode!=='T09'"],'T09 runtime recipe guard')
const scaffolder=read('scripts/create-kbx-screen.mjs')
must(scaffolder,["contracts/screens/kbx.screen-recipes.json",'--write-permission is required','permissionKind===\'write\'',"templateCode: '${recipe.code}'",'canonical scenarios:'],'recipe-driven secure scaffolder')
if(scaffolder.includes("const commandMap ="))failures.push('scaffolder must not keep a second hard-coded commandMap beside recipe contract')
const navigation=read('packages/kbx-contracts/src/navigation.ts')
must(navigation,['KbxHomeAttentionSource','KbxHomeAttentionItem','operationId?: string','notificationId?: string'],'Home attention contract')
const homeNav=read('packages/kbx-ui/src/shell/homeNavigation.ts')
must(homeNav,['buildKbxHomeAttention','operation-failed','notification-urgent','operation-running','allowedIds.has'],'Home attention orchestration')
const home=read('packages/kbx-ui/src/shell/KbxHomePage.vue')
must(home,['buildKbxHomeAttention',"@click=\"emit('attention',item)\""],'Home attention UI')
if(home.includes(':operations="homeOperations"'))failures.push('HomePage must receive operations as props, not reference AppFrame state directly')
must(home,['operations?:KbxOperationRun[]','notifications?:KbxUserNotification[]','kbx-home__attention-list','attention:[item:KbxHomeAttentionItem]'],'Home actionable workbench')
const app=read('apps/web/src/shell/KbxAppFrame.vue')
must(app,['homeOperations=computed','homeNotifications=computed','openHomeAttention','safeWorkspacePath(item.screenId,candidate)','알림에 포함된 허용되지 않은 이동 경로를 차단했습니다.',':operations="homeOperations"',':notifications="homeNotifications"','@attention="openHomeAttention"'],'Home runtime security orchestration')
if(failures.length){console.error(failures.join('\n'));process.exit(1)}
console.log(`v35 recipe/navigation validation passed: recipes=${recipes.recipes.length}, screens=${screenManifest.length}; explicit templateCode, canonical scenario closure, secure write scaffolding, and actionable Home attention routing are enforced.`)
@@ -0,0 +1,59 @@
import fs from 'node:fs'
import path from 'node:path'
const root=process.cwd();const failures=[]
const read=p=>fs.readFileSync(path.join(root,p),'utf8')
const json=p=>JSON.parse(read(p))
const must=(text,needles,scope)=>needles.forEach(needle=>{if(!text.includes(needle))failures.push(`${scope} missing ${needle}`)})
const recipes=json('contracts/screens/kbx.screen-recipes.json')
const verification=json('generated/screen-recipe-verification-manifest.json')
const scenarios=json('contracts/testing/kbx.test-scenarios.json').scenarios
const screens=json('generated/screen-manifest.json')
const screenById=new Map(screens.map(screen=>[screen.id,screen]))
const scenarioById=new Map(scenarios.map(scenario=>[scenario.id,scenario]))
if(recipes.recipes.length!==9)failures.push(`expected 9 recipes, found ${recipes.recipes.length}`)
if(verification.recipes.length!==9)failures.push(`expected 9 recipe verification rows, found ${verification.recipes.length}`)
for(const recipe of recipes.recipes){
const profile=recipe.testProfile
if(!profile) { failures.push(`${recipe.code} missing testProfile`); continue }
for(const key of ['requiredScenarioKinds','requiredTags','requiredEvidence','requiredChecks'])if(!(profile[key]?.length>0))failures.push(`${recipe.code} testProfile missing ${key}`)
if(!profile.requiredScenarioKinds.includes('e2e'))failures.push(`${recipe.code} must require at least one E2E scenario`)
const row=verification.recipes.find(item=>item.code===recipe.code)
if(!row?.complete)failures.push(`${recipe.code} recipe verification incomplete: ${JSON.stringify(row)}`)
const canonical=recipe.canonicalScenarioIds.flatMap(id=>scenarioById.has(id)?[scenarioById.get(id)]:[])
const representativeE2e=canonical.find(scenario=>scenario.kind==='e2e'&&screenById.get(scenario.screenId)?.templateCode===recipe.code)
if(!representativeE2e)failures.push(`${recipe.code} needs an E2E scenario on a screen that actually uses ${recipe.code}`)
}
const typeContract=read('packages/kbx-contracts/src/screen.ts')
must(typeContract,['KbxScreenRecipeTestProfile','requiredScenarioKinds','requiredTags','requiredEvidence','requiredChecks'],'recipe verification type contract')
const generator=read('scripts/generate-screen-recipe-contracts.mjs')
must(generator,['screen-recipe-verification-manifest.json','missingScenarioKinds','missingTags','missingEvidence','kbxScreenRecipeVerificationCatalog'],'recipe verification generator')
for(const [file,id] of [
['tests/e2e/erp-item-master.spec.ts','scenario.erp.item-master.keyboard-recovery'],
['tests/e2e/common-operations.spec.ts','scenario.common.operations.queue-recovery'],
['tests/e2e/common-reconcile.spec.ts','scenario.common.reconcile.mismatch-recovery'],
]) must(read(file),[id,'scenarioTitle','data-kbx-surface'],file)
const scaffolder=read('scripts/create-kbx-screen.mjs')
must(scaffolder,['KbxGeneratedScreenTestPlan','.test-plan.ts','recipe.testProfile.requiredChecks','recipe.testProfile.requiredEvidence'],'recipe-driven generated test plan')
const scaffoldValidator=read('scripts/validate-scaffolder.mjs')
must(scaffoldValidator,['generated recipe test plan','test plan missing required check','test plan missing evidence'],'scaffolder test-plan governance')
const navigation=read('packages/kbx-contracts/src/navigation.ts')
must(navigation,['occurredAt: string','KbxHomeAttentionQueue','overflowCount','sourceCounts'],'Home attention queue contract')
const homeNav=read('packages/kbx-ui/src/shell/homeNavigation.ts')
must(homeNav,['buildKbxHomeAttentionQueue','b.occurredAt.localeCompare(a.occurredAt)','overflowCount','sourceCounts','Backward-compatible item-only view'],'Home attention deterministic queue')
const home=read('packages/kbx-ui/src/shell/KbxHomePage.vue')
must(home,['buildKbxHomeAttentionQueue','exactAttentionCount','attentionOverflowCount','전체 {{exactAttentionCount}}개 · 상위 {{attentionItems.length}}개 표시'],'Home exact attention count UX')
const uiIndex=read('packages/kbx-ui/src/index.ts')
if(!uiIndex.includes('buildKbxHomeAttentionQueue'))failures.push('public UI API missing buildKbxHomeAttentionQueue')
const homeTest=read('tests/unit/kbx-home-attention.contract.spec.ts')
must(homeTest,['newest-first within priority','overflowCount','sourceCounts'], 'Home attention queue unit contract')
const releaseAnalyzer=read('scripts/analyze-kbx-release.mjs')
must(releaseAnalyzer,['screen-recipe','testProfile','canonicalScenarioIds'],'release analyzer recipe verification tracking')
if(failures.length){console.error(failures.join('\n'));process.exit(1)}
console.log(`v36 recipe/home validation passed: recipes=${recipes.recipes.length}, recipe verification=9/9, canonical E2E representative coverage enforced, and Home attention exact-count/newest-first queue is active.`)
@@ -0,0 +1,92 @@
import fs from 'node:fs'
const read = p => JSON.parse(fs.readFileSync(p, 'utf8'))
const scenarios = read('contracts/testing/kbx.test-scenarios.json')
const fixtures = read('contracts/testing/kbx.test-fixtures.json')
const screens = read('generated/screen-manifest.json')
const apis = read('generated/api-manifest.json')
const permissions = read('generated/permission-manifest.json')
const problems = new Set(read('generated/problem-manifest.json').types ?? [])
const screenIds = new Set(screens.map ? screens.map(x=>x.id) : (screens.screens ?? []).map(x=>x.id))
const apiMap = new Map((apis.operations ?? []).map(x => [x.id, x]))
const permissionIds = new Set((permissions.permissions ?? []).map(x => x.id))
const fixtureSetIds = new Set((fixtures.fixtureSets ?? []).map(x => x.id))
const fixtureRefs = new Set(Object.keys(fixtures.refs ?? {}))
const errors = []
if (!fixtures.syntheticOnly || !scenarios.principles?.syntheticFixturesOnly || !scenarios.principles?.noProductionData)
errors.push('test contracts must declare synthetic-only/no-production-data policy')
if (fixtures.fixedReferenceClock !== scenarios.principles?.fixedReferenceClock)
errors.push('scenario and fixture reference clocks differ')
const ids = new Set()
for (const s of scenarios.scenarios ?? []) {
if (ids.has(s.id)) errors.push(`duplicate scenario id: ${s.id}`)
ids.add(s.id)
if (!/^scenario\.[a-z0-9.-]+$/.test(s.id)) errors.push(`invalid scenario id: ${s.id}`)
if (!screenIds.has(s.screenId)) errors.push(`${s.id}: unknown screenId ${s.screenId}`)
if (!['e2e','integration','contract'].includes(s.kind)) errors.push(`${s.id}: invalid kind ${s.kind}`)
if (!['database-reset','tenant-reset','ui-only'].includes(s.isolation)) errors.push(`${s.id}: invalid isolation ${s.isolation}`)
if (!(s.assertions?.length > 0)) errors.push(`${s.id}: assertions required`)
if (!(s.evidence?.length > 0)) errors.push(`${s.id}: evidence required`)
for (const setId of s.fixtureSets ?? []) if (!fixtureSetIds.has(setId)) errors.push(`${s.id}: unknown fixture set ${setId}`)
for (const p of s.requiredPermissions ?? []) if (!permissionIds.has(p)) errors.push(`${s.id}: unknown permission ${p}`)
for (const opId of s.apiOperations ?? []) {
const op = apiMap.get(opId)
if (!op) { errors.push(`${s.id}: unknown api operation ${opId}`); continue }
if (!(s.steps ?? []).some(step => step.operationId === opId)) errors.push(`${s.id}: API ${opId} declared but no step uses it`)
if (op.idempotency === 'required') {
const uses = (s.steps ?? []).filter(step => step.operationId === opId)
if (!uses.some(step => typeof step.idempotencyKeyRef === 'string')) errors.push(`${s.id}: required-idempotency API ${opId} needs idempotencyKeyRef`)
}
}
for (const step of s.steps ?? []) {
if (step.operationId && !apiMap.has(step.operationId)) errors.push(`${s.id}/${step.id}: unknown step operation ${step.operationId}`)
if (step.fixtureRef && !fixtureRefs.has(step.fixtureRef)) errors.push(`${s.id}/${step.id}: unknown fixtureRef ${step.fixtureRef}`)
if (step.idempotencyKeyRef && !fixtureRefs.has(step.idempotencyKeyRef)) errors.push(`${s.id}/${step.id}: unknown idempotencyKeyRef ${step.idempotencyKeyRef}`)
if (step.problemType && !problems.has(step.problemType)) errors.push(`${s.id}/${step.id}: unknown problemType ${step.problemType}`)
const raw = JSON.stringify(step)
if (/https?:\/\//i.test(raw) || /\/api\//i.test(raw)) errors.push(`${s.id}/${step.id}: raw URL/API route is forbidden; use operationId`)
}
}
for (const set of fixtures.fixtureSets ?? []) {
for (const ref of set.refs ?? []) if (!fixtureRefs.has(ref)) errors.push(`${set.id}: unknown ref ${ref}`)
}
// Simple synthetic-data guard: real-looking Korean mobile numbers must use 0000 exchange in canonical fixtures.
const fixtureText = JSON.stringify(fixtures)
for (const m of fixtureText.matchAll(/010-(\d{4})-(\d{4})/g)) if (m[1] !== '0000') errors.push(`fixture contains non-canonical phone pattern: ${m[0]}`)
for (const [ref, value] of Object.entries(fixtures.refs ?? {})) {
const serialized = JSON.stringify(value)
if (/\b\d{13}\b/.test(serialized)) errors.push(`${ref}: production-like 13-digit barcode forbidden in canonical fixture`)
if (value.kind === 'customer' && !String(value.code ?? '').startsWith('TEST-')) errors.push(`${ref}: customer code must start TEST-`)
if (value.kind === 'order' && !String(value.orderNo ?? '').startsWith('TEST-')) errors.push(`${ref}: orderNo must start TEST-`)
}
const golden = ['OMS-ORD-001','OMS-ORD-002','OMS-ORD-003','WMS-PICK-001','COMMON-OPS-001','COMMON-REC-001','ERP-INV-MOVE-001','ERP-PRICE-001','ERP-INV-001','ERP-MST-ITEM-001']
for (const screenId of golden) if (!(scenarios.scenarios ?? []).some(s => s.screenId === screenId)) errors.push(`canonical scenario missing for ${screenId}`)
const seed = fs.readFileSync('tests/fixtures/postgres/20-seed-v18.sql','utf8')
const reset = fs.readFileSync('tests/fixtures/postgres/10-reset-v18.sql','utf8')
for (const [name,text] of [['seed',seed],['reset',reset]]) {
if (!text.includes('KBX_SCENARIO_TEST_ONLY')) errors.push(`${name} SQL missing environment guard`)
if (/\btruncate\b/i.test(text)) errors.push(`${name} SQL must not truncate arbitrary host data`)
}
const generated = read('generated/test-scenario-manifest.json')
if (generated.scenarios.length !== scenarios.scenarios.length) errors.push('generated scenario manifest count mismatch')
const generatedFixtures = read('generated/test-fixture-manifest.json')
if (!generatedFixtures.syntheticOnly) errors.push('generated fixture manifest lost syntheticOnly')
const tsGenerated = fs.readFileSync('packages/kbx-contracts/src/generated/testScenarioCatalog.ts','utf8')
const csGenerated = fs.readFileSync('backend/Shared/Testing/Generated/KbxTestScenarioCatalog.g.cs','utf8')
if (!tsGenerated.includes(generated.sourceSha256) || !csGenerated.includes(generated.sourceSha256)) errors.push('scenario source SHA parity missing in TS/C# generated catalogs')
if (!tsGenerated.includes(generated.fixtureSourceSha256) || !csGenerated.includes(generated.fixtureSourceSha256)) errors.push('fixture source SHA parity missing in TS/C# generated catalogs')
if (errors.length) {
console.error('test governance FAIL')
for (const e of errors) console.error(`- ${e}`)
process.exit(1)
}
console.log(`test governance PASS: scenarios=${scenarios.scenarios.length}, fixtureSets=${fixtures.fixtureSets.length}, refs=${fixtureRefs.size}, goldenCoverage=${golden.length}/${golden.length}`)
@@ -0,0 +1,50 @@
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
import { execSync } from 'node:child_process'
const require = createRequire(import.meta.url)
let ts
try {
ts = require('typescript')
} catch {
try {
const globalRoot = execSync('npm root -g', { encoding:'utf8' }).trim()
ts = require(path.join(globalRoot, 'typescript'))
} catch {
console.log('TypeScript is not installed yet; syntax transpile gate is deferred to pnpm typecheck.')
process.exit(0)
}
}
const root = process.cwd()
function walk(dir) {
if (!fs.existsSync(dir)) return []
return fs.readdirSync(dir,{withFileTypes:true}).flatMap(e => e.isDirectory() ? walk(path.join(dir,e.name)) : [path.join(dir,e.name)])
}
let count = 0
const failures = []
for (const file of walk(root)) {
if (file.includes('/node_modules/')) continue
let source = null
if (file.endsWith('.ts')) source = fs.readFileSync(file,'utf8')
else if (file.endsWith('.vue')) {
const text = fs.readFileSync(file,'utf8')
const match = text.match(/<script setup[^\n]*>\s*([\s\S]*?)<\/script>/)
if (match) source = match[1]
}
if (source == null) continue
const result = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
reportDiagnostics: true,
fileName: file + (file.endsWith('.vue') ? '.ts' : ''),
})
const diagnostics = (result.diagnostics ?? []).filter(x => x.category === ts.DiagnosticCategory.Error)
if (diagnostics.length) failures.push({ file: path.relative(root,file), diagnostics: diagnostics.map(x => ts.flattenDiagnosticMessageText(x.messageText, ' ')) })
count++
}
if (failures.length) {
console.error(JSON.stringify(failures, null, 2))
process.exit(1)
}
console.log(`TypeScript syntax transpile passed for ${count} TS/Vue script units.`)