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/BLUEPRINT.md +230 -230
- package/CHANGELOG.md +475 -338
- package/README.md +1127 -878
- package/dist/ai-refactor.js +185 -0
- package/dist/ai-testgen.js +105 -0
- package/dist/arch-rules.js +82 -0
- package/dist/cli.js +1029 -20
- package/dist/covmerge.js +176 -0
- package/dist/dashboard.js +259 -0
- package/dist/diagram.js +264 -0
- package/dist/docgen.js +156 -0
- package/dist/embeddings.js +136 -0
- package/dist/explain.js +123 -0
- package/dist/fix.js +92 -0
- package/dist/history.js +36 -0
- package/dist/html.js +602 -270
- package/dist/incremental.js +122 -0
- package/dist/index.js +537 -0
- package/dist/indexstore.js +105 -0
- package/dist/lsp.js +238 -0
- package/dist/patch.js +199 -0
- package/dist/plugins.js +88 -0
- package/dist/report.js +285 -76
- package/dist/security.js +178 -0
- package/dist/serve.js +185 -0
- package/dist/similar.js +98 -0
- package/dist/smells.js +285 -0
- package/dist/testgen.js +280 -0
- package/dist/webapp.js +341 -0
- package/package.json +49 -47
- package/scripts/install-skill.mjs +187 -187
package/dist/report.js
CHANGED
|
@@ -116,9 +116,12 @@ export async function buildReport(absDir, root) {
|
|
|
116
116
|
},
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
|
-
/* ───
|
|
119
|
+
/* ─── HTML dashboard ────────────────────────────────────────────────────────── */
|
|
120
120
|
const GRADE_COLOR = {
|
|
121
|
-
A: "#1d9e75", B: "#
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -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
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
149
|
-
|
|
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"
|
|
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
|
|
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
|
|
163
|
-
|
|
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 · ${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
|
-
:
|
|
170
|
-
+
|
|
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"
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
.
|
|
182
|
-
.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
}
|
package/dist/security.js
ADDED
|
@@ -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
|
+
}
|