Skip to content

Commit 612b45a

Browse files
Braedon Saundersclaude
authored andcommitted
Merge pull request #59 from AbdurRafay-Qureshi/main
Add Mermaid block-diagram visualization Adds a "Block Diagram" visualization mode that groups analyzed files into higher-level architecture sections (entry, routes, components, backend, services, shared, config) and renders them with Mermaid. Merge cleanups folded in: - Resolve index.html conflict with the PDF-export work on main: keep getEmbeddedSvgStyle() and the theme-aware exportSVG(), drop the stale duplicate so SVG export still embeds theme CSS. - Restore the tests/fixtures/web-app-world fixture so the two web-app classification tests pass (the fixture had been deleted while the tests referencing it remained). - Drop the generated package.json (npm-init boilerplate pointing at the contributor's fork); this stays a single-file static app. Full suite green: 29/29. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents f2329cb + 21ee31d commit 612b45a

19 files changed

Lines changed: 2100 additions & 18 deletions

File tree

index.html

Lines changed: 1762 additions & 18 deletions
Large diffs are not rendered by default.
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const apiClient = {
2+
fetch(url: string) {
3+
return url;
4+
}
5+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { analyzeYoutube } from '../youtube/analyzer';
2+
3+
export function registerRoutes() {
4+
return analyzeYoutube;
5+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = { port: 3000 };
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = function middleware() {};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = { registerRoutes() {} };
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { apiClient } from '../apiClient';
2+
3+
export function analyzeYoutube(url: string) {
4+
return apiClient.fetch(url);
5+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function AboutPage() {
2+
return <main>About</main>;
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function authMiddleware(req: unknown) {
2+
return req;
3+
}

0 commit comments

Comments
 (0)