|
| 1 | +import assert from 'node:assert/strict'; |
| 2 | +import { readdir, readFile } from 'node:fs/promises'; |
| 3 | +import { basename, dirname, join, relative } from 'node:path'; |
| 4 | +import { fileURLToPath } from 'node:url'; |
| 5 | +import test from 'node:test'; |
| 6 | +import vm from 'node:vm'; |
| 7 | + |
| 8 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 9 | +const repoRoot = join(__dirname, '..'); |
| 10 | +const htmlSource = await readFile(join(repoRoot, 'index.html'), 'utf8'); |
| 11 | +const startMarker = '// ===== CODEFLOW_ANALYZER_START ====='; |
| 12 | +const endMarker = '// ===== CODEFLOW_ANALYZER_END ====='; |
| 13 | +const parserStart = htmlSource.indexOf(startMarker); |
| 14 | +const parserEnd = htmlSource.indexOf(endMarker, parserStart); |
| 15 | + |
| 16 | +if (parserStart < 0 || parserEnd < 0) { |
| 17 | + throw new Error('Could not locate analyzer source in index.html'); |
| 18 | +} |
| 19 | + |
| 20 | +const context = { |
| 21 | + console, |
| 22 | + TreeSitter: undefined, |
| 23 | + Babel: undefined, |
| 24 | + acorn: undefined, |
| 25 | + getSecurityScanContent(file) { |
| 26 | + return file && file.content ? file.content : ''; |
| 27 | + }, |
| 28 | + isSanitizedPreviewRenderer() { |
| 29 | + return false; |
| 30 | + }, |
| 31 | +}; |
| 32 | + |
| 33 | +vm.createContext(context); |
| 34 | +vm.runInContext( |
| 35 | + `${htmlSource.slice(parserStart, parserEnd)}\n` + |
| 36 | + 'this.Parser = Parser;' + |
| 37 | + ' this.buildAnalysisData = buildAnalysisData;' + |
| 38 | + ' this.buildArchitectureDiagram = buildArchitectureDiagram;' + |
| 39 | + ' this.generateMermaidBlockDiagram = generateMermaidBlockDiagram;' + |
| 40 | + ' this.getVisibleArchitectureBlocks = getVisibleArchitectureBlocks;' + |
| 41 | + ' this.getArchitectureGroupOrder = getArchitectureGroupOrder;', |
| 42 | + context |
| 43 | +); |
| 44 | + |
| 45 | +const { Parser, buildAnalysisData, buildArchitectureDiagram, generateMermaidBlockDiagram, getVisibleArchitectureBlocks, getArchitectureGroupOrder } = context; |
| 46 | + |
| 47 | +async function collectRepoFiles(root) { |
| 48 | + const files = []; |
| 49 | + const ignored = new Set([ |
| 50 | + '.git', |
| 51 | + 'node_modules', |
| 52 | + 'dist', |
| 53 | + 'build', |
| 54 | + 'coverage', |
| 55 | + '.venv', |
| 56 | + 'venv', |
| 57 | + 'test-results', |
| 58 | + ]); |
| 59 | + |
| 60 | + async function walk(dir) { |
| 61 | + const entries = await readdir(dir, { withFileTypes: true }); |
| 62 | + for (const entry of entries) { |
| 63 | + const fullPath = join(dir, entry.name); |
| 64 | + if (entry.isDirectory()) { |
| 65 | + if (!ignored.has(entry.name)) await walk(fullPath); |
| 66 | + continue; |
| 67 | + } |
| 68 | + if (!entry.isFile() || !Parser.isIncluded(entry.name)) continue; |
| 69 | + const repoPath = relative(root, fullPath).replace(/\\/g, '/'); |
| 70 | + files.push({ |
| 71 | + fullPath, |
| 72 | + path: repoPath, |
| 73 | + name: basename(repoPath), |
| 74 | + folder: repoPath.includes('/') ? repoPath.slice(0, repoPath.lastIndexOf('/')) : 'root', |
| 75 | + isCode: Parser.isCode(entry.name), |
| 76 | + }); |
| 77 | + } |
| 78 | + } |
| 79 | + |
| 80 | + await walk(root); |
| 81 | + return files.sort((a, b) => a.path.localeCompare(b.path)); |
| 82 | +} |
| 83 | + |
| 84 | +async function analyzeCodeflowRepo() { |
| 85 | + const files = await collectRepoFiles(repoRoot); |
| 86 | + const analyzed = []; |
| 87 | + const allFns = []; |
| 88 | + |
| 89 | + for (const file of files) { |
| 90 | + const content = await readFile(file.fullPath, 'utf8'); |
| 91 | + const layer = Parser.detectLayer(file.path); |
| 92 | + const actualIsCode = |
| 93 | + file.isCode !== false && |
| 94 | + (!Parser.isScriptContainer(file.path) || Parser.hasEmbeddedCode(content, file.path)); |
| 95 | + const functions = actualIsCode ? Parser.extract(content, file.path) : []; |
| 96 | + analyzed.push({ |
| 97 | + path: file.path, |
| 98 | + name: file.name, |
| 99 | + folder: file.folder, |
| 100 | + content, |
| 101 | + functions, |
| 102 | + lines: content ? content.split('\n').length : 0, |
| 103 | + layer, |
| 104 | + churn: 0, |
| 105 | + isCode: actualIsCode, |
| 106 | + }); |
| 107 | + if (actualIsCode) { |
| 108 | + functions.forEach((fn) => allFns.push(Object.assign({}, fn, { folder: file.folder, layer }))); |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + return buildAnalysisData({ |
| 113 | + analyzed, |
| 114 | + allFns, |
| 115 | + excludePatterns: [], |
| 116 | + progress() {}, |
| 117 | + yieldFn: async () => {}, |
| 118 | + }); |
| 119 | +} |
| 120 | + |
| 121 | +function blockPaths(diagram, includeTests, includeBuildOutput) { |
| 122 | + return getVisibleArchitectureBlocks(diagram.blocks || [], includeTests, includeBuildOutput).flatMap((block) => block.files || []); |
| 123 | +} |
| 124 | + |
| 125 | +function blockHasFile(block, suffix) { |
| 126 | + return (block.files || []).some((file) => file === suffix || file.endsWith('/' + suffix) || file.endsWith(suffix)); |
| 127 | +} |
| 128 | + |
| 129 | +function hasDependency(diagram, fromSuffix, toSuffix, label) { |
| 130 | + const fromBlock = diagram.blocks.find((block) => blockHasFile(block, fromSuffix)); |
| 131 | + const toBlock = diagram.blocks.find((block) => blockHasFile(block, toSuffix)); |
| 132 | + assert.ok(fromBlock, `missing block ${fromSuffix}`); |
| 133 | + assert.ok(toBlock, `missing block ${toSuffix}`); |
| 134 | + return diagram.dependencies.some( |
| 135 | + (dep) => |
| 136 | + dep.from === fromBlock.id && |
| 137 | + dep.to === toBlock.id && |
| 138 | + (!label || dep.label === label) |
| 139 | + ); |
| 140 | +} |
| 141 | + |
| 142 | +test('codeflow architecture diagram hides tests by default', async () => { |
| 143 | + const data = await analyzeCodeflowRepo(); |
| 144 | + const diagram = data.architectureDiagram; |
| 145 | + |
| 146 | + assert.ok(diagram); |
| 147 | + assert.equal(diagram.framework, 'Browser App'); |
| 148 | + |
| 149 | + const visiblePaths = blockPaths(diagram, false, false); |
| 150 | + assert.ok(visiblePaths.some((path) => /index\.html$/i.test(path))); |
| 151 | + assert.ok(visiblePaths.some((path) => path === 'card/index.js')); |
| 152 | + assert.ok(visiblePaths.some((path) => path === 'card/lib/analyzer.js')); |
| 153 | + assert.ok(visiblePaths.some((path) => path === 'card/lib/collect.js')); |
| 154 | + assert.equal( |
| 155 | + visiblePaths.some((path) => /tests\//i.test(path) || /\.test\.mjs$/i.test(path)), |
| 156 | + false |
| 157 | + ); |
| 158 | + assert.equal( |
| 159 | + visiblePaths.some((path) => /fixtures\//i.test(path)), |
| 160 | + false |
| 161 | + ); |
| 162 | + |
| 163 | + const mermaid = generateMermaidBlockDiagram(diagram, false, false); |
| 164 | + assert.match(mermaid, /Browser App Shell/); |
| 165 | + assert.doesNotMatch(mermaid, /uses \d+ calls/i); |
| 166 | +}); |
| 167 | + |
| 168 | +test('codeflow architecture diagram uses semantic module dependencies', async () => { |
| 169 | + const data = await analyzeCodeflowRepo(); |
| 170 | + const diagram = data.architectureDiagram; |
| 171 | + |
| 172 | + assert.ok(hasDependency(diagram, 'card/index.js', 'card/lib/collect.js')); |
| 173 | + assert.ok(hasDependency(diagram, 'card/index.js', 'card/lib/git.js')); |
| 174 | + assert.ok(hasDependency(diagram, 'card/lib/collect.js', 'card/lib/git.js', 'uses GitHub API')); |
| 175 | + assert.ok(hasDependency(diagram, 'card/lib/analyzer.js', 'card/lib/state.js', 'stores derived state')); |
| 176 | + assert.ok(hasDependency(diagram, 'card/lib/pr.js', 'card/lib/git.js', 'analyzes pull requests')); |
| 177 | + assert.ok(hasDependency(diagram, 'index.html', 'card/lib/analyzer.js', 'runs analysis')); |
| 178 | + |
| 179 | + const labels = diagram.dependencies.map((dep) => dep.label); |
| 180 | + assert.equal(labels.some((label) => /^uses \d+ calls?$/i.test(label)), false); |
| 181 | +}); |
| 182 | + |
| 183 | +test('codeflow architecture diagram can include tests', async () => { |
| 184 | + const data = await analyzeCodeflowRepo(); |
| 185 | + const diagram = data.architectureDiagram; |
| 186 | + const withTests = blockPaths(diagram, true); |
| 187 | + |
| 188 | + assert.ok(withTests.some((path) => path === 'tests/codeflow-golden.test.mjs')); |
| 189 | + const mermaid = generateMermaidBlockDiagram(diagram, true, false); |
| 190 | + assert.match(mermaid, /Testing/); |
| 191 | +}); |
| 192 | + |
| 193 | +async function analyzeFixture(name) { |
| 194 | + const root = join(__dirname, 'fixtures', name); |
| 195 | + const files = await collectRepoFiles(root); |
| 196 | + const analyzed = []; |
| 197 | + const allFns = []; |
| 198 | + |
| 199 | + for (const file of files) { |
| 200 | + const content = await readFile(file.fullPath, 'utf8'); |
| 201 | + const layer = Parser.detectLayer(file.path); |
| 202 | + const actualIsCode = |
| 203 | + file.isCode !== false && |
| 204 | + (!Parser.isScriptContainer(file.path) || Parser.hasEmbeddedCode(content, file.path)); |
| 205 | + const functions = actualIsCode ? Parser.extract(content, file.path) : []; |
| 206 | + analyzed.push({ |
| 207 | + path: file.path, |
| 208 | + name: file.name, |
| 209 | + folder: file.folder, |
| 210 | + content, |
| 211 | + functions, |
| 212 | + lines: content ? content.split('\n').length : 0, |
| 213 | + layer, |
| 214 | + churn: 0, |
| 215 | + isCode: actualIsCode, |
| 216 | + }); |
| 217 | + if (actualIsCode) { |
| 218 | + functions.forEach((fn) => allFns.push(Object.assign({}, fn, { folder: file.folder, layer }))); |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + return buildAnalysisData({ |
| 223 | + analyzed, |
| 224 | + allFns, |
| 225 | + excludePatterns: [], |
| 226 | + progress() {}, |
| 227 | + yieldFn: async () => {}, |
| 228 | + }); |
| 229 | +} |
| 230 | + |
| 231 | +test('web-app fixture uses semantic groups and hides build output', async () => { |
| 232 | + const data = await analyzeFixture('web-app-world'); |
| 233 | + const diagram = data.architectureDiagram; |
| 234 | + |
| 235 | + assert.ok(diagram); |
| 236 | + assert.equal(diagram.profile, 'web-app'); |
| 237 | + |
| 238 | + const visiblePaths = blockPaths(diagram, false, false); |
| 239 | + assert.equal( |
| 240 | + visiblePaths.some((path) => /(^|\/)out\//i.test(path) || /page-deadbeef/i.test(path)), |
| 241 | + false |
| 242 | + ); |
| 243 | + assert.equal(getVisibleArchitectureBlocks(diagram.blocks, false, false).some((block) => block.isTest), false); |
| 244 | + |
| 245 | + const groups = new Set(getVisibleArchitectureBlocks(diagram.blocks, false, false).map((b) => b.group)); |
| 246 | + assert.ok(groups.has('App Entry / Shell') || groups.has('Frontend Routes / Views')); |
| 247 | + assert.ok( |
| 248 | + groups.has('Backend / API Layer') || |
| 249 | + groups.has('Services / Business Logic') || |
| 250 | + groups.has('Configuration') || |
| 251 | + groups.has('Shared / Utilities') |
| 252 | + ); |
| 253 | + |
| 254 | + assert.ok(diagram.hiddenSummary); |
| 255 | + assert.ok(diagram.hiddenSummary.build >= 1 || diagram.hiddenSummary.tests >= 1); |
| 256 | + |
| 257 | + const mermaid = generateMermaidBlockDiagram(diagram, false, false); |
| 258 | + assert.doesNotMatch(mermaid, /uses \d+ calls/i); |
| 259 | + const order = getArchitectureGroupOrder('web-app'); |
| 260 | + assert.ok(order.includes('App Entry / Shell')); |
| 261 | + assert.ok(order.includes('Frontend Routes / Views')); |
| 262 | + |
| 263 | + const forbiddenRoutes = [ |
| 264 | + 'Route /a-backend/src/config', |
| 265 | + 'Route /hooks', |
| 266 | + 'Route /ui/components', |
| 267 | + 'Route /platforms/youtube/schema', |
| 268 | + 'Route /a-backend/src/routes', |
| 269 | + ]; |
| 270 | + for (const label of forbiddenRoutes) { |
| 271 | + assert.doesNotMatch(mermaid, new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); |
| 272 | + } |
| 273 | + assert.doesNotMatch(mermaid, /LandingPage.*global-error/i); |
| 274 | + assert.match(mermaid, /Global Error Boundary|global-error/i); |
| 275 | +}); |
| 276 | + |
| 277 | +test('web-app fixture classifies backend barrels and shared indexes without routes', async () => { |
| 278 | + const data = await analyzeFixture('web-app-world'); |
| 279 | + const diagram = data.architectureDiagram; |
| 280 | + const routeBlocks = getVisibleArchitectureBlocks(diagram.blocks, false, false).filter( |
| 281 | + (block) => block.role === 'frontend-route' || (block.route && block.kind === 'page') |
| 282 | + ); |
| 283 | + |
| 284 | + for (const block of routeBlocks) { |
| 285 | + const files = (block.files || []).join(' '); |
| 286 | + assert.equal(/a-backend\/src\/(config|middleware|routes)\/index\.js/i.test(files), false); |
| 287 | + assert.equal(/src\/hooks\/index\.ts/i.test(files), false); |
| 288 | + assert.equal(/src\/ui\/components\/index\.ts/i.test(files), false); |
| 289 | + assert.equal(/src\/platforms\/youtube\/schema\/index\.ts/i.test(files), false); |
| 290 | + } |
| 291 | +}); |
0 commit comments