Files
KArtSell.Aegis/docs/Design/kbx-foundation-v36/scripts/analyze-kbx-release.mjs
T

200 lines
18 KiB
JavaScript

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