universal-ast-mapper 1.28.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,280 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ // ─── Framework detection ───────────────────────────────────────────────────────
4
+ /** Infer the test framework from the nearest package.json. */
5
+ export function detectTestFramework(root) {
6
+ try {
7
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
8
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
9
+ if (deps["vitest"])
10
+ return "vitest";
11
+ if (deps["jest"] || deps["@jest/core"] || deps["ts-jest"] || deps["babel-jest"])
12
+ return "jest";
13
+ if (deps["mocha"])
14
+ return "mocha";
15
+ }
16
+ catch { /* no package.json */ }
17
+ return "node";
18
+ }
19
+ /** Derive the conventional test file path for a source file. */
20
+ export function resolveTestPath(sourceAbs, lang, outDir) {
21
+ const dir = outDir ?? path.dirname(sourceAbs);
22
+ const base = path.basename(sourceAbs);
23
+ const ext = path.extname(base);
24
+ const stem = base.slice(0, -ext.length);
25
+ if (lang === "python")
26
+ return path.join(dir, `test_${stem}.py`);
27
+ if (lang === "go")
28
+ return path.join(dir, `${stem}_test.go`);
29
+ if (lang === "java")
30
+ return path.join(dir, `${stem}Test.java`);
31
+ if (lang === "ruby")
32
+ return path.join(dir, `${stem}_spec.rb`);
33
+ return path.join(dir, `${stem}.test${ext}`);
34
+ }
35
+ // ─── Signature helpers ─────────────────────────────────────────────────────────
36
+ /** Extract param names from a function signature, returned as comment hints. */
37
+ function paramHints(sym) {
38
+ const s = sym.signature ?? "";
39
+ const m = s.match(/\(([^)]*)\)/);
40
+ if (!m || !m[1].trim())
41
+ return "";
42
+ return m[1]
43
+ .split(",")
44
+ .map((p) => {
45
+ const raw = p.trim()
46
+ .replace(/^\.\.\./, "")
47
+ .replace(/:.*$/, "")
48
+ .replace(/=.*$/, "")
49
+ .replace(/\?$/, "")
50
+ .trim();
51
+ return raw && raw !== "this" ? raw : null;
52
+ })
53
+ .filter(Boolean)
54
+ .map((n) => `/* ${n} */`)
55
+ .join(", ");
56
+ }
57
+ function isAsync(sym) {
58
+ return (sym.signature ?? "").includes("async ");
59
+ }
60
+ // ─── JavaScript / TypeScript ───────────────────────────────────────────────────
61
+ function jsFrameworkHeader(fw) {
62
+ if (fw === "vitest")
63
+ return [`import { describe, it, expect, beforeEach, vi } from 'vitest';`];
64
+ if (fw === "jest")
65
+ return [`import { describe, it, expect, beforeEach, jest } from '@jest/globals';`];
66
+ if (fw === "mocha")
67
+ return [`import { describe, it } from 'mocha';`, `import { expect } from 'chai';`];
68
+ // node:test (default)
69
+ return [`import { describe, it } from 'node:test';`, `import assert from 'node:assert/strict';`];
70
+ }
71
+ function jsAssert(fw, expr, indent) {
72
+ if (fw === "node")
73
+ return `${indent}assert.ok(${expr}); // TODO: assert expected value`;
74
+ return `${indent}expect(${expr}).toBeDefined(); // TODO: assert expected value`;
75
+ }
76
+ function jsSymbolTests(sym, fw, isTs) {
77
+ const lines = [];
78
+ let count = 0;
79
+ if (sym.kind === "function") {
80
+ const hint = paramHints(sym);
81
+ const awaitKw = isAsync(sym) ? "await " : "";
82
+ const itKw = isAsync(sym) ? "it('should ...', async () => {" : "it('should ...', () => {";
83
+ count++;
84
+ lines.push(`describe('${sym.name}', () => {`, ` ${itKw}`, ` // TODO: arrange`, ` const result = ${awaitKw}${sym.name}(${hint});`, jsAssert(fw, "result", " "), ` });`, `});`, "");
85
+ }
86
+ else if (sym.kind === "class") {
87
+ const publicMethods = sym.children.filter((c) => c.kind === "method" && c.visibility === "public" && c.name !== "constructor");
88
+ const typeAnno = isTs ? `: ${sym.name}` : "";
89
+ lines.push(`describe('${sym.name}', () => {`, ` let instance${typeAnno};`, "", ` beforeEach(() => {`, ` instance = new ${sym.name}(/* TODO: constructor args */);`, ` });`, "");
90
+ if (publicMethods.length === 0) {
91
+ count++;
92
+ lines.push(` it('should be instantiable', () => {`, jsAssert(fw, "instance", " "), ` });`);
93
+ }
94
+ else {
95
+ for (const m of publicMethods) {
96
+ const hint = paramHints(m);
97
+ const awaitKw = isAsync(m) ? "await " : "";
98
+ const itKw = isAsync(m) ? `it('${m.name}: should ...', async () => {` : `it('${m.name}: should ...', () => {`;
99
+ count++;
100
+ lines.push(` ${itKw}`, ` // TODO: arrange`, ` const result = ${awaitKw}instance.${m.name}(${hint});`, jsAssert(fw, "result", " "), ` });`, "");
101
+ }
102
+ }
103
+ lines.push(`});`, "");
104
+ }
105
+ else if (sym.kind === "const" || sym.kind === "var") {
106
+ count++;
107
+ lines.push(`describe('${sym.name}', () => {`, ` it('should be defined', () => {`, jsAssert(fw, sym.name, " "), ` });`, `});`, "");
108
+ }
109
+ return { lines, count };
110
+ }
111
+ function generateJsTest(skel, syms, fw, isTs) {
112
+ const lines = [...jsFrameworkHeader(fw), ""];
113
+ const srcBase = path.basename(skel.file).replace(/\.[^.]+$/, "");
114
+ const srcPath = `./${srcBase}${isTs ? "" : ".js"}`;
115
+ const runtimeImports = syms.filter((s) => !["interface", "type"].includes(s.kind)).map((s) => s.name);
116
+ if (runtimeImports.length > 0)
117
+ lines.push(`import { ${runtimeImports.join(", ")} } from '${srcPath}';`);
118
+ if (isTs) {
119
+ const typeImports = syms.filter((s) => s.kind === "interface" || s.kind === "type").map((s) => s.name);
120
+ if (typeImports.length > 0)
121
+ lines.push(`import type { ${typeImports.join(", ")} } from '${srcPath}';`);
122
+ }
123
+ lines.push("");
124
+ let testCount = 0;
125
+ for (const sym of syms) {
126
+ const { lines: symLines, count } = jsSymbolTests(sym, fw, isTs);
127
+ lines.push(...symLines);
128
+ testCount += count;
129
+ }
130
+ return { content: lines.join("\n"), testCount };
131
+ }
132
+ // ─── Python ───────────────────────────────────────────────────────────────────
133
+ function generatePyTest(skel, syms) {
134
+ const lines = ["import pytest", ""];
135
+ const mod = path.basename(skel.file).replace(/\.py$/, "");
136
+ const fns = syms.filter((s) => s.kind === "function" && !s.name.startsWith("_"));
137
+ const classes = syms.filter((s) => s.kind === "class");
138
+ const toImport = [...fns.map((s) => s.name), ...classes.map((s) => s.name)];
139
+ if (toImport.length > 0)
140
+ lines.push(`from .${mod} import ${toImport.join(", ")}`, "");
141
+ let testCount = 0;
142
+ for (const fn of fns) {
143
+ const hint = paramHints(fn).replace(/\/\* (\w+) \*\//g, "$1");
144
+ testCount++;
145
+ lines.push(`def test_${fn.name}():`, ` # TODO: arrange`, ` result = ${fn.name}(${hint})`, ` assert result is not None # TODO: assert expected value`, "");
146
+ }
147
+ for (const cls of classes) {
148
+ const methods = cls.children.filter((c) => c.kind === "method" && c.visibility === "public" && !c.name.startsWith("__"));
149
+ lines.push(`class Test${cls.name}:`, "");
150
+ lines.push(` def setup_method(self):`, ` self.instance = ${cls.name}() # TODO: args`, "");
151
+ if (methods.length === 0) {
152
+ testCount++;
153
+ lines.push(` def test_created(self):`, ` assert self.instance is not None`, "");
154
+ }
155
+ else {
156
+ for (const m of methods) {
157
+ const hint = paramHints(m).replace(/\/\* (\w+) \*\//g, "$1");
158
+ testCount++;
159
+ lines.push(` def test_${m.name}(self):`, ` # TODO: arrange`, ` result = self.instance.${m.name}(${hint})`, ` assert result is not None # TODO: assert expected value`, "");
160
+ }
161
+ }
162
+ }
163
+ return { content: lines.join("\n"), testCount };
164
+ }
165
+ // ─── Go ───────────────────────────────────────────────────────────────────────
166
+ function generateGoTest(skel, syms) {
167
+ const pkgDir = path.dirname(skel.file).split("/").pop() ?? "main";
168
+ const lines = [`package ${pkgDir}`, "", `import (`, `\t"testing"`, `)`, ""];
169
+ let testCount = 0;
170
+ const fns = syms.filter((s) => s.kind === "function" && s.exported);
171
+ const structs = syms.filter((s) => s.kind === "struct" && s.exported);
172
+ for (const fn of fns) {
173
+ const hint = paramHints(fn).replace(/\/\* (\w+) \*\//g, "/* $1 */");
174
+ testCount++;
175
+ lines.push(`func Test${fn.name}(t *testing.T) {`, `\t// TODO: arrange`, `\t_ = ${fn.name}(${hint})`, `\t// if got != want { t.Errorf("expected %v, got %v", want, got) }`, `}`, "");
176
+ }
177
+ for (const s of structs) {
178
+ const methods = s.children.filter((c) => c.kind === "method" && c.visibility === "public");
179
+ for (const m of methods) {
180
+ testCount++;
181
+ lines.push(`func Test${s.name}_${m.name}(t *testing.T) {`, `\t// TODO: arrange`, `\t// instance := ${s.name}{}`, `\t// got := instance.${m.name}(/* args */)`, `\t// if got != want { t.Errorf("expected %v, got %v", want, got) }`, `}`, "");
182
+ }
183
+ }
184
+ return { content: lines.join("\n"), testCount };
185
+ }
186
+ // ─── Java ─────────────────────────────────────────────────────────────────────
187
+ function generateJavaTest(skel, syms) {
188
+ const classes = syms.filter((s) => s.kind === "class" && s.exported);
189
+ const lines = [
190
+ `import org.junit.jupiter.api.Test;`,
191
+ `import org.junit.jupiter.api.BeforeEach;`,
192
+ `import static org.junit.jupiter.api.Assertions.*;`,
193
+ "",
194
+ ];
195
+ let testCount = 0;
196
+ for (const cls of classes) {
197
+ const methods = cls.children.filter((c) => c.kind === "method" && c.visibility === "public");
198
+ lines.push(`class ${cls.name}Test {`, "", ` private ${cls.name} instance;`, "");
199
+ lines.push(` @BeforeEach`, ` void setUp() {`, ` instance = new ${cls.name}(); // TODO: args`, ` }`, "");
200
+ if (methods.length === 0) {
201
+ testCount++;
202
+ lines.push(` @Test`, ` void shouldBeCreated() {`, ` assertNotNull(instance);`, ` }`, "");
203
+ }
204
+ else {
205
+ for (const m of methods) {
206
+ if (m.name === "constructor" || m.name === cls.name)
207
+ continue;
208
+ testCount++;
209
+ const camel = m.name.charAt(0).toUpperCase() + m.name.slice(1);
210
+ lines.push(` @Test`, ` void ${m.name}ShouldWork() {`, ` // TODO: arrange`, ` var result = instance.${m.name}(/* args */);`, ` assertNotNull(result); // TODO: assert expected value`, ` }`, "");
211
+ }
212
+ }
213
+ lines.push(`}`);
214
+ }
215
+ return { content: lines.join("\n"), testCount };
216
+ }
217
+ // ─── Ruby ─────────────────────────────────────────────────────────────────────
218
+ function generateRubyTest(skel, syms) {
219
+ const srcRel = "./" + path.basename(skel.file).replace(/\.rb$/, "");
220
+ const lines = [`require 'rspec'`, `require_relative '${srcRel}'`, ""];
221
+ let testCount = 0;
222
+ const classes = syms.filter((s) => s.kind === "class");
223
+ const fns = syms.filter((s) => s.kind === "function");
224
+ for (const cls of classes) {
225
+ const methods = cls.children.filter((c) => c.kind === "method" && c.visibility === "public" && !c.name.startsWith("initialize"));
226
+ lines.push(`RSpec.describe ${cls.name} do`, ` subject { described_class.new }`, "");
227
+ if (methods.length === 0) {
228
+ testCount++;
229
+ lines.push(` it 'is instantiable' do`, ` expect(subject).not_to be_nil`, ` end`, "");
230
+ }
231
+ else {
232
+ for (const m of methods) {
233
+ testCount++;
234
+ lines.push(` describe '#${m.name}' do`, ` it 'should ...' do`, ` # TODO: arrange`, ` result = subject.${m.name}(/* args */)`, ` expect(result).not_to be_nil`, ` end`, ` end`, "");
235
+ }
236
+ }
237
+ lines.push(`end`, "");
238
+ }
239
+ for (const fn of fns) {
240
+ testCount++;
241
+ lines.push(`RSpec.describe '${fn.name}' do`, ` it 'should ...' do`, ` result = ${fn.name}(/* args */)`, ` expect(result).not_to be_nil`, ` end`, `end`, "");
242
+ }
243
+ return { content: lines.join("\n"), testCount };
244
+ }
245
+ // ─── Entry point ───────────────────────────────────────────────────────────────
246
+ /** Generate a test file for a parsed skeleton. Returns content and metadata. */
247
+ export function generateTestFile(skel, sourceAbs, opts = {}) {
248
+ const fw = opts.framework ?? "node";
249
+ const exportedOnly = opts.exportedOnly ?? true;
250
+ const syms = exportedOnly
251
+ ? skel.symbols.filter((s) => s.exported !== false)
252
+ : skel.symbols;
253
+ const testPath = resolveTestPath(sourceAbs, skel.language, opts.outDir);
254
+ const lang = skel.language;
255
+ let content;
256
+ let testCount;
257
+ if (lang === "typescript" || lang === "tsx") {
258
+ ({ content, testCount } = generateJsTest(skel, syms, fw, true));
259
+ }
260
+ else if (lang === "javascript" || lang === "jsx") {
261
+ ({ content, testCount } = generateJsTest(skel, syms, fw, false));
262
+ }
263
+ else if (lang === "python") {
264
+ ({ content, testCount } = generatePyTest(skel, syms));
265
+ }
266
+ else if (lang === "go") {
267
+ ({ content, testCount } = generateGoTest(skel, syms));
268
+ }
269
+ else if (lang === "java") {
270
+ ({ content, testCount } = generateJavaTest(skel, syms));
271
+ }
272
+ else if (lang === "ruby") {
273
+ ({ content, testCount } = generateRubyTest(skel, syms));
274
+ }
275
+ else {
276
+ content = `// Test file for ${skel.file}\n// Language: ${lang} — add tests manually\n`;
277
+ testCount = 0;
278
+ }
279
+ return { sourceFile: skel.file, testFilePath: testPath, framework: fw, content, testCount };
280
+ }
package/dist/webapp.js ADDED
@@ -0,0 +1,341 @@
1
+ /** Generate the self-contained SPA HTML served by `ast-map serve`. */
2
+ export function webAppHtml(port) {
3
+ return `<!DOCTYPE html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="UTF-8">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <title>AST Map — Live Dashboard</title>
9
+ <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
10
+ <style>
11
+ :root {
12
+ --bg: #0f1117; --surface: #1a1d27; --border: #2d3142;
13
+ --text: #e2e8f0; --muted: #94a3b8; --accent: #7c3aed;
14
+ --green: #22c55e; --yellow: #eab308; --red: #ef4444; --blue: #3b82f6;
15
+ }
16
+ * { box-sizing: border-box; margin: 0; padding: 0; }
17
+ body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; }
18
+ #sidebar { width: 220px; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 16px 0; flex-shrink: 0; }
19
+ .logo { padding: 0 16px 16px; font-size: 14px; font-weight: 700; color: var(--accent); letter-spacing: 1px; border-bottom: 1px solid var(--border); }
20
+ .logo span { color: var(--muted); font-weight: 400; }
21
+ .nav-item { padding: 10px 16px; cursor: pointer; font-size: 13px; color: var(--muted); transition: all .15s; border-left: 3px solid transparent; }
22
+ .nav-item:hover { color: var(--text); background: rgba(124,58,237,.1); }
23
+ .nav-item.active { color: var(--text); border-left-color: var(--accent); background: rgba(124,58,237,.15); }
24
+ .nav-section { padding: 12px 16px 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-top: 8px; }
25
+ #main { flex: 1; overflow-y: auto; padding: 24px; }
26
+ .page { display: none; } .page.active { display: block; }
27
+ h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
28
+ .subtitle { color: var(--muted); font-size: 13px; margin-bottom: 20px; }
29
+ .grid { display: grid; gap: 16px; }
30
+ .grid-4 { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
31
+ .grid-2 { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
32
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
33
+ .stat-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; }
34
+ .stat-value { font-size: 28px; font-weight: 700; line-height: 1; }
35
+ .stat-sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
36
+ .score-ring { width: 80px; height: 80px; margin: 0 auto 8px; }
37
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
38
+ .badge-green { background: rgba(34,197,94,.15); color: var(--green); }
39
+ .badge-yellow { background: rgba(234,179,8,.15); color: var(--yellow); }
40
+ .badge-red { background: rgba(239,68,68,.15); color: var(--red); }
41
+ .badge-blue { background: rgba(59,130,246,.15); color: var(--blue); }
42
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
43
+ th { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; }
44
+ td { padding: 8px 12px; border-bottom: 1px solid rgba(45,49,66,.5); }
45
+ tr:hover td { background: rgba(124,58,237,.05); }
46
+ .search { width: 100%; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; color: var(--text); font-size: 13px; margin-bottom: 16px; outline: none; }
47
+ .search:focus { border-color: var(--accent); }
48
+ #graph-canvas { width: 100%; height: calc(100vh - 140px); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
49
+ .tooltip { position: fixed; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; font-size: 12px; pointer-events: none; z-index: 9999; max-width: 260px; display: none; }
50
+ .sparkline { display: inline-block; width: 80px; height: 24px; vertical-align: middle; }
51
+ .refresh-btn { margin-left: auto; padding: 6px 14px; background: var(--accent); border: none; border-radius: 6px; color: #fff; font-size: 12px; cursor: pointer; }
52
+ .refresh-btn:hover { opacity: .85; }
53
+ .header-row { display: flex; align-items: center; margin-bottom: 16px; }
54
+ .pill { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; background: var(--border); color: var(--muted); margin-left: 6px; }
55
+ .error-box { background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.3); border-radius: 6px; padding: 12px 16px; color: var(--red); font-size: 13px; margin-top: 8px; }
56
+ .loading { color: var(--muted); font-size: 13px; padding: 32px; text-align: center; }
57
+ .timeline { height: 120px; width: 100%; }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <div id="sidebar">
62
+ <div class="logo">AST Map <span>v2</span></div>
63
+ <div class="nav-section">Overview</div>
64
+ <div class="nav-item active" data-page="overview">📊 Dashboard</div>
65
+ <div class="nav-item" data-page="timeline">📈 History</div>
66
+ <div class="nav-section">Analysis</div>
67
+ <div class="nav-item" data-page="files">📁 Files</div>
68
+ <div class="nav-item" data-page="symbols">🔷 Symbols</div>
69
+ <div class="nav-item" data-page="deps">🕸️ Dependency Graph</div>
70
+ <div class="nav-section">Issues</div>
71
+ <div class="nav-item" data-page="smells">🤢 Code Smells</div>
72
+ <div class="nav-item" data-page="security">🔒 Security</div>
73
+ <div class="nav-item" data-page="dead">💀 Dead Code</div>
74
+ </div>
75
+
76
+ <div id="main">
77
+ <!-- OVERVIEW -->
78
+ <div class="page active" id="page-overview">
79
+ <div class="header-row"><h1>Dashboard</h1><button class="refresh-btn" onclick="loadAll()">↺ Refresh</button></div>
80
+ <div class="subtitle" id="root-label">Loading…</div>
81
+ <div class="grid grid-4" id="stat-cards" style="margin-bottom:20px"></div>
82
+ <div class="grid grid-2">
83
+ <div class="card"><div class="stat-label">Top imported symbols</div><div id="top-syms"></div></div>
84
+ <div class="card"><div class="stat-label">Recent issues</div><div id="recent-issues"></div></div>
85
+ </div>
86
+ </div>
87
+
88
+ <!-- HISTORY -->
89
+ <div class="page" id="page-timeline">
90
+ <h1>Health Score History</h1>
91
+ <div class="subtitle">Score trend over time</div>
92
+ <div class="card" style="margin-bottom:16px"><svg class="timeline" id="timeline-svg"></svg></div>
93
+ <div class="card"><table><thead><tr><th>Date</th><th>Score</th><th>Grade</th><th>Files</th><th>Dead</th><th>Cycles</th></tr></thead><tbody id="history-table"></tbody></table></div>
94
+ </div>
95
+
96
+ <!-- FILES -->
97
+ <div class="page" id="page-files">
98
+ <h1>Files</h1>
99
+ <input class="search" id="file-search" placeholder="Filter files…" oninput="filterFiles()">
100
+ <div class="card"><table><thead><tr><th>File</th><th>Lang</th><th>Symbols</th><th>Lines</th></tr></thead><tbody id="file-table"></tbody></table></div>
101
+ </div>
102
+
103
+ <!-- SYMBOLS -->
104
+ <div class="page" id="page-symbols">
105
+ <h1>Symbols</h1>
106
+ <input class="search" id="sym-search" placeholder="Search symbols…" oninput="filterSymbols()">
107
+ <div class="card"><table><thead><tr><th>Symbol</th><th>Kind</th><th>File</th><th>Exported</th></tr></thead><tbody id="sym-table"></tbody></table></div>
108
+ </div>
109
+
110
+ <!-- DEPS GRAPH -->
111
+ <div class="page" id="page-deps">
112
+ <h1>Dependency Graph</h1>
113
+ <div class="subtitle">File-level import relationships — drag to explore</div>
114
+ <svg id="graph-canvas"></svg>
115
+ </div>
116
+
117
+ <!-- SMELLS -->
118
+ <div class="page" id="page-smells">
119
+ <h1>Code Smells</h1>
120
+ <div id="smells-content"></div>
121
+ </div>
122
+
123
+ <!-- SECURITY -->
124
+ <div class="page" id="page-security">
125
+ <h1>Security Issues</h1>
126
+ <div id="security-content"></div>
127
+ </div>
128
+
129
+ <!-- DEAD CODE -->
130
+ <div class="page" id="page-dead">
131
+ <h1>Dead Exports</h1>
132
+ <div class="subtitle">Exported symbols with no known importers inside the scanned directory</div>
133
+ <div class="card"><table><thead><tr><th>Symbol</th><th>Kind</th><th>File</th><th>Confidence</th></tr></thead><tbody id="dead-table"></tbody></table></div>
134
+ </div>
135
+ </div>
136
+
137
+ <div class="tooltip" id="tooltip"></div>
138
+
139
+ <script>
140
+ const API = 'http://localhost:${port}/api';
141
+ let state = { report: null, graph: null, dead: [], history: [], skeletons: [], smells: [], security: [] };
142
+
143
+ // ─── Navigation ───────────────────────────────────────────────────────────────
144
+ document.querySelectorAll('.nav-item').forEach(el => {
145
+ el.addEventListener('click', () => {
146
+ document.querySelectorAll('.nav-item').forEach(e => e.classList.remove('active'));
147
+ document.querySelectorAll('.page').forEach(e => e.classList.remove('active'));
148
+ el.classList.add('active');
149
+ const page = document.getElementById('page-' + el.dataset.page);
150
+ if (page) { page.classList.add('active'); renderPage(el.dataset.page); }
151
+ });
152
+ });
153
+
154
+ // ─── Data loading ─────────────────────────────────────────────────────────────
155
+ async function fetchJson(path) {
156
+ const r = await fetch(API + path);
157
+ if (!r.ok) throw new Error(r.statusText);
158
+ return r.json();
159
+ }
160
+
161
+ async function loadAll() {
162
+ try {
163
+ const [report, graph, dead, history, skeletons, smells, security] = await Promise.all([
164
+ fetchJson('/report'), fetchJson('/graph'), fetchJson('/dead'),
165
+ fetchJson('/history'), fetchJson('/skeletons'), fetchJson('/smells'), fetchJson('/security'),
166
+ ]);
167
+ state = { report, graph, dead, history, skeletons, smells, security };
168
+ renderPage(document.querySelector('.nav-item.active')?.dataset.page ?? 'overview');
169
+ } catch(e) {
170
+ document.getElementById('root-label').textContent = 'Error: ' + e.message;
171
+ }
172
+ }
173
+
174
+ // ─── Renderers ────────────────────────────────────────────────────────────────
175
+ function renderPage(name) {
176
+ if (name === 'overview') renderOverview();
177
+ else if (name === 'timeline') renderTimeline();
178
+ else if (name === 'files') renderFiles();
179
+ else if (name === 'symbols') renderSymbols();
180
+ else if (name === 'deps') renderGraph();
181
+ else if (name === 'smells') renderSmells();
182
+ else if (name === 'security') renderSecurity();
183
+ else if (name === 'dead') renderDead();
184
+ }
185
+
186
+ function grade(s) { return s >= 90 ? 'A' : s >= 80 ? 'B' : s >= 70 ? 'C' : s >= 60 ? 'D' : 'F'; }
187
+ function gradeClass(s) { return s >= 80 ? 'badge-green' : s >= 60 ? 'badge-yellow' : 'badge-red'; }
188
+
189
+ function renderOverview() {
190
+ const r = state.report;
191
+ if (!r) return;
192
+ document.getElementById('root-label').textContent = r.directory ?? '.';
193
+ const score = r.score ?? 0;
194
+ document.getElementById('stat-cards').innerHTML = [
195
+ { label: 'Health Score', value: score, sub: 'Grade ' + (r.grade ?? grade(score)), cls: gradeClass(score) },
196
+ { label: 'Files', value: r.files ?? 0, sub: '' },
197
+ { label: 'Symbols', value: r.symbols ?? 0, sub: '' },
198
+ { label: 'Dead Exports', value: r.deadExports ?? state.dead.length, sub: '', cls: (r.deadExports || state.dead.length) > 0 ? 'badge-yellow' : 'badge-green' },
199
+ { label: 'Circular Deps', value: r.cyclicGroups ?? 0, sub: '', cls: (r.cyclicGroups ?? 0) > 0 ? 'badge-red' : 'badge-green' },
200
+ { label: 'Max Complexity', value: r.maxComplexity ?? 0, sub: '', cls: (r.maxComplexity ?? 0) > 20 ? 'badge-red' : (r.maxComplexity ?? 0) > 10 ? 'badge-yellow' : 'badge-green' },
201
+ { label: 'Smells', value: state.smells.length, sub: '', cls: state.smells.length > 0 ? 'badge-yellow' : 'badge-green' },
202
+ { label: 'Security', value: state.security.length, sub: '', cls: state.security.length > 0 ? 'badge-red' : 'badge-green' },
203
+ ].map(s => \`<div class="card"><div class="stat-label">\${s.label}</div><div class="stat-value"><span class="badge \${s.cls || ''}">\${s.value}</span></div><div class="stat-sub">\${s.sub}</div></div>\`).join('');
204
+
205
+ const topNodes = (state.graph?.nodes ?? []).filter(n => n.nodeType === 'symbol').sort((a, b) => (b.inDegree ?? 0) - (a.inDegree ?? 0)).slice(0, 8);
206
+ document.getElementById('top-syms').innerHTML = topNodes.map(n =>
207
+ \`<div style="display:flex;justify-content:space-between;padding:4px 0;font-size:12px;border-bottom:1px solid var(--border)"><span>\${n.id?.split('::').pop() ?? n.id}</span><span class="badge badge-blue">\${n.inDegree ?? 0}</span></div>\`
208
+ ).join('') || '<div style="color:var(--muted);font-size:12px">No data</div>';
209
+
210
+ const recent = [...state.smells.slice(0, 3).map(s => ({ type: '🤢', msg: (s.symbol ?? s.smell), file: s.file })),
211
+ ...state.security.slice(0, 3).map(s => ({ type: '🔒', msg: s.rule, file: s.file }))];
212
+ document.getElementById('recent-issues').innerHTML = recent.map(i =>
213
+ \`<div style="display:flex;gap:8px;padding:4px 0;font-size:12px;border-bottom:1px solid var(--border)"><span>\${i.type}</span><div><div>\${i.msg}</div><div style="color:var(--muted)">\${i.file}</div></div></div>\`
214
+ ).join('') || '<div style="color:var(--green);font-size:12px">No issues 🎉</div>';
215
+ }
216
+
217
+ function renderTimeline() {
218
+ const hist = state.history;
219
+ const tbody = document.getElementById('history-table');
220
+ tbody.innerHTML = [...hist].reverse().map(h =>
221
+ \`<tr><td>\${h.date}</td><td><span class="badge \${gradeClass(h.score)}">\${h.score}</span></td><td>\${h.grade}</td><td>\${h.files}</td><td>\${h.dead}</td><td>\${h.cycles}</td></tr>\`
222
+ ).join('');
223
+
224
+ if (hist.length < 2) return;
225
+ const svg = document.getElementById('timeline-svg');
226
+ const W = svg.clientWidth || 600, H = 120, M = { t: 10, r: 20, b: 30, l: 40 };
227
+ const iW = W - M.l - M.r, iH = H - M.t - M.b;
228
+ const xs = d3.scalePoint().domain(hist.map(h => h.date)).range([0, iW]);
229
+ const ys = d3.scaleLinear().domain([0, 100]).range([iH, 0]);
230
+ const line = d3.line().x(h => xs(h.date) ?? 0).y(h => ys(h.score));
231
+ svg.innerHTML = \`<g transform="translate(\${M.l},\${M.t})">
232
+ <g transform="translate(0,\${iH})">\${d3.axisBottom(xs).ticks(5)(d3.select(document.createElementNS('http://www.w3.org/2000/svg','g'))?.node?.() ?? document.createElementNS('http://www.w3.org/2000/svg','g'))?.outerHTML ?? ''}</g>
233
+ <path d="\${line(hist)}" fill="none" stroke="#7c3aed" stroke-width="2"/>
234
+ \${hist.map(h => \`<circle cx="\${xs(h.date)}" cy="\${ys(h.score)}" r="4" fill="#7c3aed"/>\`).join('')}
235
+ </g>\`;
236
+ }
237
+
238
+ let allFiles = [];
239
+ function renderFiles() {
240
+ allFiles = state.skeletons;
241
+ filterFiles();
242
+ }
243
+ function filterFiles() {
244
+ const q = document.getElementById('file-search')?.value?.toLowerCase() ?? '';
245
+ const rows = allFiles.filter(s => !q || s.file.toLowerCase().includes(q));
246
+ document.getElementById('file-table').innerHTML = rows.map(s =>
247
+ \`<tr><td style="font-family:monospace">\${s.file}</td><td><span class="pill">\${s.language}</span></td><td>\${s.symbolCount ?? s.symbols?.length ?? 0}</td><td>\${s.lineCount ?? '?'}</td></tr>\`
248
+ ).join('');
249
+ }
250
+
251
+ let allSymbols = [];
252
+ function renderSymbols() {
253
+ allSymbols = state.skeletons.flatMap(s => flattenSyms(s.symbols, s.file));
254
+ filterSymbols();
255
+ }
256
+ function flattenSyms(syms, file, out = []) {
257
+ for (const s of syms) { out.push({ ...s, file }); flattenSyms(s.children ?? [], file, out); }
258
+ return out;
259
+ }
260
+ function filterSymbols() {
261
+ const q = document.getElementById('sym-search')?.value?.toLowerCase() ?? '';
262
+ const rows = allSymbols.filter(s => !q || s.name.toLowerCase().includes(q) || s.kind.includes(q));
263
+ document.getElementById('sym-table').innerHTML = rows.slice(0, 200).map(s =>
264
+ \`<tr><td><b>\${esc(s.name)}</b></td><td><span class="pill">\${s.kind}</span></td><td style="font-family:monospace;font-size:11px">\${esc(s.file)}</td><td>\${s.exported ? '✓' : ''}</td></tr>\`
265
+ ).join('');
266
+ }
267
+
268
+ function renderGraph() {
269
+ const g = state.graph;
270
+ if (!g) return;
271
+ const svg = d3.select('#graph-canvas');
272
+ svg.selectAll('*').remove();
273
+ const W = document.getElementById('graph-canvas').clientWidth || 800;
274
+ const H = document.getElementById('graph-canvas').clientHeight || 500;
275
+ const fileNodes = g.nodes.filter(n => n.nodeType === 'file').slice(0, 60);
276
+ const nodeIds = new Set(fileNodes.map(n => n.id));
277
+ const links = g.edges.filter(e => e.edgeType === 'imports' && nodeIds.has(e.from) && nodeIds.has(e.to))
278
+ .map(e => ({ source: e.from, target: e.to }));
279
+
280
+ const sim = d3.forceSimulation(fileNodes)
281
+ .force('link', d3.forceLink(links).id(d => d.id).distance(80))
282
+ .force('charge', d3.forceManyBody().strength(-120))
283
+ .force('center', d3.forceCenter(W / 2, H / 2));
284
+
285
+ const link = svg.append('g').selectAll('line').data(links).join('line')
286
+ .attr('stroke', '#2d3142').attr('stroke-width', 1);
287
+ const node = svg.append('g').selectAll('circle').data(fileNodes).join('circle')
288
+ .attr('r', 6).attr('fill', '#7c3aed').attr('cursor', 'pointer')
289
+ .call(d3.drag().on('start', e => { if (!e.active) sim.alphaTarget(.3).restart(); e.subject.fx = e.subject.x; e.subject.fy = e.subject.y; })
290
+ .on('drag', e => { e.subject.fx = e.x; e.subject.fy = e.y; })
291
+ .on('end', e => { if (!e.active) sim.alphaTarget(0); e.subject.fx = null; e.subject.fy = null; }))
292
+ .on('mouseover', (ev, d) => { const t = document.getElementById('tooltip'); t.style.display='block'; t.style.left=ev.clientX+12+'px'; t.style.top=ev.clientY+'px'; t.textContent=d.id; })
293
+ .on('mouseout', () => { document.getElementById('tooltip').style.display='none'; });
294
+
295
+ sim.on('tick', () => {
296
+ link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
297
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
298
+ node.attr('cx', d => d.x).attr('cy', d => d.y);
299
+ });
300
+ }
301
+
302
+ function renderSmells() {
303
+ const s = state.smells;
304
+ document.getElementById('smells-content').innerHTML = s.length === 0
305
+ ? '<div class="card" style="color:var(--green)">No smells detected 🎉</div>'
306
+ : \`<div class="card"><table><thead><tr><th>Smell</th><th>Symbol</th><th>File</th><th>Line</th><th>Severity</th></tr></thead><tbody>\${s.map(i =>
307
+ \`<tr><td><span class="badge badge-yellow">\${esc(i.smell)}</span></td><td>\${esc(i.symbol??'')}</td><td style="font-size:11px">\${esc(i.file)}</td><td>\${i.line??''}</td><td>\${i.severity}</td></tr>\`
308
+ ).join('')}</tbody></table></div>\`;
309
+ }
310
+
311
+ function renderSecurity() {
312
+ const s = state.security;
313
+ document.getElementById('security-content').innerHTML = s.length === 0
314
+ ? '<div class="card" style="color:var(--green)">No security issues detected 🎉</div>'
315
+ : \`<div class="card"><table><thead><tr><th>Rule</th><th>Severity</th><th>File</th><th>Line</th><th>Message</th></tr></thead><tbody>\${s.map(i =>
316
+ \`<tr><td><span class="badge \${i.severity==='critical'||i.severity==='high'?'badge-red':'badge-yellow'}">\${esc(i.rule)}</span></td><td>\${esc(i.severity)}</td><td style="font-size:11px">\${esc(i.file)}</td><td>\${i.line}</td><td style="font-size:11px">\${esc(i.message)}</td></tr>\`
317
+ ).join('')}</tbody></table></div>\`;
318
+ }
319
+
320
+ function renderDead() {
321
+ const d = state.dead;
322
+ document.getElementById('dead-table').innerHTML = d.map(i =>
323
+ \`<tr><td><b>\${esc(i.symbol)}</b></td><td><span class="pill">\${esc(i.kind)}</span></td><td style="font-family:monospace;font-size:11px">\${esc(i.file)}</td><td><span class="badge \${i.confidence==='high'?'badge-red':'badge-yellow'}">\${esc(i.confidence)}</span></td></tr>\`
324
+ ).join('');
325
+ }
326
+
327
+ function esc(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
328
+
329
+ // ─── Bootstrap ────────────────────────────────────────────────────────────────
330
+ loadAll();
331
+
332
+ // ─── Live reload via SSE ──────────────────────────────────────────────────────
333
+ (function connectSSE() {
334
+ const es = new EventSource('http://localhost:${port}/events');
335
+ es.addEventListener('change', () => loadAll());
336
+ es.addEventListener('error', () => { es.close(); setTimeout(connectSSE, 3000); });
337
+ })();
338
+ </script>
339
+ </body>
340
+ </html>`;
341
+ }