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.
package/dist/report.js CHANGED
@@ -116,9 +116,12 @@ export async function buildReport(absDir, root) {
116
116
  },
117
117
  };
118
118
  }
119
- /* ─── Premium HTML dashboard ───────────────────────────────────────────────── */
119
+ /* ─── HTML dashboard ────────────────────────────────────────────────────────── */
120
120
  const GRADE_COLOR = {
121
- A: "#1d9e75", B: "#1d9e75", C: "#ba7517", D: "#d85a30", F: "#e24b4a",
121
+ A: "#1d9e75", B: "#22c55e", C: "#ba7517", D: "#d85a30", F: "#e24b4a",
122
+ };
123
+ const GRADE_BG = {
124
+ A: "#dcfce7", B: "#dcfce7", C: "#fef9c3", D: "#ffedd5", F: "#fee2e2",
122
125
  };
123
126
  function esc(s) {
124
127
  return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -129,104 +132,310 @@ function ratingColor(r) {
129
132
  function instColor(i) {
130
133
  return i >= 0.8 ? "#e24b4a" : i <= 0.2 ? "#1d9e75" : "#ba7517";
131
134
  }
132
- function statCard(label, value, accent) {
133
- return `<div class="stat"><div class="sv"${accent ? ` style="color:${accent}"` : ""}>${value}</div><div class="sl">${label}</div></div>`;
135
+ function scoreRing(score, grade) {
136
+ const gc = GRADE_COLOR[grade] ?? "#888";
137
+ const r = 42, circ = 2 * Math.PI * r;
138
+ const dash = (score / 100) * circ;
139
+ return `<div class="score-ring-wrap">
140
+ <svg width="110" height="110" viewBox="0 0 110 110">
141
+ <circle cx="55" cy="55" r="${r}" fill="none" stroke="currentColor" stroke-width="9" opacity=".1"/>
142
+ <circle cx="55" cy="55" r="${r}" fill="none" stroke="${gc}" stroke-width="9"
143
+ stroke-dasharray="${dash.toFixed(1)} ${circ.toFixed(1)}"
144
+ stroke-linecap="round" transform="rotate(-90 55 55)"
145
+ style="transition:stroke-dasharray 1s ease"/>
146
+ <text x="55" y="50" text-anchor="middle" font-size="28" font-weight="700" fill="${gc}" font-family="system-ui,sans-serif">${grade}</text>
147
+ <text x="55" y="66" text-anchor="middle" font-size="12" fill="currentColor" opacity=".6" font-family="system-ui,sans-serif">${score}/100</text>
148
+ </svg>
149
+ </div>`;
150
+ }
151
+ function statCard(label, value, accent, sub) {
152
+ const accent_attr = accent ? ` style="color:${accent}"` : "";
153
+ const sub_html = sub ? `<div class="sl-sub">${sub}</div>` : "";
154
+ return `<div class="stat"><div class="sv"${accent_attr}>${value}</div><div class="sl">${label}</div>${sub_html}</div>`;
134
155
  }
135
- function bar(label, value, max, color, right) {
156
+ function bar(label, value, max, color, right, title) {
136
157
  const pct = max > 0 ? Math.round((value / max) * 100) : 0;
137
- return `<div class="row"><div class="rl">${esc(label)}</div><div class="track"><div class="fill" style="width:${pct}%;background:${color}"></div></div><div class="rr">${right}</div></div>`;
158
+ const titleAttr = title ? ` title="${esc(title)}"` : "";
159
+ return `<div class="row"${titleAttr}><div class="rl">${esc(label)}</div><div class="track"><div class="fill" style="width:${pct}%;background:${color}"></div></div><div class="rr">${right}</div></div>`;
160
+ }
161
+ function collapsibleCard(id, title, content, icon, open = true) {
162
+ return `<div class="card" id="card-${id}">
163
+ <div class="card-header" onclick="toggleCard('${id}')">
164
+ <span class="card-icon">${icon}</span>
165
+ <h2>${title}</h2>
166
+ <span class="card-arrow" id="arr-${id}">${open ? "▾" : "▸"}</span>
167
+ </div>
168
+ <div class="card-body" id="body-${id}" style="${open ? "" : "display:none"}">
169
+ ${content}
170
+ </div>
171
+ </div>`;
138
172
  }
139
- export function buildReportHtml(d) {
173
+ export function buildReportHtml(d, history = []) {
140
174
  const gc = GRADE_COLOR[d.grade] ?? "#888";
175
+ const prev = history.length >= 2 ? history[history.length - 2] : null;
176
+ const scoreDelta = prev ? d.score - prev.score : null;
177
+ const deltaBadge = scoreDelta === null ? ""
178
+ : scoreDelta > 0 ? `<span class="delta up">↑ +${scoreDelta}</span>`
179
+ : scoreDelta < 0 ? `<span class="delta dn">↓ ${scoreDelta}</span>`
180
+ : `<span class="delta neu">→ 0</span>`;
141
181
  const maxLang = d.languages[0]?.files ?? 1;
142
182
  const langs = d.languages.map((l) => bar(l.lang, l.files, maxLang, "#534ab7", `${l.files}`)).join("");
143
183
  const maxCx = d.complexity.hotspots[0]?.complexity ?? 1;
144
184
  const hotspots = d.complexity.hotspots.length
145
- ? d.complexity.hotspots.map((h) => bar(`${h.name} · ${h.file}`, h.complexity, maxCx, ratingColor(h.rating), `<b>${h.complexity}</b>`)).join("")
185
+ ? d.complexity.hotspots.map((h) => bar(`${h.name} · ${h.file}`, h.complexity, maxCx, ratingColor(h.rating), `<b>${h.complexity}</b>`, `${h.name} in ${h.file} — complexity ${h.complexity}`)).join("")
146
186
  : `<div class="empty">No functions found.</div>`;
147
187
  const god = d.godNodes.length
148
- ? d.godNodes.map((g) => `<div class="li"><span class="mono">${esc(g.symbol)}</span><span class="dim">${esc(g.file)}</span><span class="pill">${g.importCount} importers</span></div>`).join("")
149
- : `<div class="empty">None.</div>`;
188
+ ? d.godNodes.map((g) => `<div class="li">
189
+ <span class="kbadge">god</span>
190
+ <span class="mono">${esc(g.symbol)}</span>
191
+ <span class="dim">${esc(g.file)}</span>
192
+ <span class="pill pill-warn">${g.importCount} importers</span>
193
+ </div>`).join("")
194
+ : `<div class="ok">✓ No dominant god nodes</div>`;
150
195
  const dead = d.dead.count
151
- ? d.dead.items.map((x) => `<div class="li"><span class="kbadge">${esc(x.kind)}</span><span class="mono">${esc(x.symbol)}</span><span class="dim">${esc(x.file)}</span></div>`).join("")
196
+ ? d.dead.items.map((x) => `<div class="li">
197
+ <span class="kbadge">${esc(x.kind)}</span>
198
+ <span class="mono">${esc(x.symbol)}</span>
199
+ <span class="dim">${esc(x.file)}</span>
200
+ </div>`).join("")
152
201
  + (d.dead.count > d.dead.items.length ? `<div class="more">+${d.dead.count - d.dead.items.length} more…</div>` : "")
153
202
  : `<div class="ok">✓ No high-confidence dead exports</div>`;
154
203
  const cycles = d.cycles.count
155
- ? d.cycles.items.map((c) => `<div class="li"><span class="mono">${esc(c.join(" → "))}</span></div>`).join("")
204
+ ? d.cycles.items.map((c) => `<div class="li cycle-li">
205
+ <span class="cycle-arrow">↻</span>
206
+ <span class="mono cycle-chain">${esc(c.join(" → "))}</span>
207
+ </div>`).join("")
156
208
  : `<div class="ok">✓ No circular dependencies</div>`;
157
209
  const modules = d.modules.length
158
210
  ? d.modules.map((m) => bar(`${m.module} · ${m.files} file(s)`, m.instability, 1, instColor(m.instability), `Ca ${m.afferent} · Ce ${m.efferent} · <b>I ${m.instability.toFixed(2)}</b>`)).join("")
159
211
  : `<div class="empty">No cross-module imports.</div>`;
160
212
  const covPct = Math.round(d.testCoverage.coverageRatio * 100);
161
213
  const covC = d.testCoverage.coverageRatio >= 0.7 ? "#1d9e75" : d.testCoverage.coverageRatio >= 0.4 ? "#ba7517" : "#e24b4a";
162
- const covHead = d.testCoverage.testFiles > 0
163
- ? bar(`${d.testCoverage.testedSources}/${d.testCoverage.sourceFiles} sources tested · ${d.testCoverage.testFiles} test file(s)${d.testCoverage.rootFallback ? " (from project root)" : ""}`, covPct, 100, covC, `<b>${covPct}%</b>`)
164
- : "";
214
+ const covRing = d.testCoverage.testFiles > 0 ? (() => {
215
+ const r = 28, circ = 2 * Math.PI * r;
216
+ const dash = (covPct / 100) * circ;
217
+ return `<div class="cov-ring-wrap">
218
+ <svg width="72" height="72" viewBox="0 0 72 72">
219
+ <circle cx="36" cy="36" r="${r}" fill="none" stroke="currentColor" stroke-width="7" opacity=".12"/>
220
+ <circle cx="36" cy="36" r="${r}" fill="none" stroke="${covC}" stroke-width="7"
221
+ stroke-dasharray="${dash.toFixed(1)} ${circ.toFixed(1)}"
222
+ stroke-linecap="round" transform="rotate(-90 36 36)"/>
223
+ <text x="36" y="40" text-anchor="middle" font-size="14" font-weight="700" fill="${covC}" font-family="system-ui,sans-serif">${covPct}%</text>
224
+ </svg>
225
+ </div>`;
226
+ })() : "";
227
+ const covSummary = d.testCoverage.testFiles > 0
228
+ ? `<div class="cov-summary">
229
+ ${covRing}
230
+ <div class="cov-text">
231
+ <div class="cov-pct" style="color:${covC}">${covPct}% covered</div>
232
+ <div class="cov-detail">${d.testCoverage.testedSources}/${d.testCoverage.sourceFiles} source files tested &middot; ${d.testCoverage.testFiles} test file(s)${d.testCoverage.rootFallback ? " (from project root)" : ""}</div>
233
+ </div>
234
+ </div>` : "";
165
235
  const covList = d.testCoverage.testFiles === 0
166
236
  ? `<div class="empty">No test files found in the scanned directory or project root.</div>`
167
237
  : d.testCoverage.untested.length === 0
168
238
  ? `<div class="ok">✓ Every source file has at least one test</div>`
169
- : d.testCoverage.untested.map((u) => `<div class="li"><span class="mono">${esc(u.file)}</span><span class="dim">${u.symbols} symbol(s)</span><span class="pill">Ca ${u.afferent}</span></div>`).join("")
170
- + (d.testCoverage.untestedCount > d.testCoverage.untested.length ? `<div class="more">+${d.testCoverage.untestedCount - d.testCoverage.untested.length} more…</div>` : "");
239
+ : `<div class="untested-header">Untested files (by risk)</div>`
240
+ + d.testCoverage.untested.map((u) => `<div class="li"><span class="mono">${esc(u.file)}</span><span class="dim">${u.symbols} symbol(s)</span><span class="pill">Ca ${u.afferent}</span></div>`).join("")
241
+ + (d.testCoverage.untestedCount > d.testCoverage.untested.length
242
+ ? `<div class="more">+${d.testCoverage.untestedCount - d.testCoverage.untested.length} more untested…</div>` : "");
171
243
  const sdp = d.layerViolations.count
172
- ? d.layerViolations.items.map((v) => `<div class="li"><span class="mono">${esc(v.from)}</span><span class="dim">→ ${esc(v.to)}</span><span class="pill" style="color:${instColor(0.9)}">+${v.severity.toFixed(2)}</span></div>`).join("")
173
- + (d.layerViolations.count > d.layerViolations.items.length ? `<div class="more">+${d.layerViolations.count - d.layerViolations.items.length} more…</div>` : "")
244
+ ? d.layerViolations.items.map((v) => `<div class="li">
245
+ <span class="mono">${esc(v.from)}</span>
246
+ <span class="dim">→ ${esc(v.to)}</span>
247
+ <span class="pill pill-err">+${v.severity.toFixed(2)}</span>
248
+ </div>`).join("")
249
+ + (d.layerViolations.count > d.layerViolations.items.length
250
+ ? `<div class="more">+${d.layerViolations.count - d.layerViolations.items.length} more…</div>` : "")
174
251
  : `<div class="ok">✓ No stability inversions (SDP)</div>`;
175
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
176
- <title>${esc(d.project)} code health</title><style>
177
- :root{--bg:#fafaf8;--card:#fff;--bd:#e7e5df;--tx:#2b2b28;--dim:#8a8880;--soft:#f1efe9}
178
- @media(prefers-color-scheme:dark){:root{--bg:#161613;--card:#1e1e1b;--bd:#33332e;--tx:#e6e4dd;--dim:#9a988f;--soft:#26261f}}
179
- *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--tx);font-family:system-ui,-apple-system,sans-serif;line-height:1.5}
180
- .wrap{max-width:980px;margin:0 auto;padding:32px 24px 60px}
181
- .hero{display:flex;align-items:center;gap:24px;margin-bottom:28px}
182
- .badge{width:104px;height:104px;border-radius:24px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#fff;flex:0 0 auto}
183
- .badge .g{font-size:46px;font-weight:700;line-height:1}.badge .s{font-size:12px;opacity:.9}
184
- .h1{font-size:26px;font-weight:650;margin:0}.sub{color:var(--dim);font-size:13px;margin-top:4px}
185
- .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:12px;margin-bottom:30px}
186
- .stat{background:var(--card);border:1px solid var(--bd);border-radius:14px;padding:14px 16px}
187
- .sv{font-size:24px;font-weight:650}.sl{font-size:12px;color:var(--dim);margin-top:2px}
188
- .card{background:var(--card);border:1px solid var(--bd);border-radius:16px;padding:18px 20px;margin-bottom:18px}
189
- .card h2{font-size:14px;font-weight:600;margin:0 0 14px;letter-spacing:.02em;text-transform:uppercase;color:var(--dim)}
190
- .row{display:flex;align-items:center;gap:12px;margin:7px 0;font-size:13px}
191
- .rl{flex:0 0 46%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
192
- .track{flex:1;height:8px;background:var(--soft);border-radius:5px;overflow:hidden}.fill{height:100%;border-radius:5px}
193
- .rr{flex:0 0 auto;color:var(--dim);min-width:32px;text-align:right}
194
- .li{display:flex;align-items:center;gap:10px;padding:5px 0;font-size:13px;border-top:1px solid var(--bd)}.li:first-child{border-top:none}
195
- .mono{font-family:ui-monospace,monospace;font-weight:550}.dim{color:var(--dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
196
- .pill{margin-left:auto;background:var(--soft);border-radius:20px;padding:2px 10px;font-size:11px;color:var(--dim);flex:0 0 auto}
197
- .kbadge{font-size:11px;color:var(--dim);background:var(--soft);border-radius:5px;padding:1px 7px;flex:0 0 auto}
198
- .ok{color:#1d9e75;font-size:13px}.empty{color:var(--dim);font-size:13px}.more{color:var(--dim);font-size:12px;padding-top:6px}
199
- .two{display:grid;grid-template-columns:1fr 1fr;gap:18px}@media(max-width:720px){.two{grid-template-columns:1fr}.rl{flex-basis:42%}}
200
- .foot{color:var(--dim);font-size:11px;text-align:center;margin-top:24px}
201
- </style></head><body><div class="wrap">
202
- <div class="hero">
203
- <div class="badge" style="background:${gc}"><div class="g">${d.grade}</div><div class="s">${d.score}/100</div></div>
204
- <div><h1 class="h1">${esc(d.project)} — code health</h1>
205
- <div class="sub">${d.fileCount} files · ${d.symbolCount} symbols · ${d.languages.length} language(s) · ${esc(d.generatedAt.slice(0, 10))}</div></div>
206
- </div>
207
- <div class="grid">
208
- ${statCard("Files", d.fileCount)}
209
- ${statCard("Symbols", d.symbolCount)}
210
- ${statCard("Import edges", d.edgeCount)}
211
- ${statCard("Avg complexity", d.complexity.average)}
212
- ${statCard("Max complexity", d.complexity.max, ratingColor(d.complexity.max > 20 ? "very-high" : d.complexity.max > 10 ? "high" : "low"))}
213
- ${statCard("Dead exports", d.dead.count, d.dead.count ? "#d85a30" : "#1d9e75")}
214
- ${statCard("Cycles", d.cycles.count, d.cycles.count ? "#e24b4a" : "#1d9e75")}
215
- ${statCard("SDP violations", d.layerViolations.count, d.layerViolations.count ? "#d85a30" : "#1d9e75")}
216
- ${statCard("Test coverage", covPct + "%", covC)}
217
- </div>
218
- <div class="card"><h2>Language breakdown</h2>${langs}</div>
219
- <div class="card"><h2>Complexity hotspots</h2>${hotspots}</div>
220
- <div class="two">
221
- <div class="card"><h2>God nodes (most imported)</h2>${god}</div>
222
- <div class="card"><h2>Circular dependencies</h2>${cycles}</div>
223
- </div>
224
- <div class="two">
225
- <div class="card"><h2>Module coupling (instability)</h2>${modules}</div>
226
- <div class="card"><h2>Layer violations (stable → volatile)</h2>${sdp}</div>
227
- </div>
228
- <div class="card"><h2>Test coverage (untested by risk)</h2>${covHead}${covList}</div>
229
- <div class="card"><h2>Dead exports (high confidence)</h2>${dead}</div>
230
- <div class="foot">Generated by AST-MCP · universal-ast-mapper</div>
231
- </div></body></html>`;
252
+ const issues = [
253
+ d.cycles.count > 0 ? `<div class="issue-row issue-err">🔴 ${d.cycles.count} circular ${d.cycles.count === 1 ? "dependency" : "dependencies"} detected</div>` : "",
254
+ d.dead.count > 5 ? `<div class="issue-row issue-warn">🟠 ${d.dead.count} dead exports (potential dead code)</div>` : "",
255
+ d.complexity.max > 20 ? `<div class="issue-row issue-warn">🟠 Max complexity ${d.complexity.max} — consider refactoring</div>` : "",
256
+ d.testCoverage.testFiles === 0 ? `<div class="issue-row issue-warn">🟡 No test files found</div>` :
257
+ covPct < 40 ? `<div class="issue-row issue-warn">🟡 Low test coverage (${covPct}%)</div>` : "",
258
+ d.layerViolations.count > 5 ? `<div class="issue-row issue-info">🔵 ${d.layerViolations.count} layer violations (SDP)</div>` : "",
259
+ ].filter(Boolean).join("");
260
+ return `<!doctype html>
261
+ <html lang="en">
262
+ <head>
263
+ <meta charset="utf-8">
264
+ <meta name="viewport" content="width=device-width,initial-scale=1">
265
+ <title>${esc(d.project)} Code Health</title>
266
+ <style>
267
+ :root{
268
+ --bg:#f6f8fa;--card:#fff;--bd:#e2e8f0;--tx:#0f172a;--dim:#64748b;
269
+ --soft:#f1f5f9;--accent:#6366f1;
270
+ --shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
271
+ }
272
+ @media(prefers-color-scheme:dark){
273
+ :root{--bg:#0d1117;--card:#161b22;--bd:#21262d;--tx:#e6edf3;--dim:#7d8590;--soft:#1c2128;}
274
+ }
275
+ *{box-sizing:border-box;margin:0;padding:0;}
276
+ body{background:var(--bg);color:var(--tx);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.5;font-size:13px;}
277
+ .wrap{max-width:1000px;margin:0 auto;padding:28px 20px 60px;}
278
+
279
+ /* ── Topbar ── */
280
+ .topbar{background:var(--card);border-bottom:1px solid var(--bd);padding:0 20px;height:50px;display:flex;align-items:center;gap:10px;position:sticky;top:0;z-index:10;box-shadow:var(--shadow);}
281
+ .topbar-title{font-weight:700;font-size:13px;color:var(--accent);}
282
+ .topbar-sep{width:1px;height:18px;background:var(--bd);}
283
+ .topbar-meta{font-size:12px;color:var(--dim);flex:1;}
284
+ .topbar-grade{font-weight:700;font-size:13px;padding:3px 10px;border-radius:999px;}
285
+ .btn{font:12px system-ui,sans-serif;cursor:pointer;border:1px solid var(--bd);background:transparent;color:inherit;border-radius:7px;padding:4px 11px;transition:background .12s;}
286
+ .btn:hover{background:var(--soft);}
287
+
288
+ /* ── Hero ── */
289
+ .hero{display:flex;align-items:center;gap:20px;margin:24px 0 22px;background:var(--card);border:1px solid var(--bd);border-radius:16px;padding:20px 24px;box-shadow:var(--shadow);}
290
+ .hero-right{flex:1;min-width:0;}
291
+ .score-ring-wrap svg{display:block;}
292
+ .h1{font-size:22px;font-weight:700;margin-bottom:4px;}
293
+ .sub{color:var(--dim);font-size:12px;}
294
+ .issues{margin-top:12px;display:flex;flex-direction:column;gap:4px;}
295
+ .issue-row{font-size:12px;padding:5px 10px;border-radius:8px;}
296
+ .issue-err{background:#fee2e2;color:#991b1b;}
297
+ .issue-warn{background:#fef9c3;color:#854d0e;}
298
+ .issue-info{background:#dbeafe;color:#1e40af;}
299
+ @media(prefers-color-scheme:dark){
300
+ .issue-err{background:#450a0a;color:#fca5a5;}
301
+ .issue-warn{background:#422006;color:#fde68a;}
302
+ .issue-info{background:#0c1e40;color:#93c5fd;}
303
+ }
304
+
305
+ /* ── Stat grid ── */
306
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:10px;margin-bottom:20px;}
307
+ .stat{background:var(--card);border:1px solid var(--bd);border-radius:12px;padding:13px 15px;box-shadow:var(--shadow);transition:transform .12s,box-shadow .12s;}
308
+ .stat:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.1);}
309
+ .sv{font-size:22px;font-weight:700;line-height:1.2;}
310
+ .sl{font-size:11px;color:var(--dim);margin-top:3px;}
311
+ .sl-sub{font-size:10px;color:var(--dim);margin-top:2px;opacity:.7;}
312
+
313
+ /* ── Cards ── */
314
+ .card{background:var(--card);border:1px solid var(--bd);border-radius:14px;margin-bottom:14px;box-shadow:var(--shadow);overflow:hidden;}
315
+ .card-header{display:flex;align-items:center;gap:8px;padding:14px 18px;cursor:pointer;user-select:none;transition:background .1s;}
316
+ .card-header:hover{background:var(--soft);}
317
+ .card-icon{font-size:15px;flex-shrink:0;}
318
+ .card-header h2{font-size:13px;font-weight:600;letter-spacing:.02em;text-transform:uppercase;color:var(--dim);flex:1;margin:0;}
319
+ .card-arrow{color:var(--dim);font-size:12px;transition:transform .15s;}
320
+ .card-body{padding:6px 18px 16px;border-top:1px solid var(--bd);}
321
+
322
+ /* ── Bars ── */
323
+ .row{display:flex;align-items:center;gap:10px;margin:7px 0;font-size:12px;}
324
+ .rl{flex:0 0 42%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--tx);}
325
+ .track{flex:1;height:7px;background:var(--soft);border-radius:4px;overflow:hidden;}
326
+ .fill{height:100%;border-radius:4px;transition:width .5s ease;}
327
+ .rr{flex:0 0 auto;color:var(--dim);min-width:40px;text-align:right;font-size:11px;}
328
+
329
+ /* ── List items ── */
330
+ .li{display:flex;align-items:center;gap:8px;padding:6px 0;font-size:12px;border-top:1px solid var(--bd);}
331
+ .li:first-child{border-top:none;}
332
+ .mono{font-family:ui-monospace,monospace;font-weight:600;font-size:11px;}
333
+ .dim{color:var(--dim);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;}
334
+ .pill{margin-left:auto;background:var(--soft);border-radius:20px;padding:2px 9px;font-size:10px;color:var(--dim);flex-shrink:0;white-space:nowrap;}
335
+ .pill-warn{background:#fef9c3;color:#854d0e;}
336
+ .pill-err{background:#fee2e2;color:#991b1b;}
337
+ @media(prefers-color-scheme:dark){.pill-warn{background:#422006;color:#fde68a;}.pill-err{background:#450a0a;color:#fca5a5;}}
338
+ .kbadge{font-size:10px;color:var(--dim);background:var(--soft);border-radius:5px;padding:1px 6px;flex-shrink:0;}
339
+ .ok{color:#16a34a;font-size:12px;padding:4px 0;}
340
+ .empty{color:var(--dim);font-size:12px;padding:4px 0;}
341
+ .more{color:var(--dim);font-size:11px;padding-top:4px;}
342
+ .tag{font-size:10px;background:var(--soft);border-radius:5px;padding:1px 6px;color:var(--dim);}
343
+
344
+ /* ── Two col ── */
345
+ .two{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
346
+ @media(max-width:740px){.two{grid-template-columns:1fr;}.rl{flex-basis:38%;}}
347
+
348
+ /* ── Coverage ── */
349
+ .cov-summary{display:flex;align-items:center;gap:14px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--bd);}
350
+ .cov-ring-wrap svg{display:block;}
351
+ .cov-pct{font-size:18px;font-weight:700;}
352
+ .cov-detail{font-size:11px;color:var(--dim);margin-top:3px;}
353
+ .untested-header{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);margin-bottom:4px;}
354
+
355
+ /* ── Cycles ── */
356
+ .cycle-li{align-items:flex-start;}
357
+ .cycle-arrow{color:#e24b4a;font-size:16px;flex-shrink:0;line-height:1.4;}
358
+ .cycle-chain{font-size:11px;word-break:break-all;flex:1;}
359
+
360
+ .delta{font-size:13px;font-weight:600;padding:3px 10px;border-radius:999px;margin-left:8px;}
361
+ .delta.up{background:#dcfce7;color:#16a34a;}.delta.dn{background:#fee2e2;color:#dc2626;}.delta.neu{background:var(--soft);color:var(--dim);}
362
+ @media(prefers-color-scheme:dark){.delta.up{background:#14532d;color:#4ade80;}.delta.dn{background:#7f1d1d;color:#f87171;}}
363
+ .sparkline{display:flex;align-items:flex-end;gap:2px;height:28px;margin-top:8px;}
364
+ .spark-bar{width:8px;border-radius:2px 2px 0 0;min-height:2px;transition:opacity .2s;}
365
+ .spark-bar:hover{opacity:.75;}
366
+
367
+ /* ── Footer ── */
368
+ .foot{color:var(--dim);font-size:11px;text-align:center;margin-top:20px;padding-top:14px;border-top:1px solid var(--bd);}
369
+ .foot a{color:var(--dim);}
370
+ </style>
371
+ </head>
372
+ <body>
373
+ <div class="topbar">
374
+ <span class="topbar-title">AST Map</span>
375
+ <div class="topbar-sep"></div>
376
+ <span class="topbar-meta">${esc(d.project)} · Code Health Report · ${esc(d.generatedAt.slice(0, 10))}</span>
377
+ <span class="topbar-grade" style="background:${gc}22;color:${gc};border:1px solid ${gc}44">${d.grade} · ${d.score}/100</span>
378
+ <button class="btn" onclick="window.print()">Print</button>
379
+ </div>
380
+ <div class="wrap">
381
+ <div class="hero">
382
+ ${scoreRing(d.score, d.grade)}
383
+ <div class="hero-right">
384
+ <h1 class="h1">${esc(d.project)}</h1>
385
+ <div class="sub">${d.fileCount} files · ${d.symbolCount} symbols · ${d.languages.length} language(s) · generated ${esc(d.generatedAt.slice(0, 10))}${deltaBadge}</div>
386
+ ${issues ? `<div class="issues">${issues}</div>` : `<div class="issues"><div class="issue-row issue-info">✅ No critical issues detected</div></div>`}
387
+ ${history.length > 1 ? (() => {
388
+ const max = Math.max(...history.map(h => h.score), 1);
389
+ const bars = history.map(h => {
390
+ const pct = Math.round((h.score / max) * 100);
391
+ const gc2 = GRADE_COLOR[h.grade] ?? "#888";
392
+ return `<div class="spark-bar" style="height:${pct}%;background:${gc2}" title="${h.date.slice(0, 10)}: ${h.score}/100 (${h.grade})"></div>`;
393
+ }).join("");
394
+ return `<div class="sparkline" title="Score history (last ${history.length} runs)">${bars}</div>`;
395
+ })() : ""}
396
+ </div>
397
+ </div>
398
+
399
+ <div class="grid">
400
+ ${statCard("Files", d.fileCount)}
401
+ ${statCard("Symbols", d.symbolCount)}
402
+ ${statCard("Import edges", d.edgeCount)}
403
+ ${statCard("Avg complexity", d.complexity.average)}
404
+ ${statCard("Max complexity", d.complexity.max, ratingColor(d.complexity.max > 20 ? "very-high" : d.complexity.max > 10 ? "high" : "low"))}
405
+ ${statCard("Dead exports", d.dead.count, d.dead.count > 5 ? "#d85a30" : d.dead.count > 0 ? "#ba7517" : "#1d9e75")}
406
+ ${statCard("Cycles", d.cycles.count, d.cycles.count ? "#e24b4a" : "#1d9e75")}
407
+ ${statCard("SDP violations", d.layerViolations.count, d.layerViolations.count > 5 ? "#d85a30" : d.layerViolations.count > 0 ? "#ba7517" : "#1d9e75")}
408
+ ${statCard("Test coverage", covPct + "%", covC)}
409
+ </div>
410
+
411
+ ${collapsibleCard("langs", "Language breakdown", langs || `<div class="empty">No data.</div>`, "🌐")}
412
+ ${collapsibleCard("cx", "Complexity hotspots", hotspots, "🔥")}
413
+
414
+ <div class="two">
415
+ ${collapsibleCard("god", "God nodes (most imported)", god, "👑")}
416
+ ${collapsibleCard("cycles", "Circular dependencies", cycles, "🔄")}
417
+ </div>
418
+
419
+ <div class="two">
420
+ ${collapsibleCard("modules", "Module coupling (instability)", modules, "📦")}
421
+ ${collapsibleCard("sdp", "Layer violations (SDP)", sdp, "🏗️")}
422
+ </div>
423
+
424
+ ${collapsibleCard("cov", "Test coverage", covSummary + covList, "🧪")}
425
+ ${collapsibleCard("dead", "Dead exports (high confidence)", dead, "💀", false)}
426
+
427
+ <div class="foot">Generated by <strong>AST-MCP</strong> · universal-ast-mapper · <a href="https://github.com/6ixthxense/ast-mcp">github</a></div>
428
+ </div>
429
+ <script>
430
+ function toggleCard(id){
431
+ const body=document.getElementById('body-'+id);
432
+ const arr=document.getElementById('arr-'+id);
433
+ if(!body||!arr)return;
434
+ const open=body.style.display!=='none';
435
+ body.style.display=open?'none':'';
436
+ arr.textContent=open?'▸':'▾';
437
+ }
438
+ </script>
439
+ </body>
440
+ </html>`;
232
441
  }
@@ -0,0 +1,178 @@
1
+ // ─── Static security scanner ──────────────────────────────────────────────────
2
+ // Line-by-line regex scanning — no AST needed. Finds dangerous patterns
3
+ // in source code across JavaScript, TypeScript, Python, etc.
4
+ // ─── Rule definitions ─────────────────────────────────────────────────────────
5
+ export const SECURITY_RULES = [
6
+ {
7
+ id: "eval",
8
+ severity: "critical",
9
+ message: "Use of eval() allows arbitrary code execution",
10
+ // matches eval( but not eval.toString( or eval.call(
11
+ pattern: /\beval\s*\(/,
12
+ exclude: /\beval\s*\.\s*\w+/,
13
+ },
14
+ {
15
+ id: "inner-html",
16
+ severity: "high",
17
+ message: "Direct assignment to innerHTML can lead to XSS",
18
+ // .innerHTML = but not .innerHTML +=
19
+ pattern: /\.innerHTML\s*=[^=+]/,
20
+ },
21
+ {
22
+ id: "document-write",
23
+ severity: "high",
24
+ message: "document.write() can overwrite the page and lead to XSS",
25
+ pattern: /\bdocument\s*\.\s*write\s*\(/,
26
+ },
27
+ {
28
+ id: "dangerously-set-inner-html",
29
+ severity: "high",
30
+ message: "dangerouslySetInnerHTML bypasses React's XSS protection",
31
+ pattern: /dangerouslySetInnerHTML/,
32
+ },
33
+ {
34
+ id: "child-process",
35
+ severity: "medium",
36
+ message: "Use of child_process module can lead to command injection if inputs are not sanitized",
37
+ pattern: /require\s*\(\s*['"]child_process['"]\s*\)|import\s+.*\bchild_process\b/,
38
+ },
39
+ {
40
+ id: "shell-exec",
41
+ severity: "high",
42
+ message: "exec/execSync with a non-literal argument is vulnerable to command injection",
43
+ // Only flag when the argument looks like a variable/template (contains $ or an identifier before ))
44
+ pattern: /\b(?:exec|execSync)\s*\(\s*(?:[`$]|\w+\s*[+,)])/,
45
+ },
46
+ {
47
+ id: "weak-crypto",
48
+ severity: "medium",
49
+ message: "MD5 and SHA-1 are cryptographically weak and should not be used for security purposes",
50
+ pattern: /createHash\s*\(\s*['"](?:md5|sha1)['"]\s*\)/i,
51
+ },
52
+ {
53
+ id: "hardcoded-secret",
54
+ severity: "high",
55
+ message: "Hardcoded secret/credential detected",
56
+ // variable named password/secret/api_key/apiKey/token/passwd assigned a string literal of 8+ chars
57
+ pattern: /(?:password|secret|api_key|apiKey|token|passwd)\s*[=:]\s*['"][^'"]{8,}['"]/i,
58
+ // filter out common placeholders
59
+ exclude: /(?:your[-_]?key|xxx+|changeme|placeholder|example|test|dummy|sample|fake|mock|<|>|\*)/i,
60
+ },
61
+ {
62
+ id: "sql-injection",
63
+ severity: "high",
64
+ message: "SQL query built with string concatenation may be vulnerable to injection",
65
+ // query( or execute( followed by string concatenation on same line or a nearby + sign
66
+ pattern: /\b(?:query|execute)\s*\(\s*[`"']?[^)]*\+/,
67
+ },
68
+ {
69
+ id: "http-url",
70
+ severity: "low",
71
+ message: "Hardcoded HTTP (non-HTTPS) URL detected",
72
+ pattern: /['"`]http:\/\/(?!(?:localhost|127\.0\.0\.1|0\.0\.0\.0|example\.com|schema\.org))/,
73
+ },
74
+ {
75
+ id: "no-rate-limit",
76
+ severity: "medium",
77
+ message: "Express route handler without apparent rate limiting",
78
+ // Express route .get( or .post( etc.
79
+ pattern: /\.\s*(?:get|post|put|patch|delete|all)\s*\(\s*['"`]/,
80
+ // only applies to JS/TS files
81
+ fileFilter: /\.[jt]sx?$/,
82
+ },
83
+ {
84
+ id: "prototype-pollution",
85
+ severity: "high",
86
+ message: "Potential prototype pollution via __proto__, constructor.prototype, or unsafe Object.assign",
87
+ pattern: /(?:__proto__|constructor\s*\.\s*prototype|Object\.assign\s*\(\s*\{\s*\}[^)]*(?:req|params|body|input|data|user))/,
88
+ },
89
+ ];
90
+ // ─── Comment-line detection ───────────────────────────────────────────────────
91
+ /**
92
+ * Returns true when the (trimmed) line is a comment and should be skipped
93
+ * for most security rules. Covers //, #, * (JSDoc / block comment lines).
94
+ */
95
+ function isCommentLine(trimmed) {
96
+ return (trimmed.startsWith("//") ||
97
+ trimmed.startsWith("#") ||
98
+ trimmed.startsWith("*") ||
99
+ trimmed.startsWith("/*"));
100
+ }
101
+ // Rules that should also scan comment lines (none by default — keep list empty
102
+ // but the structure allows future exceptions).
103
+ const SCAN_COMMENTS_FOR = new Set([]);
104
+ // ─── Core scanner ─────────────────────────────────────────────────────────────
105
+ /**
106
+ * Scan a single file's source text for security issues.
107
+ *
108
+ * @param source - Full file contents.
109
+ * @param relPath - Relative file path (used for fileFilter matching and issue reporting).
110
+ * @param rules - Rule set to apply (defaults to SECURITY_RULES).
111
+ */
112
+ export function scanFileForSecurityIssues(source, relPath, rules = SECURITY_RULES) {
113
+ const issues = [];
114
+ const lines = source.split("\n");
115
+ // Pre-filter rules by fileFilter so we don't re-test every line.
116
+ const applicableRules = rules.filter((r) => r.fileFilter === undefined || r.fileFilter.test(relPath));
117
+ // Build a lookup of line indices that need rate-limit context checks.
118
+ // (We collect matches first, then do the window search in one pass.)
119
+ const rateLimitMatches = [];
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const raw = lines[i];
122
+ const trimmed = raw.trim();
123
+ const lineNo = i + 1; // 1-based
124
+ for (const rule of applicableRules) {
125
+ // Skip comment lines unless the rule explicitly needs them.
126
+ if (isCommentLine(trimmed) && !SCAN_COMMENTS_FOR.has(rule.id))
127
+ continue;
128
+ if (!rule.pattern.test(raw))
129
+ continue;
130
+ if (rule.exclude && rule.exclude.test(raw))
131
+ continue;
132
+ // Special handling for no-rate-limit: defer until we have all line indices.
133
+ if (rule.id === "no-rate-limit") {
134
+ rateLimitMatches.push(i);
135
+ continue;
136
+ }
137
+ issues.push({
138
+ file: relPath,
139
+ rule: rule.id,
140
+ severity: rule.severity,
141
+ message: rule.message,
142
+ line: lineNo,
143
+ snippet: trimmed.slice(0, 120),
144
+ });
145
+ }
146
+ }
147
+ // ── no-rate-limit: window check ──────────────────────────────────────────
148
+ // For each matched route line, look 5 lines before and 5 lines after for
149
+ // rate-limit keywords. Only emit an issue when none are found nearby.
150
+ const rateLimitRule = applicableRules.find((r) => r.id === "no-rate-limit");
151
+ if (rateLimitRule && rateLimitMatches.length > 0) {
152
+ const WINDOW = 5;
153
+ const rateLimitKeyword = /rateLimit|throttle|limiter/i;
154
+ for (const idx of rateLimitMatches) {
155
+ const windowStart = Math.max(0, idx - WINDOW);
156
+ const windowEnd = Math.min(lines.length - 1, idx + WINDOW);
157
+ let hasRateLimit = false;
158
+ for (let w = windowStart; w <= windowEnd; w++) {
159
+ if (rateLimitKeyword.test(lines[w])) {
160
+ hasRateLimit = true;
161
+ break;
162
+ }
163
+ }
164
+ if (!hasRateLimit) {
165
+ const trimmed = lines[idx].trim();
166
+ issues.push({
167
+ file: relPath,
168
+ rule: "no-rate-limit",
169
+ severity: rateLimitRule.severity,
170
+ message: rateLimitRule.message,
171
+ line: idx + 1,
172
+ snippet: trimmed.slice(0, 120),
173
+ });
174
+ }
175
+ }
176
+ }
177
+ return issues;
178
+ }