|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Architecture Advisor — app-config vs model guard. |
| 4 | + * |
| 5 | + * The app's src/config/*.ts must mirror the canonical model. This recomputes the values out of |
| 6 | + * the config files and diffs them against scripts/verify-model.mjs (the executable source of |
| 7 | + * truth, itself machine-verified against the docs) plus the Model Data Sheet / Option Content |
| 8 | + * Sheet for names, anti-patterns, and templates — so the implementation can never silently drift |
| 9 | + * from discovery → requirements → blueprint. Run: node scripts/check-app-config.mjs (exit 0 = OK). |
| 10 | + */ |
| 11 | +import { readFileSync } from 'node:fs'; |
| 12 | + |
| 13 | +const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8'); |
| 14 | +const norm = (s) => s.replaceAll('−', '-'); |
| 15 | +const noComments = (s) => s.replace(/\/\/[^\n]*/g, ''); // strip line comments before value parsing |
| 16 | + |
| 17 | +const MJS = noComments(read('scripts/verify-model.mjs')); |
| 18 | +const MDS = norm(read('docs/03-blueprint/model-data-sheet.md')); |
| 19 | +const OCS = read('docs/03-blueprint/option-content-sheet.md'); |
| 20 | + |
| 21 | +const QA = ['performance','scalability','availability','security','maintainability','deployability','testability','observability','dataConsistency','interoperability','costEfficiency','timeToMarket']; |
| 22 | +const FACT = ['team','distribution','ttm','budget','lifespan','scale','dataVolume','async','realtime','domain','consistency','security','legacy','devops']; |
| 23 | + |
| 24 | +const problems = []; |
| 25 | +let n = 0; |
| 26 | +const ok = (m) => console.log(` [${++n}] ${m} ✓`); |
| 27 | +const bad = (m) => { console.log(` [${++n}] ${m} ✗`); problems.push(m); }; |
| 28 | +const arrEq = (a, b) => JSON.stringify(a) === JSON.stringify(b); |
| 29 | + |
| 30 | +// ---- helpers to pull arrays/objects out of the TS config (by text) ---- |
| 31 | +const slice = (s, from, to) => s.slice(s.indexOf(from), to ? s.indexOf(to, s.indexOf(from)) : undefined); |
| 32 | +const strList = (block) => [...block.matchAll(/'([^']+)'/g)].map((m) => m[1]); |
| 33 | + |
| 34 | +// ---- 1. QA order ---- |
| 35 | +{ |
| 36 | + const app = strList(slice(read('src/config/qualityAttributes.ts'), 'QA_ORDER', '];')); |
| 37 | + arrEq(app, QA) ? ok('QA order matches canonical (12)') : bad(`QA order differs: ${app.join(',')}`); |
| 38 | +} |
| 39 | + |
| 40 | +// ---- 2. Factor order + budget inverted ---- |
| 41 | +{ |
| 42 | + const f = read('src/config/factors.ts'); |
| 43 | + const app = strList(slice(f, 'FACTOR_ORDER', '];')); |
| 44 | + arrEq(app, FACT) ? ok('factor order matches canonical (14)') : bad(`factor order differs: ${app.join(',')}`); |
| 45 | + // budget must be flagged inverted |
| 46 | + const budgetBlock = f.slice(f.indexOf('budget: {'), f.indexOf('lifespan: {')); |
| 47 | + budgetBlock.includes('inverted: true') ? ok('budget flagged inverted') : bad('budget missing inverted flag'); |
| 48 | +} |
| 49 | + |
| 50 | +// ---- 3. influence matrix app vs verify-model.mjs ---- |
| 51 | +function parseInfluence(text, from, to) { |
| 52 | + const blk = text.slice(text.indexOf(from), text.indexOf(to, text.indexOf(from))); |
| 53 | + const out = {}; |
| 54 | + for (const m of blk.matchAll(/(\w+):\s*\{([^}]*)\}/g)) { |
| 55 | + const ent = {}; |
| 56 | + for (const e of m[2].matchAll(/(\w+):\s*(-?\d+)/g)) ent[e[1]] = Number(e[2]); |
| 57 | + if (Object.keys(ent).length) out[m[1]] = ent; |
| 58 | + } |
| 59 | + return out; |
| 60 | +} |
| 61 | +{ |
| 62 | + const app = parseInfluence(noComments(read('src/config/factorQaMatrix.ts')), 'INFLUENCE', '};'); |
| 63 | + const mjs = parseInfluence(MJS, 'const INFLUENCE', '};'); |
| 64 | + const diff = [...new Set([...Object.keys(app), ...Object.keys(mjs)])].filter((k) => JSON.stringify(app[k]) !== JSON.stringify(mjs[k])); |
| 65 | + diff.length === 0 && Object.keys(app).length === 14 ? ok('influence matrix matches verify-model.mjs (14 factors)') : bad(`influence differs at: ${diff.join(', ') || 'count'}`); |
| 66 | +} |
| 67 | + |
| 68 | +// ---- 4 & 5. dimension qaFit + option ids/names ---- |
| 69 | +function appDims() { |
| 70 | + const t = noComments(read('src/config/dimensions.ts')); |
| 71 | + const out = {}; |
| 72 | + for (const dm of t.matchAll(/(D[1-5]):\s*\{[\s\S]*?options:\s*\[([\s\S]*?)\],\s*\},/g)) { |
| 73 | + const dim = dm[1]; |
| 74 | + const opts = [...dm[2].matchAll(/\{\s*id:\s*'([^']+)',\s*name:\s*'([^']+)',\s*qaFit:\s*\[([0-9,\s]+)\]/g)].map((m) => ({ id: m[1], name: m[2], fit: m[3].split(',').map((x) => Number(x.trim())) })); |
| 75 | + out[dim] = opts; |
| 76 | + } |
| 77 | + return out; |
| 78 | +} |
| 79 | +function mjsDims() { |
| 80 | + const blk = MJS.slice(MJS.indexOf('const DIMENSIONS'), MJS.indexOf('const DEFAULTS')); |
| 81 | + const order = ['D1', 'D2', 'D3', 'D4', 'D5']; |
| 82 | + const out = {}; |
| 83 | + order.forEach((d, i) => { |
| 84 | + const start = blk.indexOf(`${d}:[`); |
| 85 | + const end = i + 1 < order.length ? blk.indexOf(`${order[i + 1]}:[`) : blk.length; |
| 86 | + const seg = blk.slice(start, end); |
| 87 | + out[d] = [...seg.matchAll(/\[(\d+(?:,\d+){11})\]/g)].map((m) => m[1].split(',').map(Number)); |
| 88 | + }); |
| 89 | + return out; |
| 90 | +} |
| 91 | +{ |
| 92 | + const app = appDims(), mjs = mjsDims(); |
| 93 | + const dims = ['D1', 'D2', 'D3', 'D4', 'D5']; |
| 94 | + const fitDiff = dims.filter((d) => !arrEq(app[d]?.map((o) => o.fit), mjs[d])); |
| 95 | + fitDiff.length === 0 ? ok('dimension qaFit vectors match verify-model.mjs (5 dimensions)') : bad(`qaFit differs in: ${fitDiff.join(', ')}`); |
| 96 | + |
| 97 | + // option ids + names vs Model Data Sheet Section 4 |
| 98 | + const mdsSec = MDS.slice(MDS.indexOf('## 4. Dimension'), MDS.indexOf('## 5.')); |
| 99 | + const mdsPairs = [...mdsSec.matchAll(/^\|\s*`([a-z0-9-]+)`\s*\|\s*([^|]+?)\s*\|\s*`[0-9,]+`/gm)].map((m) => `${m[1]}=${m[2]}`); |
| 100 | + const appPairs = dims.flatMap((d) => app[d].map((o) => `${o.id}=${o.name}`)); |
| 101 | + arrEq(appPairs.sort(), mdsPairs.sort()) ? ok(`option ids+names match Model Data Sheet Section 4 (${appPairs.length})`) : bad('option ids/names differ from Model Data Sheet Section 4'); |
| 102 | +} |
| 103 | + |
| 104 | +// ---- 6. defaults ---- |
| 105 | +{ |
| 106 | + const d = read('src/config/defaults.ts'); |
| 107 | + /ttm:\s*1/.test(d) && /budget:\s*2/.test(d) && !/team:|scale:/.test(d) ? ok('defaults = ttm:1, budget:2 (all others 0)') : bad('defaults differ'); |
| 108 | +} |
| 109 | + |
| 110 | +// ---- 7. presets (full app levels) vs verify-model deltas over defaults ---- |
| 111 | +function appPresets() { |
| 112 | + const t = noComments(read('src/config/presets.ts')); |
| 113 | + const out = {}; |
| 114 | + for (const m of t.matchAll(/id:\s*'([a-z-]+)',[\s\S]*?levels:\s*levels\(\[([0-9,\s]+)\]\)/g)) { |
| 115 | + const v = m[2].split(',').map((x) => Number(x.trim())); |
| 116 | + const o = {}; FACT.forEach((f, i) => (o[f] = v[i])); |
| 117 | + out[m[1]] = o; |
| 118 | + } |
| 119 | + return out; |
| 120 | +} |
| 121 | +function mjsPresets() { |
| 122 | + const blk = MJS.slice(MJS.indexOf('const PRESETS'), MJS.indexOf('const TARGETS')); |
| 123 | + const out = {}; |
| 124 | + for (const m of blk.matchAll(/"([a-z-]+)":\s*\{([^}]*)\}/g)) { |
| 125 | + const o = {}; FACT.forEach((f) => (o[f] = 0)); o.ttm = 1; o.budget = 2; |
| 126 | + for (const e of m[2].matchAll(/(\w+):\s*(\d+)/g)) o[e[1]] = Number(e[2]); |
| 127 | + out[m[1]] = o; |
| 128 | + } |
| 129 | + return out; |
| 130 | +} |
| 131 | +{ |
| 132 | + const app = appPresets(), mjs = mjsPresets(); |
| 133 | + const diff = []; |
| 134 | + for (const k of new Set([...Object.keys(app), ...Object.keys(mjs)])) |
| 135 | + for (const f of FACT) if (app[k]?.[f] !== mjs[k]?.[f]) diff.push(`${k}.${f}`); |
| 136 | + diff.length === 0 && Object.keys(app).length === 5 ? ok('preset levels match verify-model.mjs (5 presets × 14)') : bad(`preset levels differ at: ${diff.join(', ') || 'count'}`); |
| 137 | +} |
| 138 | + |
| 139 | +// ---- 8. anti-pattern ids + severities vs Model Data Sheet Section 5 ---- |
| 140 | +{ |
| 141 | + const t = read('src/config/antiPatterns.ts'); |
| 142 | + const app = [...t.matchAll(/id:\s*'([a-z-]+)',\s*severity:\s*'(info|warning|danger)'/g)].map((m) => `${m[1]}:${m[2]}`).sort(); |
| 143 | + const sec = MDS.slice(MDS.indexOf('## 5. Anti-pattern'), MDS.indexOf('## 6.')); |
| 144 | + const mds = [...sec.matchAll(/^\|\s*`([a-z-]+)`\s*\|\s*(info|warning|danger)\s*\|/gm)].map((m) => `${m[1]}:${m[2]}`).sort(); |
| 145 | + arrEq(app, mds) && app.length === 7 ? ok('anti-pattern ids+severities match Model Data Sheet Section 5 (7)') : bad('anti-pattern ids/severities differ from Model Data Sheet Section 5'); |
| 146 | +} |
| 147 | + |
| 148 | +// ---- 9. fitness templates (EN) vs Option Content Sheet Section 7 ---- |
| 149 | +{ |
| 150 | + const t = read('src/config/fitnessFunctions.ts'); |
| 151 | + const sec = OCS.slice(OCS.indexOf('## 7. Fitness'), OCS.indexOf('\n---', OCS.indexOf('## 7. Fitness'))); |
| 152 | + let missing = 0; |
| 153 | + for (const m of sec.matchAll(/^\|\s*(\w+)\s*\|\s*([^|]+?)\s*\|/gm)) { |
| 154 | + const qa = m[1], text = m[2].trim(); |
| 155 | + if (qa === 'QA' || !QA.includes(qa)) continue; |
| 156 | + if (!t.includes(text)) missing++; |
| 157 | + } |
| 158 | + missing === 0 ? ok('fitness templates (EN) match Option Content Sheet Section 7 (12)') : bad(`${missing} fitness template(s) differ from Option Content Sheet`); |
| 159 | +} |
| 160 | + |
| 161 | +console.log(''); |
| 162 | +if (problems.length) { |
| 163 | + console.error(`✗ ${problems.length} app-config mismatch(es) vs the model. Fix src/config to match the canonical docs.`); |
| 164 | + process.exit(1); |
| 165 | +} |
| 166 | +console.log('✓ App config mirrors the model: QA/factor order, influence, qaFit, option names, defaults, presets, anti-patterns, and fitness templates all agree with the docs.'); |
0 commit comments