mirror of
https://github.com/Asixa/codemap-skill.git
synced 2026-08-28 22:01:26 +00:00
Harden per review: HTML-escape all model-derived strings (no innerHTML XSS), strict apply_audit validation (score-grade match, tag whitelist, clean/findings rules), add tests/ + CI, tone down README + honest 'what it is/isn't', SKILL.md capability/fallback mapping
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest]
|
||||||
|
python: ["3.9", "3.12"]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
|
||||||
|
- name: Compile scripts (syntax)
|
||||||
|
run: python -m py_compile scripts/scan.py scripts/query.py scripts/apply_audit.py scripts/render.py
|
||||||
|
|
||||||
|
- name: Unit + golden tests
|
||||||
|
run: python -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
- name: Render the sample project (smoke)
|
||||||
|
run: >
|
||||||
|
python scripts/render.py
|
||||||
|
--state examples/sample-project/modules.json
|
||||||
|
--template assets/template.html
|
||||||
|
--out-html /tmp/codemap.html --out-md /tmp/codemap.md
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
template-js:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
- name: Syntax-check the template's inline JS
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import re
|
||||||
|
html = open("assets/template.html", encoding="utf-8").read()
|
||||||
|
m = re.search(r"<script>(.*)</script>", html, re.S)
|
||||||
|
open("/tmp/_t.js", "w", encoding="utf-8").write(m.group(1).replace("__ARCH_DATA__", "{}"))
|
||||||
|
PY
|
||||||
|
node --check /tmp/_t.js
|
||||||
@@ -9,11 +9,13 @@ helps you **pay down the cruft** — incrementally, one commit at a time.
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
[](https://github.com/Asixa/codemap-skill/actions/workflows/test.yml)
|
||||||
|
|
||||||
> Every codebase accumulates cruft over time — monkeypatches, silent fallbacks, dead
|
> Every codebase accumulates cruft over time — monkeypatches, silent fallbacks, dead
|
||||||
> "legacy" paths, half-finished stubs, copy-pasted duplication, god-files, and valueless
|
> "legacy" paths, half-finished stubs, copy-pasted duplication, god-files, and valueless
|
||||||
> glue. **codemap surfaces that rot, ranks it, and hands an AI agent a clear punch-list to
|
> glue. **codemap surfaces that rot, ranks it, and hands an AI agent a clear punch-list to
|
||||||
> fix it** — with a regression-gated fix loop so the cleanup never breaks your build.
|
> fix it** — with a regression-gated fix loop: a change is accepted only when an
|
||||||
|
> independent check shows your tests still pass.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -63,12 +65,24 @@ Most "architecture diagram" tools draw *files and imports*. codemap is different
|
|||||||
- **Incremental + git-aware.** A per-module content hash + the last-run commit mean re-runs
|
- **Incremental + git-aware.** A per-module content hash + the last-run commit mean re-runs
|
||||||
only re-audit what changed, and `update` shows you the **commits since last time** and
|
only re-audit what changed, and `update` shows you the **commits since last time** and
|
||||||
which modules they touched.
|
which modules they touched.
|
||||||
- **Cleanup that can't regress.** `fix` runs a four-role loop — lock a test baseline →
|
- **Regression-gated cleanup.** `fix` runs a four-role loop — lock a test baseline →
|
||||||
fix → an **independent acceptance check** proves the pre-fix tests still pass → re-score.
|
fix → an **independent acceptance check** must show the pre-fix tests still pass → re-score.
|
||||||
|
|
||||||
It's the maintenance pass you never have time to do, turned into something an agent can
|
It's the maintenance pass you never have time to do, turned into something an agent can
|
||||||
run on a schedule.
|
run on a schedule.
|
||||||
|
|
||||||
|
> **What it is (and isn't).** codemap is an agent-orchestration framework that makes the
|
||||||
|
> map + audit *consistent and reviewable* — deterministic scripts handle LoC, hashing,
|
||||||
|
> staleness, filtering and rendering, and a fixed rubric forces `file:line` evidence and
|
||||||
|
> an independent audit per module. But the **module decomposition and the scores are model
|
||||||
|
> judgments**, not the output of a deterministic static analyzer. Treat the map as a
|
||||||
|
> high-quality, reviewable starting point — and commit `modules.json` so every score is
|
||||||
|
> diffable in PRs.
|
||||||
|
|
||||||
|
Want to see it before installing? Open
|
||||||
|
**[`examples/sample-project/codemap.html`](examples/sample-project/codemap.html)** — a
|
||||||
|
fully rendered demo (the sample used for the screenshots).
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
**Click any module** to highlight what it calls (downstream) and what depends on it
|
**Click any module** to highlight what it calls (downstream) and what depends on it
|
||||||
@@ -155,11 +169,15 @@ codemap/
|
|||||||
scripts/ # deterministic, stdlib-only Python
|
scripts/ # deterministic, stdlib-only Python
|
||||||
scan.py # LoC + content hash + git diff + staleness
|
scan.py # LoC + content hash + git diff + staleness
|
||||||
query.py # filter modules (grade/tag/severity/…) → ids/paths/findings
|
query.py # filter modules (grade/tag/severity/…) → ids/paths/findings
|
||||||
apply_audit.py # merge one subagent's audit into the state
|
apply_audit.py # validate + merge one subagent's audit into the state
|
||||||
render.py # modules.json → HTML + report
|
render.py # modules.json → HTML + report
|
||||||
assets/
|
assets/
|
||||||
template.html # the interactive map shell (data injected at render time)
|
template.html # the interactive map shell (data injected at render time)
|
||||||
examples/ # the screenshots above
|
tests/ # stdlib unittest golden tests for the scripts
|
||||||
|
examples/
|
||||||
|
01-map.png … # the screenshots above
|
||||||
|
sample-project/ # a fully rendered demo (modules.json + codemap.html/md)
|
||||||
|
.github/workflows/test.yml # CI: py_compile + unittest + render + JS syntax check
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -91,10 +91,10 @@ per-module subagent loop — never load the full `modules.json` just to pick tar
|
|||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
1. **Every module score comes from an independent subagent.** One subagent audits one
|
1. **Every module score comes from an independent sub-task.** One sub-task audits one
|
||||||
module against its `paths`, using the prompt in `reference/STANDARDS.md`. Never score
|
module against its `paths`, using the prompt in `reference/STANDARDS.md`. Never score
|
||||||
inline in the main thread; never copy one module's score to another. Spawn them in
|
inline in the main thread; never copy one module's score to another. Run them in
|
||||||
parallel (one message, multiple Agent calls — Explore or general-purpose).
|
parallel where the platform supports it (see *Capabilities & platform mapping*).
|
||||||
2. **Scripts are deterministic; only decomposition, auditing, and theme-synthesis are
|
2. **Scripts are deterministic; only decomposition, auditing, and theme-synthesis are
|
||||||
model work.** `scan.py` / `render.py` / `apply_audit.py` never make quality judgments.
|
model work.** `scan.py` / `render.py` / `apply_audit.py` never make quality judgments.
|
||||||
3. **`modules.json` is the only thing you edit by hand** (structure/decomposition).
|
3. **`modules.json` is the only thing you edit by hand** (structure/decomposition).
|
||||||
@@ -111,14 +111,29 @@ per-module subagent loop — never load the full `modules.json` just to pick tar
|
|||||||
build/typecheck is clean. A fixer may not write/edit its own tests or grade its own
|
build/typecheck is clean. A fixer may not write/edit its own tests or grade its own
|
||||||
work — that defeats the gate.
|
work — that defeats the gate.
|
||||||
|
|
||||||
|
## Capabilities & platform mapping
|
||||||
|
|
||||||
|
This workflow needs three capabilities. Each has a graceful fallback, so it runs on any
|
||||||
|
agent — only the convenience changes, never the rules above.
|
||||||
|
|
||||||
|
| Capability | Native (Claude Code) | Codex / Cursor | Fallback if unavailable |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Independent sub-tasks** (one auditor/fixer per module) | `Agent` tool, many in parallel | their subagent/task tool | Audit modules **one at a time in the main thread** — still one module per pass against the rubric, never batch-scoring. Slower, fully valid. |
|
||||||
|
| **Structured result** (the audit JSON) | `schema` on the Agent call | tool-specific schema, or just ask for JSON | Ask the sub-task to return **only** the JSON object; `apply_audit.py` validates it and rejects malformed/inconsistent results — no schema feature required. |
|
||||||
|
| **Ask the user** (preferences on `init`) | `AskUserQuestion` | tool's prompt UI | Ask in plain text, or apply defaults (`lang=en`, output `.codemap/`, title = repo folder name) and tell the user how to change them in `.codemap/config.json`. |
|
||||||
|
|
||||||
|
The non-negotiables (independent per-module audit, deterministic scripts, the four-role
|
||||||
|
fix gate) hold on every platform; the table only changes *how* you spawn the work.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Command: `init` (first build)
|
## Command: `init` (first build)
|
||||||
|
|
||||||
Use when no `modules.json` exists yet. (Also accepts `generate` as an alias.)
|
Use when no `modules.json` exists yet. (Also accepts `generate` as an alias.)
|
||||||
|
|
||||||
0. **Ask the user for preferences first** (use the AskUserQuestion tool), then save them to
|
0. **Ask the user for preferences first** (use `AskUserQuestion` if available, else just ask
|
||||||
`<project>/.codemap/config.json`:
|
in plain text; or apply the defaults from *Capabilities & platform mapping*), then save
|
||||||
|
them to `<project>/.codemap/config.json`:
|
||||||
- **UI language** — `en` or `zh` (localizes the map chrome + report; module names are
|
- **UI language** — `en` or `zh` (localizes the map chrome + report; module names are
|
||||||
never translated). → `meta.lang`.
|
never translated). → `meta.lang`.
|
||||||
- **Output location** — where the HTML/MD go. Default `.codemap/` (kept with the tool
|
- **Output location** — where the HTML/MD go. Default `.codemap/` (kept with the tool
|
||||||
|
|||||||
+33
-27
@@ -201,6 +201,11 @@ const BANDS = DATA.bands || [];
|
|||||||
const SPINE = DATA.spine || [];
|
const SPINE = DATA.spine || [];
|
||||||
const REPORT_THEMES = DATA.reportThemes || [];
|
const REPORT_THEMES = DATA.reportThemes || [];
|
||||||
const META = DATA.meta || {};
|
const META = DATA.meta || {};
|
||||||
|
/* HTML-escape every string that comes from modules.json (labels, descriptions,
|
||||||
|
findings, tags, paths, meta, themes, standard) before it enters innerHTML. The only
|
||||||
|
trusted HTML is the template's own i18n strings (tl) and the structural markup. */
|
||||||
|
function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g, c=>(
|
||||||
|
{"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
||||||
let BAD_TAGS = new Set(["monkeypatch","fallback","legacy","dual-format","stub","fake-output","bloat","duplication","glue","silent-except","silent-catch","any-escape","over-fit","god-component","placeholder"]);
|
let BAD_TAGS = new Set(["monkeypatch","fallback","legacy","dual-format","stub","fake-output","bloat","duplication","glue","silent-except","silent-catch","any-escape","over-fit","god-component","placeholder"]);
|
||||||
|
|
||||||
/* ---------- i18n: display language via meta.lang (module names never translated) ---------- */
|
/* ---------- i18n: display language via meta.lang (module names never translated) ---------- */
|
||||||
@@ -325,9 +330,9 @@ const svg=document.getElementById("edges");
|
|||||||
const detail=document.getElementById("detail");
|
const detail=document.getElementById("detail");
|
||||||
const cardEl={};
|
const cardEl={};
|
||||||
|
|
||||||
document.getElementById("projTitle").innerHTML = (META.project||"Project")+` <span>${tl('mapSuffix')}</span>`;
|
document.getElementById("projTitle").innerHTML = esc(META.project||"Project")+` <span>${tl('mapSuffix')}</span>`;
|
||||||
document.getElementById("subLine").innerHTML = (META.subtitle||tl('sub'))+
|
document.getElementById("subLine").innerHTML = esc(META.subtitle||tl('sub'))+
|
||||||
(META.mdPath?` · <a href="${META.mdPath.split('/').pop()}" style="color:var(--accent);text-decoration:none">${tl('report')} ↗</a>`:"");
|
(META.mdPath?` · <a href="${esc(META.mdPath.split('/').pop())}" style="color:var(--accent);text-decoration:none">${tl('report')} ↗</a>`:"");
|
||||||
/* localize static header chrome (module names/labels are never translated) */
|
/* localize static header chrome (module names/labels are never translated) */
|
||||||
document.querySelector(".scrollnote").textContent = tl("scrollNote");
|
document.querySelector(".scrollnote").textContent = tl("scrollNote");
|
||||||
document.getElementById("search").placeholder = tl("searchPh");
|
document.getElementById("search").placeholder = tl("searchPh");
|
||||||
@@ -340,14 +345,14 @@ document.getElementById("tagFilter").options[0].textContent = tl("anyIssue");
|
|||||||
BANDS.forEach(b=>{
|
BANDS.forEach(b=>{
|
||||||
if(b.wire){
|
if(b.wire){
|
||||||
const w=document.createElement("div"); w.className="wire";
|
const w=document.createElement("div"); w.className="wire";
|
||||||
w.innerHTML=`<div class="line"></div><div class="lbl">${b.t||"boundary"}</div><div class="line"></div>`;
|
w.innerHTML=`<div class="line"></div><div class="lbl">${esc(b.t||"boundary")}</div><div class="line"></div>`;
|
||||||
board.appendChild(w); return;
|
board.appendChild(w); return;
|
||||||
}
|
}
|
||||||
const band=document.createElement("div"); band.className=`band tier-${b.tier||""}`;
|
const band=document.createElement("div"); band.className=`band tier-${b.tier||""}`;
|
||||||
const items=M.filter(m=>m.band===b.id);
|
const items=M.filter(m=>m.band===b.id);
|
||||||
const gloc=items.reduce((a,m)=>a+(m.loc||0),0);
|
const gloc=items.reduce((a,m)=>a+(m.loc||0),0);
|
||||||
const head=document.createElement("div"); head.className="band-head";
|
const head=document.createElement("div"); head.className="band-head";
|
||||||
head.innerHTML=`<span class="t">${b.t||b.id}</span><span class="d">${b.d||""}</span>`+
|
head.innerHTML=`<span class="t">${esc(b.t||b.id)}</span><span class="d">${esc(b.d||"")}</span>`+
|
||||||
`<span class="num">${items.length}</span><span class="num gloc">${gloc.toLocaleString()} ${tl('locUnit')}</span>`;
|
`<span class="num">${items.length}</span><span class="num gloc">${gloc.toLocaleString()} ${tl('locUnit')}</span>`;
|
||||||
band.appendChild(head);
|
band.appendChild(head);
|
||||||
const body=document.createElement("div"); body.className="band-body";
|
const body=document.createElement("div"); body.className="band-body";
|
||||||
@@ -357,7 +362,7 @@ BANDS.forEach(b=>{
|
|||||||
if(m.score!=null) n.style.setProperty("--h",healthColor(m.score));
|
if(m.score!=null) n.style.setProperty("--h",healthColor(m.score));
|
||||||
const chip=m.score!=null?`<span class="chip">${m.score}</span>`:"";
|
const chip=m.score!=null?`<span class="chip">${m.score}</span>`:"";
|
||||||
const locStr=m.loc!=null?` · ${Number(m.loc).toLocaleString()} ${tl('locUnit')}`:"";
|
const locStr=m.loc!=null?` · ${Number(m.loc).toLocaleString()} ${tl('locUnit')}`:"";
|
||||||
n.innerHTML=`${chip}<div class="lab">${m.label}</div><div class="meta">${m.grade||"–"} · ${(m.deps||[]).length}→ ${dependentsOf(m.id).length}←${locStr}</div>`;
|
n.innerHTML=`${chip}<div class="lab">${esc(m.label)}</div><div class="meta">${esc(m.grade||"–")} · ${(m.deps||[]).length}→ ${dependentsOf(m.id).length}←${locStr}</div>`;
|
||||||
n.addEventListener("click",e=>{e.stopPropagation();select(m.id)});
|
n.addEventListener("click",e=>{e.stopPropagation();select(m.id)});
|
||||||
body.appendChild(n); cardEl[m.id]=n;
|
body.appendChild(n); cardEl[m.id]=n;
|
||||||
});
|
});
|
||||||
@@ -407,33 +412,34 @@ function showSpine(){
|
|||||||
for(let i=0;i<SPINE.length-1;i++){const a=cardEl[SPINE[i]],b=cardEl[SPINE[i+1]];
|
for(let i=0;i<SPINE.length-1;i++){const a=cardEl[SPINE[i]],b=cardEl[SPINE[i+1]];
|
||||||
if(a&&b){a.classList.add("sel");b.classList.add("sel");edge(a,b,"out");}}
|
if(a&&b){a.classList.add("sel");b.classList.add("sel");edge(a,b,"out");}}
|
||||||
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('criticalPath')}</div><h2>${tl('spineTitle')}</h2>
|
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('criticalPath')}</div><h2>${tl('spineTitle')}</h2>
|
||||||
<p class="desc">${META.spineDesc||"The system's critical request path, end to end."}</p>
|
<p class="desc">${esc(META.spineDesc||"The system's critical request path, end to end.")}</p>
|
||||||
<div class="reltitle">${tl('path')}</div>
|
<div class="reltitle">${tl('path')}</div>
|
||||||
<div class="rel">${SPINE.map(id=>byId[id]?`<button data-go="${id}"><span>${byId[id].label}</span><span class="bnd">${byId[id].coupling}</span></button>`:"").join("")}</div>
|
<div class="rel">${SPINE.map(id=>byId[id]?`<button data-go="${esc(id)}"><span>${esc(byId[id].label)}</span><span class="bnd">${esc(byId[id].coupling)}</span></button>`:"").join("")}</div>
|
||||||
${legendHTML()}</div>`;
|
${legendHTML()}</div>`;
|
||||||
bindGo();
|
bindGo();
|
||||||
}
|
}
|
||||||
function auditHTML(m){
|
function auditHTML(m){
|
||||||
if(m.score==null) return "";
|
if(m.score==null) return "";
|
||||||
const col=healthColor(m.score);
|
const col=healthColor(m.score);
|
||||||
const tags=(m.tags||[]).map(t=>`<span class="tg ${t==='clean'?'ok':(BAD_TAGS.has(t)?'bad':'')}">${tagLabel(t)}</span>`).join("");
|
const tags=(m.tags||[]).map(t=>`<span class="tg ${t==='clean'?'ok':(BAD_TAGS.has(t)?'bad':'')}">${esc(tagLabel(t))}</span>`).join("");
|
||||||
|
const sevCls=s=>({HIGH:"HIGH",MED:"MED",LOW:"LOW"}[s]||"LOW");
|
||||||
const fnd=(m.findings||[]).length
|
const fnd=(m.findings||[]).length
|
||||||
? m.findings.map(f=>`<div class="finding sev-${f.sev}"><div class="top"><span class="sev sev-${f.sev}">${f.sev}</span><span class="loc">${f.loc||""}</span></div><div class="txt">${f.text||""}</div></div>`).join("")
|
? m.findings.map(f=>`<div class="finding sev-${sevCls(f.sev)}"><div class="top"><span class="sev sev-${sevCls(f.sev)}">${esc(f.sev)}</span><span class="loc">${esc(f.loc||"")}</span></div><div class="txt">${esc(f.text||"")}</div></div>`).join("")
|
||||||
: `<div class="none">${tl('clean')}</div>`;
|
: `<div class="none">${tl('clean')}</div>`;
|
||||||
return `<div class="reltitle" style="margin-top:6px">${tl('audit')}</div>`+`
|
return `<div class="reltitle" style="margin-top:6px">${tl('audit')}</div>`+`
|
||||||
<div class="scorebox"><span class="big" style="color:${col}">${m.score}</span>
|
<div class="scorebox"><span class="big" style="color:${col}">${m.score}</span>
|
||||||
<span class="gr grade-${m.grade}">${m.grade}</span>
|
<span class="gr grade-${esc(m.grade)}">${esc(m.grade)}</span>
|
||||||
<span class="bar"><i style="width:${m.score}%;background:${col}"></i></span></div>
|
<span class="bar"><i style="width:${m.score}%;background:${col}"></i></span></div>
|
||||||
<div class="tagchips">${tags}</div><div class="findings">${fnd}</div>`;
|
<div class="tagchips">${tags}</div><div class="findings">${fnd}</div>`;
|
||||||
}
|
}
|
||||||
function renderDetail(m,outs,ins){
|
function renderDetail(m,outs,ins){
|
||||||
const li=arr=>arr.length?arr.map(id=>byId[id]?`<button data-go="${id}"><span>${byId[id].label}</span><span class="bnd">${byId[id].band}</span></button>`:"").join(""):`<div class="none">${tl('none')}</div>`;
|
const li=arr=>arr.length?arr.map(id=>byId[id]?`<button data-go="${esc(id)}"><span>${esc(byId[id].label)}</span><span class="bnd">${esc(byId[id].band)}</span></button>`:"").join(""):`<div class="none">${tl('none')}</div>`;
|
||||||
detail.innerHTML=`<div class="detail"><div class="kicker">${bandTitle(m.band)}</div>
|
detail.innerHTML=`<div class="detail"><div class="kicker">${esc(bandTitle(m.band))}</div>
|
||||||
<h2>${m.label}</h2><div class="path">${m.path||""}</div>
|
<h2>${esc(m.label)}</h2><div class="path">${esc(m.path||"")}</div>
|
||||||
<div class="pill-row"><span class="pill cpl">${tl('couplingLbl')}: ${m.coupling}</span>
|
<div class="pill-row"><span class="pill cpl">${tl('couplingLbl')}: ${esc(m.coupling)}</span>
|
||||||
${m.loc!=null?`<span class="pill">${Number(m.loc).toLocaleString()} ${tl('locUnit')}</span>`:""}
|
${m.loc!=null?`<span class="pill">${Number(m.loc).toLocaleString()} ${tl('locUnit')}</span>`:""}
|
||||||
<span class="pill">${outs.length} ${tl('deps')}</span><span class="pill">${ins.length} ${tl('dependents')}</span></div>
|
<span class="pill">${outs.length} ${tl('deps')}</span><span class="pill">${ins.length} ${tl('dependents')}</span></div>
|
||||||
${m.desc?`<div class="reltitle">${tl('about')}</div><p class="desc">${m.desc}</p>`:""}
|
${m.desc?`<div class="reltitle">${tl('about')}</div><p class="desc">${esc(m.desc)}</p>`:""}
|
||||||
${auditHTML(m)}
|
${auditHTML(m)}
|
||||||
<div class="reltitle"><span class="dotc" style="background:var(--out)"></span>${tl('dependsOn')}</div>
|
<div class="reltitle"><span class="dotc" style="background:var(--out)"></span>${tl('dependsOn')}</div>
|
||||||
<div class="rel">${li(outs)}</div>
|
<div class="rel">${li(outs)}</div>
|
||||||
@@ -461,7 +467,7 @@ function renderIntro(){
|
|||||||
const cores=M.filter(m=>m.coupling==="core");
|
const cores=M.filter(m=>m.coupling==="core");
|
||||||
detail.innerHTML=`<div class="detail"><div class="empty-hint">${tl('introHint')}</div>
|
detail.innerHTML=`<div class="detail"><div class="empty-hint">${tl('introHint')}</div>
|
||||||
${cores.length?`<div class="reltitle" style="margin-top:22px">${tl('coreModules')}</div>
|
${cores.length?`<div class="reltitle" style="margin-top:22px">${tl('coreModules')}</div>
|
||||||
<div class="rel">${cores.map(m=>`<button data-go="${m.id}"><span>${m.label}</span><span class="bnd">${m.band}</span></button>`).join("")}</div>`:""}
|
<div class="rel">${cores.map(m=>`<button data-go="${esc(m.id)}"><span>${esc(m.label)}</span><span class="bnd">${esc(m.band)}</span></button>`).join("")}</div>`:""}
|
||||||
${legendHTML()}</div>`;
|
${legendHTML()}</div>`;
|
||||||
bindGo();
|
bindGo();
|
||||||
}
|
}
|
||||||
@@ -480,7 +486,7 @@ function renderReport(){
|
|||||||
<div class="kicker" style="font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:.5px">${tl('qa')} · ${scored.length} ${tl('modulesWord')}</div>
|
<div class="kicker" style="font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:.5px">${tl('qa')} · ${scored.length} ${tl('modulesWord')}</div>
|
||||||
<h2 style="font-size:18px;margin:5px 0 2px;font-weight:650">${tl('healthReport')}</h2>
|
<h2 style="font-size:18px;margin:5px 0 2px;font-weight:650">${tl('healthReport')}</h2>
|
||||||
<div class="path" style="color:var(--faint);font-size:11px;margin-bottom:4px">${["monkeypatch","fallback","legacy","stub","bloat","duplication","dual-format"].map(tagLabel).join(" · ")}</div>
|
<div class="path" style="color:var(--faint);font-size:11px;margin-bottom:4px">${["monkeypatch","fallback","legacy","stub","bloat","duplication","dual-format"].map(tagLabel).join(" · ")}</div>
|
||||||
${locLine?`<div class="path" style="color:var(--muted);font-size:11px;margin-bottom:2px">${locLine}</div>`:""}
|
${locLine?`<div class="path" style="color:var(--muted);font-size:11px;margin-bottom:2px">${esc(locLine)}</div>`:""}
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div class="stat"><div class="n" style="color:${healthColor(avg)}">${avg}</div><div class="l">${tl('avg')}</div></div>
|
<div class="stat"><div class="n" style="color:${healthColor(avg)}">${avg}</div><div class="l">${tl('avg')}</div></div>
|
||||||
<div class="stat"><div class="n grade-A">${gc.A}</div><div class="l">A</div></div>
|
<div class="stat"><div class="n grade-A">${gc.A}</div><div class="l">A</div></div>
|
||||||
@@ -489,9 +495,9 @@ function renderReport(){
|
|||||||
<div class="stat"><div class="n grade-D" style="color:#e0524b">${gc.D+gc.F}</div><div class="l">D/F</div></div>
|
<div class="stat"><div class="n grade-D" style="color:#e0524b">${gc.D+gc.F}</div><div class="l">D/F</div></div>
|
||||||
</div>
|
</div>
|
||||||
<h3>${tl('worst')}</h3>
|
<h3>${tl('worst')}</h3>
|
||||||
<div class="rel">${worst.map(m=>`<button data-go="${m.id}"><span>${m.label}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score} · ${m.grade}</span></button>`).join("")}</div>
|
<div class="rel">${worst.map(m=>`<button data-go="${esc(m.id)}"><span>${esc(m.label)}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score} · ${esc(m.grade)}</span></button>`).join("")}</div>
|
||||||
${topTags.length?`<h3>${tl('commonTags')}</h3><div class="tagchips">${topTags.map(([t,n])=>`<span class="tg bad">${tagLabel(t)} ·${n}</span>`).join("")}</div>`:""}
|
${topTags.length?`<h3>${tl('commonTags')}</h3><div class="tagchips">${topTags.map(([t,n])=>`<span class="tg bad">${esc(tagLabel(t))} ·${n}</span>`).join("")}</div>`:""}
|
||||||
${REPORT_THEMES.length?`<h3>${tl('themes')}</h3>${REPORT_THEMES.map(([h,b])=>`<p class="theme"><b>${h}.</b> ${b}</p>`).join("")}`:""}
|
${REPORT_THEMES.length?`<h3>${tl('themes')}</h3>${REPORT_THEMES.map(([h,b])=>`<p class="theme"><b>${esc(h)}.</b> ${esc(b)}</p>`).join("")}`:""}
|
||||||
<button class="btn clearbtn" id="toMapBtn">${tl('backToMap')}</button></div>`;
|
<button class="btn clearbtn" id="toMapBtn">${tl('backToMap')}</button></div>`;
|
||||||
bindGo(); document.getElementById("toMapBtn").addEventListener("click",clearSel);
|
bindGo(); document.getElementById("toMapBtn").addEventListener("click",clearSel);
|
||||||
}
|
}
|
||||||
@@ -513,10 +519,10 @@ function saveStdDraft(){ try{ localStorage.setItem(STD_KEY, JSON.stringify(STDDA
|
|||||||
function langKey(){ return LANG==="zh" ? "zh" : "en"; }
|
function langKey(){ return LANG==="zh" ? "zh" : "en"; }
|
||||||
function renderStd(){
|
function renderStd(){
|
||||||
const ed=STD_EDIT, ce=ed?' contenteditable="true" spellcheck="false"':'';
|
const ed=STD_EDIT, ce=ed?' contenteditable="true" spellcheck="false"':'';
|
||||||
const rubric=STDDATA.rubric.map((x,i)=>`<div class="std-row"><span class="std-badge" style="background:${healthColor(x.score)}">${x.grade}</span><span class="std-range">${x.range}</span><span class="std-desc"${ce} data-k="rubric" data-i="${i}">${stdText(x)}</span></div>`).join("");
|
const rubric=STDDATA.rubric.map((x,i)=>`<div class="std-row"><span class="std-badge" style="background:${healthColor(x.score)}">${esc(x.grade)}</span><span class="std-range">${esc(x.range)}</span><span class="std-desc"${ce} data-k="rubric" data-i="${i}">${esc(stdText(x))}</span></div>`).join("");
|
||||||
const sev=STDDATA.severities.map((x,i)=>`<div class="std-row"><span class="std-key"><span class="sev sev-${x.key}">${x.key}</span></span><span class="std-desc"${ce} data-k="sev" data-i="${i}">${stdText(x)}</span></div>`).join("");
|
const sev=STDDATA.severities.map((x,i)=>`<div class="std-row"><span class="std-key"><span class="sev sev-${esc(x.key)}">${esc(x.key)}</span></span><span class="std-desc"${ce} data-k="sev" data-i="${i}">${esc(stdText(x))}</span></div>`).join("");
|
||||||
const tags=STDDATA.tags.map((x,i)=>`<div class="std-row"><span class="std-key"><span class="tg ${x.bad===false?'ok':'bad'}"${ce} data-k="taglabel" data-i="${i}">${LANG==="zh"?(x.labelZh||x.label):x.label}</span></span><span class="std-desc"${ce} data-k="tag" data-i="${i}">${stdText(x)}</span>${ed?`<button class="xrm" data-rm="${i}" title="remove">✕</button>`:''}</div>`).join("");
|
const tags=STDDATA.tags.map((x,i)=>`<div class="std-row"><span class="std-key"><span class="tg ${x.bad===false?'ok':'bad'}"${ce} data-k="taglabel" data-i="${i}">${esc(LANG==="zh"?(x.labelZh||x.label):x.label)}</span></span><span class="std-desc"${ce} data-k="tag" data-i="${i}">${esc(stdText(x))}</span>${ed?`<button class="xrm" data-rm="${i}" title="remove">✕</button>`:''}</div>`).join("");
|
||||||
const coup=STDDATA.coupling.map((x,i)=>`<div class="std-row"><span class="std-key" style="font-family:var(--mono);font-size:11.5px;color:var(--ink)">${x.key}</span><span class="std-desc"${ce} data-k="coup" data-i="${i}">${stdText(x)}</span></div>`).join("");
|
const coup=STDDATA.coupling.map((x,i)=>`<div class="std-row"><span class="std-key" style="font-family:var(--mono);font-size:11.5px;color:var(--ink)">${esc(x.key)}</span><span class="std-desc"${ce} data-k="coup" data-i="${i}">${esc(stdText(x))}</span></div>`).join("");
|
||||||
document.getElementById("stdModal").innerHTML=`<div class="sheet">
|
document.getElementById("stdModal").innerHTML=`<div class="sheet">
|
||||||
<div class="xbtn" style="display:flex;gap:6px">
|
<div class="xbtn" style="display:flex;gap:6px">
|
||||||
<button class="btn ${ed?'active':''}" id="stdEdit">${ed?tl('stdDone'):tl('stdEditBtn')}</button>
|
<button class="btn ${ed?'active':''}" id="stdEdit">${ed?tl('stdDone'):tl('stdEditBtn')}</button>
|
||||||
@@ -585,10 +591,10 @@ function runFilter(){
|
|||||||
document.getElementById("fcount").textContent=n+" / "+M.length;
|
document.getElementById("fcount").textContent=n+" / "+M.length;
|
||||||
const matches=M.filter(matchesFilter).sort((a,b)=>(a.score??999)-(b.score??999));
|
const matches=M.filter(matchesFilter).sort((a,b)=>(a.score??999)-(b.score??999));
|
||||||
const gl={90:"≤ B",75:"≤ C",60:"≤ D",40:"F"}[filterGrade];
|
const gl={90:"≤ B",75:"≤ C",60:"≤ D",40:"F"}[filterGrade];
|
||||||
const crit=[gl||"",filterTag?("#"+tagLabel(filterTag)):"",searchQuery?('"'+searchQuery+'"'):""].filter(Boolean).join(" · ");
|
const crit=[gl||"",filterTag?("#"+esc(tagLabel(filterTag))):"",searchQuery?('"'+esc(searchQuery)+'"'):""].filter(Boolean).join(" · ");
|
||||||
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('filter')}</div>
|
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('filter')}</div>
|
||||||
<h2>${n} ${tl('modulesWord')}</h2><div class="path">${crit||tl('allWord')}</div>
|
<h2>${n} ${tl('modulesWord')}</h2><div class="path">${crit||tl('allWord')}</div>
|
||||||
<div class="rel">${matches.map(m=>`<button data-go="${m.id}"><span>${m.label}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score!=null?m.score+" · "+m.grade:""}</span></button>`).join("")||`<div class="none">${tl('noMatches')}</div>`}</div>
|
<div class="rel">${matches.map(m=>`<button data-go="${esc(m.id)}"><span>${esc(m.label)}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score!=null?m.score+" · "+esc(m.grade):""}</span></button>`).join("")||`<div class="none">${tl('noMatches')}</div>`}</div>
|
||||||
<button class="btn clearbtn" id="clearFilterBtn">${tl('clearFilters')}</button></div>`;
|
<button class="btn clearbtn" id="clearFilterBtn">${tl('clearFilters')}</button></div>`;
|
||||||
bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters);
|
bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,6 +201,11 @@ const BANDS = DATA.bands || [];
|
|||||||
const SPINE = DATA.spine || [];
|
const SPINE = DATA.spine || [];
|
||||||
const REPORT_THEMES = DATA.reportThemes || [];
|
const REPORT_THEMES = DATA.reportThemes || [];
|
||||||
const META = DATA.meta || {};
|
const META = DATA.meta || {};
|
||||||
|
/* HTML-escape every string that comes from modules.json (labels, descriptions,
|
||||||
|
findings, tags, paths, meta, themes, standard) before it enters innerHTML. The only
|
||||||
|
trusted HTML is the template's own i18n strings (tl) and the structural markup. */
|
||||||
|
function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g, c=>(
|
||||||
|
{"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
||||||
let BAD_TAGS = new Set(["monkeypatch","fallback","legacy","dual-format","stub","fake-output","bloat","duplication","glue","silent-except","silent-catch","any-escape","over-fit","god-component","placeholder"]);
|
let BAD_TAGS = new Set(["monkeypatch","fallback","legacy","dual-format","stub","fake-output","bloat","duplication","glue","silent-except","silent-catch","any-escape","over-fit","god-component","placeholder"]);
|
||||||
|
|
||||||
/* ---------- i18n: display language via meta.lang (module names never translated) ---------- */
|
/* ---------- i18n: display language via meta.lang (module names never translated) ---------- */
|
||||||
@@ -325,9 +330,9 @@ const svg=document.getElementById("edges");
|
|||||||
const detail=document.getElementById("detail");
|
const detail=document.getElementById("detail");
|
||||||
const cardEl={};
|
const cardEl={};
|
||||||
|
|
||||||
document.getElementById("projTitle").innerHTML = (META.project||"Project")+` <span>${tl('mapSuffix')}</span>`;
|
document.getElementById("projTitle").innerHTML = esc(META.project||"Project")+` <span>${tl('mapSuffix')}</span>`;
|
||||||
document.getElementById("subLine").innerHTML = (META.subtitle||tl('sub'))+
|
document.getElementById("subLine").innerHTML = esc(META.subtitle||tl('sub'))+
|
||||||
(META.mdPath?` · <a href="${META.mdPath.split('/').pop()}" style="color:var(--accent);text-decoration:none">${tl('report')} ↗</a>`:"");
|
(META.mdPath?` · <a href="${esc(META.mdPath.split('/').pop())}" style="color:var(--accent);text-decoration:none">${tl('report')} ↗</a>`:"");
|
||||||
/* localize static header chrome (module names/labels are never translated) */
|
/* localize static header chrome (module names/labels are never translated) */
|
||||||
document.querySelector(".scrollnote").textContent = tl("scrollNote");
|
document.querySelector(".scrollnote").textContent = tl("scrollNote");
|
||||||
document.getElementById("search").placeholder = tl("searchPh");
|
document.getElementById("search").placeholder = tl("searchPh");
|
||||||
@@ -340,14 +345,14 @@ document.getElementById("tagFilter").options[0].textContent = tl("anyIssue");
|
|||||||
BANDS.forEach(b=>{
|
BANDS.forEach(b=>{
|
||||||
if(b.wire){
|
if(b.wire){
|
||||||
const w=document.createElement("div"); w.className="wire";
|
const w=document.createElement("div"); w.className="wire";
|
||||||
w.innerHTML=`<div class="line"></div><div class="lbl">${b.t||"boundary"}</div><div class="line"></div>`;
|
w.innerHTML=`<div class="line"></div><div class="lbl">${esc(b.t||"boundary")}</div><div class="line"></div>`;
|
||||||
board.appendChild(w); return;
|
board.appendChild(w); return;
|
||||||
}
|
}
|
||||||
const band=document.createElement("div"); band.className=`band tier-${b.tier||""}`;
|
const band=document.createElement("div"); band.className=`band tier-${b.tier||""}`;
|
||||||
const items=M.filter(m=>m.band===b.id);
|
const items=M.filter(m=>m.band===b.id);
|
||||||
const gloc=items.reduce((a,m)=>a+(m.loc||0),0);
|
const gloc=items.reduce((a,m)=>a+(m.loc||0),0);
|
||||||
const head=document.createElement("div"); head.className="band-head";
|
const head=document.createElement("div"); head.className="band-head";
|
||||||
head.innerHTML=`<span class="t">${b.t||b.id}</span><span class="d">${b.d||""}</span>`+
|
head.innerHTML=`<span class="t">${esc(b.t||b.id)}</span><span class="d">${esc(b.d||"")}</span>`+
|
||||||
`<span class="num">${items.length}</span><span class="num gloc">${gloc.toLocaleString()} ${tl('locUnit')}</span>`;
|
`<span class="num">${items.length}</span><span class="num gloc">${gloc.toLocaleString()} ${tl('locUnit')}</span>`;
|
||||||
band.appendChild(head);
|
band.appendChild(head);
|
||||||
const body=document.createElement("div"); body.className="band-body";
|
const body=document.createElement("div"); body.className="band-body";
|
||||||
@@ -357,7 +362,7 @@ BANDS.forEach(b=>{
|
|||||||
if(m.score!=null) n.style.setProperty("--h",healthColor(m.score));
|
if(m.score!=null) n.style.setProperty("--h",healthColor(m.score));
|
||||||
const chip=m.score!=null?`<span class="chip">${m.score}</span>`:"";
|
const chip=m.score!=null?`<span class="chip">${m.score}</span>`:"";
|
||||||
const locStr=m.loc!=null?` · ${Number(m.loc).toLocaleString()} ${tl('locUnit')}`:"";
|
const locStr=m.loc!=null?` · ${Number(m.loc).toLocaleString()} ${tl('locUnit')}`:"";
|
||||||
n.innerHTML=`${chip}<div class="lab">${m.label}</div><div class="meta">${m.grade||"–"} · ${(m.deps||[]).length}→ ${dependentsOf(m.id).length}←${locStr}</div>`;
|
n.innerHTML=`${chip}<div class="lab">${esc(m.label)}</div><div class="meta">${esc(m.grade||"–")} · ${(m.deps||[]).length}→ ${dependentsOf(m.id).length}←${locStr}</div>`;
|
||||||
n.addEventListener("click",e=>{e.stopPropagation();select(m.id)});
|
n.addEventListener("click",e=>{e.stopPropagation();select(m.id)});
|
||||||
body.appendChild(n); cardEl[m.id]=n;
|
body.appendChild(n); cardEl[m.id]=n;
|
||||||
});
|
});
|
||||||
@@ -407,33 +412,34 @@ function showSpine(){
|
|||||||
for(let i=0;i<SPINE.length-1;i++){const a=cardEl[SPINE[i]],b=cardEl[SPINE[i+1]];
|
for(let i=0;i<SPINE.length-1;i++){const a=cardEl[SPINE[i]],b=cardEl[SPINE[i+1]];
|
||||||
if(a&&b){a.classList.add("sel");b.classList.add("sel");edge(a,b,"out");}}
|
if(a&&b){a.classList.add("sel");b.classList.add("sel");edge(a,b,"out");}}
|
||||||
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('criticalPath')}</div><h2>${tl('spineTitle')}</h2>
|
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('criticalPath')}</div><h2>${tl('spineTitle')}</h2>
|
||||||
<p class="desc">${META.spineDesc||"The system's critical request path, end to end."}</p>
|
<p class="desc">${esc(META.spineDesc||"The system's critical request path, end to end.")}</p>
|
||||||
<div class="reltitle">${tl('path')}</div>
|
<div class="reltitle">${tl('path')}</div>
|
||||||
<div class="rel">${SPINE.map(id=>byId[id]?`<button data-go="${id}"><span>${byId[id].label}</span><span class="bnd">${byId[id].coupling}</span></button>`:"").join("")}</div>
|
<div class="rel">${SPINE.map(id=>byId[id]?`<button data-go="${esc(id)}"><span>${esc(byId[id].label)}</span><span class="bnd">${esc(byId[id].coupling)}</span></button>`:"").join("")}</div>
|
||||||
${legendHTML()}</div>`;
|
${legendHTML()}</div>`;
|
||||||
bindGo();
|
bindGo();
|
||||||
}
|
}
|
||||||
function auditHTML(m){
|
function auditHTML(m){
|
||||||
if(m.score==null) return "";
|
if(m.score==null) return "";
|
||||||
const col=healthColor(m.score);
|
const col=healthColor(m.score);
|
||||||
const tags=(m.tags||[]).map(t=>`<span class="tg ${t==='clean'?'ok':(BAD_TAGS.has(t)?'bad':'')}">${tagLabel(t)}</span>`).join("");
|
const tags=(m.tags||[]).map(t=>`<span class="tg ${t==='clean'?'ok':(BAD_TAGS.has(t)?'bad':'')}">${esc(tagLabel(t))}</span>`).join("");
|
||||||
|
const sevCls=s=>({HIGH:"HIGH",MED:"MED",LOW:"LOW"}[s]||"LOW");
|
||||||
const fnd=(m.findings||[]).length
|
const fnd=(m.findings||[]).length
|
||||||
? m.findings.map(f=>`<div class="finding sev-${f.sev}"><div class="top"><span class="sev sev-${f.sev}">${f.sev}</span><span class="loc">${f.loc||""}</span></div><div class="txt">${f.text||""}</div></div>`).join("")
|
? m.findings.map(f=>`<div class="finding sev-${sevCls(f.sev)}"><div class="top"><span class="sev sev-${sevCls(f.sev)}">${esc(f.sev)}</span><span class="loc">${esc(f.loc||"")}</span></div><div class="txt">${esc(f.text||"")}</div></div>`).join("")
|
||||||
: `<div class="none">${tl('clean')}</div>`;
|
: `<div class="none">${tl('clean')}</div>`;
|
||||||
return `<div class="reltitle" style="margin-top:6px">${tl('audit')}</div>`+`
|
return `<div class="reltitle" style="margin-top:6px">${tl('audit')}</div>`+`
|
||||||
<div class="scorebox"><span class="big" style="color:${col}">${m.score}</span>
|
<div class="scorebox"><span class="big" style="color:${col}">${m.score}</span>
|
||||||
<span class="gr grade-${m.grade}">${m.grade}</span>
|
<span class="gr grade-${esc(m.grade)}">${esc(m.grade)}</span>
|
||||||
<span class="bar"><i style="width:${m.score}%;background:${col}"></i></span></div>
|
<span class="bar"><i style="width:${m.score}%;background:${col}"></i></span></div>
|
||||||
<div class="tagchips">${tags}</div><div class="findings">${fnd}</div>`;
|
<div class="tagchips">${tags}</div><div class="findings">${fnd}</div>`;
|
||||||
}
|
}
|
||||||
function renderDetail(m,outs,ins){
|
function renderDetail(m,outs,ins){
|
||||||
const li=arr=>arr.length?arr.map(id=>byId[id]?`<button data-go="${id}"><span>${byId[id].label}</span><span class="bnd">${byId[id].band}</span></button>`:"").join(""):`<div class="none">${tl('none')}</div>`;
|
const li=arr=>arr.length?arr.map(id=>byId[id]?`<button data-go="${esc(id)}"><span>${esc(byId[id].label)}</span><span class="bnd">${esc(byId[id].band)}</span></button>`:"").join(""):`<div class="none">${tl('none')}</div>`;
|
||||||
detail.innerHTML=`<div class="detail"><div class="kicker">${bandTitle(m.band)}</div>
|
detail.innerHTML=`<div class="detail"><div class="kicker">${esc(bandTitle(m.band))}</div>
|
||||||
<h2>${m.label}</h2><div class="path">${m.path||""}</div>
|
<h2>${esc(m.label)}</h2><div class="path">${esc(m.path||"")}</div>
|
||||||
<div class="pill-row"><span class="pill cpl">${tl('couplingLbl')}: ${m.coupling}</span>
|
<div class="pill-row"><span class="pill cpl">${tl('couplingLbl')}: ${esc(m.coupling)}</span>
|
||||||
${m.loc!=null?`<span class="pill">${Number(m.loc).toLocaleString()} ${tl('locUnit')}</span>`:""}
|
${m.loc!=null?`<span class="pill">${Number(m.loc).toLocaleString()} ${tl('locUnit')}</span>`:""}
|
||||||
<span class="pill">${outs.length} ${tl('deps')}</span><span class="pill">${ins.length} ${tl('dependents')}</span></div>
|
<span class="pill">${outs.length} ${tl('deps')}</span><span class="pill">${ins.length} ${tl('dependents')}</span></div>
|
||||||
${m.desc?`<div class="reltitle">${tl('about')}</div><p class="desc">${m.desc}</p>`:""}
|
${m.desc?`<div class="reltitle">${tl('about')}</div><p class="desc">${esc(m.desc)}</p>`:""}
|
||||||
${auditHTML(m)}
|
${auditHTML(m)}
|
||||||
<div class="reltitle"><span class="dotc" style="background:var(--out)"></span>${tl('dependsOn')}</div>
|
<div class="reltitle"><span class="dotc" style="background:var(--out)"></span>${tl('dependsOn')}</div>
|
||||||
<div class="rel">${li(outs)}</div>
|
<div class="rel">${li(outs)}</div>
|
||||||
@@ -461,7 +467,7 @@ function renderIntro(){
|
|||||||
const cores=M.filter(m=>m.coupling==="core");
|
const cores=M.filter(m=>m.coupling==="core");
|
||||||
detail.innerHTML=`<div class="detail"><div class="empty-hint">${tl('introHint')}</div>
|
detail.innerHTML=`<div class="detail"><div class="empty-hint">${tl('introHint')}</div>
|
||||||
${cores.length?`<div class="reltitle" style="margin-top:22px">${tl('coreModules')}</div>
|
${cores.length?`<div class="reltitle" style="margin-top:22px">${tl('coreModules')}</div>
|
||||||
<div class="rel">${cores.map(m=>`<button data-go="${m.id}"><span>${m.label}</span><span class="bnd">${m.band}</span></button>`).join("")}</div>`:""}
|
<div class="rel">${cores.map(m=>`<button data-go="${esc(m.id)}"><span>${esc(m.label)}</span><span class="bnd">${esc(m.band)}</span></button>`).join("")}</div>`:""}
|
||||||
${legendHTML()}</div>`;
|
${legendHTML()}</div>`;
|
||||||
bindGo();
|
bindGo();
|
||||||
}
|
}
|
||||||
@@ -480,7 +486,7 @@ function renderReport(){
|
|||||||
<div class="kicker" style="font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:.5px">${tl('qa')} · ${scored.length} ${tl('modulesWord')}</div>
|
<div class="kicker" style="font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:.5px">${tl('qa')} · ${scored.length} ${tl('modulesWord')}</div>
|
||||||
<h2 style="font-size:18px;margin:5px 0 2px;font-weight:650">${tl('healthReport')}</h2>
|
<h2 style="font-size:18px;margin:5px 0 2px;font-weight:650">${tl('healthReport')}</h2>
|
||||||
<div class="path" style="color:var(--faint);font-size:11px;margin-bottom:4px">${["monkeypatch","fallback","legacy","stub","bloat","duplication","dual-format"].map(tagLabel).join(" · ")}</div>
|
<div class="path" style="color:var(--faint);font-size:11px;margin-bottom:4px">${["monkeypatch","fallback","legacy","stub","bloat","duplication","dual-format"].map(tagLabel).join(" · ")}</div>
|
||||||
${locLine?`<div class="path" style="color:var(--muted);font-size:11px;margin-bottom:2px">${locLine}</div>`:""}
|
${locLine?`<div class="path" style="color:var(--muted);font-size:11px;margin-bottom:2px">${esc(locLine)}</div>`:""}
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div class="stat"><div class="n" style="color:${healthColor(avg)}">${avg}</div><div class="l">${tl('avg')}</div></div>
|
<div class="stat"><div class="n" style="color:${healthColor(avg)}">${avg}</div><div class="l">${tl('avg')}</div></div>
|
||||||
<div class="stat"><div class="n grade-A">${gc.A}</div><div class="l">A</div></div>
|
<div class="stat"><div class="n grade-A">${gc.A}</div><div class="l">A</div></div>
|
||||||
@@ -489,9 +495,9 @@ function renderReport(){
|
|||||||
<div class="stat"><div class="n grade-D" style="color:#e0524b">${gc.D+gc.F}</div><div class="l">D/F</div></div>
|
<div class="stat"><div class="n grade-D" style="color:#e0524b">${gc.D+gc.F}</div><div class="l">D/F</div></div>
|
||||||
</div>
|
</div>
|
||||||
<h3>${tl('worst')}</h3>
|
<h3>${tl('worst')}</h3>
|
||||||
<div class="rel">${worst.map(m=>`<button data-go="${m.id}"><span>${m.label}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score} · ${m.grade}</span></button>`).join("")}</div>
|
<div class="rel">${worst.map(m=>`<button data-go="${esc(m.id)}"><span>${esc(m.label)}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score} · ${esc(m.grade)}</span></button>`).join("")}</div>
|
||||||
${topTags.length?`<h3>${tl('commonTags')}</h3><div class="tagchips">${topTags.map(([t,n])=>`<span class="tg bad">${tagLabel(t)} ·${n}</span>`).join("")}</div>`:""}
|
${topTags.length?`<h3>${tl('commonTags')}</h3><div class="tagchips">${topTags.map(([t,n])=>`<span class="tg bad">${esc(tagLabel(t))} ·${n}</span>`).join("")}</div>`:""}
|
||||||
${REPORT_THEMES.length?`<h3>${tl('themes')}</h3>${REPORT_THEMES.map(([h,b])=>`<p class="theme"><b>${h}.</b> ${b}</p>`).join("")}`:""}
|
${REPORT_THEMES.length?`<h3>${tl('themes')}</h3>${REPORT_THEMES.map(([h,b])=>`<p class="theme"><b>${esc(h)}.</b> ${esc(b)}</p>`).join("")}`:""}
|
||||||
<button class="btn clearbtn" id="toMapBtn">${tl('backToMap')}</button></div>`;
|
<button class="btn clearbtn" id="toMapBtn">${tl('backToMap')}</button></div>`;
|
||||||
bindGo(); document.getElementById("toMapBtn").addEventListener("click",clearSel);
|
bindGo(); document.getElementById("toMapBtn").addEventListener("click",clearSel);
|
||||||
}
|
}
|
||||||
@@ -513,10 +519,10 @@ function saveStdDraft(){ try{ localStorage.setItem(STD_KEY, JSON.stringify(STDDA
|
|||||||
function langKey(){ return LANG==="zh" ? "zh" : "en"; }
|
function langKey(){ return LANG==="zh" ? "zh" : "en"; }
|
||||||
function renderStd(){
|
function renderStd(){
|
||||||
const ed=STD_EDIT, ce=ed?' contenteditable="true" spellcheck="false"':'';
|
const ed=STD_EDIT, ce=ed?' contenteditable="true" spellcheck="false"':'';
|
||||||
const rubric=STDDATA.rubric.map((x,i)=>`<div class="std-row"><span class="std-badge" style="background:${healthColor(x.score)}">${x.grade}</span><span class="std-range">${x.range}</span><span class="std-desc"${ce} data-k="rubric" data-i="${i}">${stdText(x)}</span></div>`).join("");
|
const rubric=STDDATA.rubric.map((x,i)=>`<div class="std-row"><span class="std-badge" style="background:${healthColor(x.score)}">${esc(x.grade)}</span><span class="std-range">${esc(x.range)}</span><span class="std-desc"${ce} data-k="rubric" data-i="${i}">${esc(stdText(x))}</span></div>`).join("");
|
||||||
const sev=STDDATA.severities.map((x,i)=>`<div class="std-row"><span class="std-key"><span class="sev sev-${x.key}">${x.key}</span></span><span class="std-desc"${ce} data-k="sev" data-i="${i}">${stdText(x)}</span></div>`).join("");
|
const sev=STDDATA.severities.map((x,i)=>`<div class="std-row"><span class="std-key"><span class="sev sev-${esc(x.key)}">${esc(x.key)}</span></span><span class="std-desc"${ce} data-k="sev" data-i="${i}">${esc(stdText(x))}</span></div>`).join("");
|
||||||
const tags=STDDATA.tags.map((x,i)=>`<div class="std-row"><span class="std-key"><span class="tg ${x.bad===false?'ok':'bad'}"${ce} data-k="taglabel" data-i="${i}">${LANG==="zh"?(x.labelZh||x.label):x.label}</span></span><span class="std-desc"${ce} data-k="tag" data-i="${i}">${stdText(x)}</span>${ed?`<button class="xrm" data-rm="${i}" title="remove">✕</button>`:''}</div>`).join("");
|
const tags=STDDATA.tags.map((x,i)=>`<div class="std-row"><span class="std-key"><span class="tg ${x.bad===false?'ok':'bad'}"${ce} data-k="taglabel" data-i="${i}">${esc(LANG==="zh"?(x.labelZh||x.label):x.label)}</span></span><span class="std-desc"${ce} data-k="tag" data-i="${i}">${esc(stdText(x))}</span>${ed?`<button class="xrm" data-rm="${i}" title="remove">✕</button>`:''}</div>`).join("");
|
||||||
const coup=STDDATA.coupling.map((x,i)=>`<div class="std-row"><span class="std-key" style="font-family:var(--mono);font-size:11.5px;color:var(--ink)">${x.key}</span><span class="std-desc"${ce} data-k="coup" data-i="${i}">${stdText(x)}</span></div>`).join("");
|
const coup=STDDATA.coupling.map((x,i)=>`<div class="std-row"><span class="std-key" style="font-family:var(--mono);font-size:11.5px;color:var(--ink)">${esc(x.key)}</span><span class="std-desc"${ce} data-k="coup" data-i="${i}">${esc(stdText(x))}</span></div>`).join("");
|
||||||
document.getElementById("stdModal").innerHTML=`<div class="sheet">
|
document.getElementById("stdModal").innerHTML=`<div class="sheet">
|
||||||
<div class="xbtn" style="display:flex;gap:6px">
|
<div class="xbtn" style="display:flex;gap:6px">
|
||||||
<button class="btn ${ed?'active':''}" id="stdEdit">${ed?tl('stdDone'):tl('stdEditBtn')}</button>
|
<button class="btn ${ed?'active':''}" id="stdEdit">${ed?tl('stdDone'):tl('stdEditBtn')}</button>
|
||||||
@@ -585,10 +591,10 @@ function runFilter(){
|
|||||||
document.getElementById("fcount").textContent=n+" / "+M.length;
|
document.getElementById("fcount").textContent=n+" / "+M.length;
|
||||||
const matches=M.filter(matchesFilter).sort((a,b)=>(a.score??999)-(b.score??999));
|
const matches=M.filter(matchesFilter).sort((a,b)=>(a.score??999)-(b.score??999));
|
||||||
const gl={90:"≤ B",75:"≤ C",60:"≤ D",40:"F"}[filterGrade];
|
const gl={90:"≤ B",75:"≤ C",60:"≤ D",40:"F"}[filterGrade];
|
||||||
const crit=[gl||"",filterTag?("#"+tagLabel(filterTag)):"",searchQuery?('"'+searchQuery+'"'):""].filter(Boolean).join(" · ");
|
const crit=[gl||"",filterTag?("#"+esc(tagLabel(filterTag))):"",searchQuery?('"'+esc(searchQuery)+'"'):""].filter(Boolean).join(" · ");
|
||||||
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('filter')}</div>
|
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('filter')}</div>
|
||||||
<h2>${n} ${tl('modulesWord')}</h2><div class="path">${crit||tl('allWord')}</div>
|
<h2>${n} ${tl('modulesWord')}</h2><div class="path">${crit||tl('allWord')}</div>
|
||||||
<div class="rel">${matches.map(m=>`<button data-go="${m.id}"><span>${m.label}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score!=null?m.score+" · "+m.grade:""}</span></button>`).join("")||`<div class="none">${tl('noMatches')}</div>`}</div>
|
<div class="rel">${matches.map(m=>`<button data-go="${esc(m.id)}"><span>${esc(m.label)}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score!=null?m.score+" · "+esc(m.grade):""}</span></button>`).join("")||`<div class="none">${tl('noMatches')}</div>`}</div>
|
||||||
<button class="btn clearbtn" id="clearFilterBtn">${tl('clearFilters')}</button></div>`;
|
<button class="btn clearbtn" id="clearFilterBtn">${tl('clearFilters')}</button></div>`;
|
||||||
bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters);
|
bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters);
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-22
@@ -1,24 +1,60 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""apply_audit.py — merge one subagent's audit result into modules.json.
|
"""apply_audit.py — validate + merge one subagent's audit result into modules.json.
|
||||||
|
|
||||||
A module audit is produced by an INDEPENDENT subagent (see reference/STANDARDS.md)
|
A module audit is produced by an INDEPENDENT subagent (see reference/STANDARDS.md) and
|
||||||
and returned as a small JSON object:
|
returned as a small JSON object:
|
||||||
|
|
||||||
{"score": 72, "grade": "C",
|
{"score": 72, "grade": "C",
|
||||||
"tags": ["duplication","legacy"],
|
"tags": ["duplication","legacy"],
|
||||||
"findings": [{"sev":"HIGH","loc":"path/file.py:120","text":"..."}, ...]}
|
"findings": [{"sev":"HIGH","loc":"path/file.py:120","text":"..."}, ...]}
|
||||||
|
|
||||||
This script writes that result onto the module and stamps `auditedHash` =
|
This script REJECTS bad audits before they pollute the state. It checks:
|
||||||
current `contentHash` (so scan.py will treat the module as fresh until its code
|
* score in 0..100 and grade in A..F;
|
||||||
changes again), plus `auditedAt` / `auditedRev`. Run scan.py --write FIRST so the
|
* grade matches the score band (rubric: 90+ A, 75+ B, 60+ C, 40+ D, else F);
|
||||||
current contentHash is present.
|
* every tag is in the effective standard (standard.json next to the state, else the
|
||||||
|
skill default) — including any custom tags the project added;
|
||||||
|
* `clean` does not coexist with any other tag, and requires score >= 75;
|
||||||
|
* a module with problem tags has at least one finding (file:line evidence);
|
||||||
|
* every finding has a non-empty sev/loc/text.
|
||||||
|
|
||||||
Accepts the result inline (--json '...') or from a file (--json-file path).
|
On success it writes score/grade/tags/findings and stamps `auditedHash` = current
|
||||||
|
`contentHash` (run scan.py --write FIRST), plus `auditedAt` / `auditedRev`.
|
||||||
|
|
||||||
|
Accepts the result inline (--json '...'), from a file (--json-file path), or stdin.
|
||||||
Stdlib only.
|
Stdlib only.
|
||||||
"""
|
"""
|
||||||
import argparse, datetime, json, sys
|
import argparse, datetime, json, os, sys
|
||||||
|
|
||||||
VALID_GRADES = {"A", "B", "C", "D", "F"}
|
VALID_GRADES = {"A", "B", "C", "D", "F"}
|
||||||
|
# fallback tag set if no standard.json is found (mirrors reference/standard.json)
|
||||||
|
DEFAULT_TAGS = {"monkeypatch", "fallback", "silent-except", "legacy", "dual-format",
|
||||||
|
"stub", "fake-output", "duplication", "bloat", "glue", "any-escape",
|
||||||
|
"over-fit", "god-component", "placeholder", "clean"}
|
||||||
|
|
||||||
|
|
||||||
|
def grade_for(score):
|
||||||
|
return ("A" if score >= 90 else "B" if score >= 75 else
|
||||||
|
"C" if score >= 60 else "D" if score >= 40 else "F")
|
||||||
|
|
||||||
|
|
||||||
|
def load_standard_tags(state_path):
|
||||||
|
"""Return the set of allowed tag ids from the effective standard."""
|
||||||
|
cands = [os.path.join(os.path.dirname(os.path.abspath(state_path)), "standard.json"),
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "reference", "standard.json")]
|
||||||
|
for c in cands:
|
||||||
|
if os.path.isfile(c):
|
||||||
|
try:
|
||||||
|
s = json.load(open(c, encoding="utf-8"))
|
||||||
|
ids = {t["id"] for t in s.get("tags", []) if "id" in t}
|
||||||
|
if ids:
|
||||||
|
return ids
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
return set(DEFAULT_TAGS)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg):
|
||||||
|
sys.exit("apply_audit: REJECTED — " + msg)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -40,26 +76,57 @@ def main():
|
|||||||
state = json.load(open(args.state, encoding="utf-8"))
|
state = json.load(open(args.state, encoding="utf-8"))
|
||||||
mod = next((m for m in state.get("modules", []) if m["id"] == args.id), None)
|
mod = next((m for m in state.get("modules", []) if m["id"] == args.id), None)
|
||||||
if mod is None:
|
if mod is None:
|
||||||
sys.exit(f"module id not found: {args.id}")
|
fail(f"module id not found: {args.id}")
|
||||||
|
|
||||||
score = int(result["score"])
|
# --- score / grade ---
|
||||||
grade = str(result["grade"]).strip().upper()[:1]
|
try:
|
||||||
if grade not in VALID_GRADES:
|
score = int(result["score"])
|
||||||
sys.exit(f"invalid grade: {result['grade']}")
|
except (KeyError, TypeError, ValueError):
|
||||||
|
fail("missing/invalid integer 'score'")
|
||||||
if not (0 <= score <= 100):
|
if not (0 <= score <= 100):
|
||||||
sys.exit(f"score out of range: {score}")
|
fail(f"score out of range 0..100: {score}")
|
||||||
|
grade = str(result.get("grade", "")).strip().upper()[:1]
|
||||||
|
if grade not in VALID_GRADES:
|
||||||
|
fail(f"invalid grade: {result.get('grade')!r}")
|
||||||
|
canonical = grade_for(score)
|
||||||
|
if grade != canonical:
|
||||||
|
fail(f"grade {grade} doesn't match score {score} (rubric grade is {canonical})")
|
||||||
|
|
||||||
|
# --- tags ---
|
||||||
|
tags = list(result.get("tags") or [])
|
||||||
|
if any(not isinstance(t, str) for t in tags):
|
||||||
|
fail("'tags' must be a list of strings")
|
||||||
|
if not tags:
|
||||||
|
tags = ["clean"]
|
||||||
|
allowed = load_standard_tags(args.state)
|
||||||
|
unknown = [t for t in tags if t not in allowed]
|
||||||
|
if unknown:
|
||||||
|
fail("tag(s) not in the standard: " + ", ".join(unknown) +
|
||||||
|
" (define them on the Standard page / standard.json, or use a known tag)")
|
||||||
|
nonclean = [t for t in tags if t != "clean"]
|
||||||
|
if "clean" in tags and nonclean:
|
||||||
|
fail("'clean' cannot coexist with problem tags: " + ", ".join(nonclean))
|
||||||
|
if "clean" in tags and score < 75:
|
||||||
|
fail(f"'clean' implies no material issues but score is {score} (<75) — "
|
||||||
|
"give the real problem tags + findings instead")
|
||||||
|
|
||||||
|
# --- findings ---
|
||||||
findings = []
|
findings = []
|
||||||
for f in result.get("findings", []):
|
for f in result.get("findings") or []:
|
||||||
sev = str(f.get("sev", "LOW")).upper()
|
sev = str(f.get("sev", "")).upper()
|
||||||
if sev not in {"HIGH", "MED", "LOW"}:
|
if sev not in {"HIGH", "MED", "LOW"}:
|
||||||
sev = "LOW"
|
fail(f"finding sev must be HIGH/MED/LOW, got {f.get('sev')!r}")
|
||||||
findings.append({"sev": sev, "loc": str(f.get("loc", "")),
|
loc, text = str(f.get("loc", "")).strip(), str(f.get("text", "")).strip()
|
||||||
"text": str(f.get("text", ""))})
|
if not loc or not text:
|
||||||
|
fail("every finding needs a non-empty 'loc' (file:line) and 'text'")
|
||||||
|
findings.append({"sev": sev, "loc": loc, "text": text})
|
||||||
|
if nonclean and not findings:
|
||||||
|
fail("a module with problem tags must include at least one finding "
|
||||||
|
"(cite file:line evidence) — see reference/STANDARDS.md")
|
||||||
|
|
||||||
mod["score"] = score
|
mod["score"] = score
|
||||||
mod["grade"] = grade
|
mod["grade"] = grade
|
||||||
mod["tags"] = list(result.get("tags", [])) or ["clean"]
|
mod["tags"] = tags
|
||||||
mod["findings"] = findings
|
mod["findings"] = findings
|
||||||
mod["auditedHash"] = mod.get("contentHash", "")
|
mod["auditedHash"] = mod.get("contentHash", "")
|
||||||
mod["auditedAt"] = datetime.datetime.now().strftime("%Y-%m-%d")
|
mod["auditedAt"] = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||||
@@ -68,7 +135,7 @@ def main():
|
|||||||
json.dump(state, open(args.state, "w", encoding="utf-8"),
|
json.dump(state, open(args.state, "w", encoding="utf-8"),
|
||||||
ensure_ascii=False, indent=1)
|
ensure_ascii=False, indent=1)
|
||||||
print(f"applied: {args.id} score={score} grade={grade} "
|
print(f"applied: {args.id} score={score} grade={grade} "
|
||||||
f"findings={len(findings)} tags={mod['tags']}")
|
f"findings={len(findings)} tags={tags}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Golden / behavior tests for the codemap scripts. Stdlib only — run with:
|
||||||
|
|
||||||
|
python -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
Each test drives the real CLI (subprocess) against a throwaway fixture, so it tests
|
||||||
|
exactly what an agent or CI runs.
|
||||||
|
"""
|
||||||
|
import json, os, shutil, subprocess, sys, tempfile, unittest
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SCRIPTS = os.path.normpath(os.path.join(HERE, "..", "scripts"))
|
||||||
|
TEMPLATE = os.path.normpath(os.path.join(HERE, "..", "assets", "template.html"))
|
||||||
|
|
||||||
|
|
||||||
|
def run(script, *args):
|
||||||
|
return subprocess.run([sys.executable, os.path.join(SCRIPTS, script), *args],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def write(path, text):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.d = tempfile.mkdtemp()
|
||||||
|
self.state = os.path.join(self.d, "modules.json")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.d, ignore_errors=True)
|
||||||
|
|
||||||
|
def save(self, state):
|
||||||
|
with open(self.state, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(state, f, ensure_ascii=False)
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
with open(self.state, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
class TestScan(Base):
|
||||||
|
def _project(self):
|
||||||
|
write(os.path.join(self.d, "src/a.py"), "x = 1\ny = 2\n")
|
||||||
|
write(os.path.join(self.d, "src/b.py"), "def f():\n return 3\n")
|
||||||
|
self.save({"meta": {}, "bands": [], "spine": [], "modules": [
|
||||||
|
{"id": "m_a", "label": "A", "band": "b", "coupling": "low", "deps": [], "paths": ["src/a.py"]},
|
||||||
|
{"id": "m_b", "label": "B", "band": "b", "coupling": "low", "deps": [], "paths": ["src/b.py"]},
|
||||||
|
{"id": "m_empty", "label": "E", "band": "b", "coupling": "low", "deps": [], "paths": ["nope/**/*.py"]},
|
||||||
|
]})
|
||||||
|
|
||||||
|
def test_loc_hash_empty(self):
|
||||||
|
self._project()
|
||||||
|
r = run("scan.py", "--root", self.d, "--state", self.state, "--write")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
rep = json.loads(r.stdout)
|
||||||
|
self.assertIn("m_empty", rep["empty"])
|
||||||
|
self.assertIn("m_a", rep["unaudited"])
|
||||||
|
st = {m["id"]: m for m in self.load()["modules"]}
|
||||||
|
self.assertEqual(st["m_a"]["loc"], 2)
|
||||||
|
self.assertTrue(st["m_a"]["contentHash"])
|
||||||
|
# hash is content-stable: re-scan gives the same hash
|
||||||
|
run("scan.py", "--root", self.d, "--state", self.state, "--write")
|
||||||
|
self.assertEqual(self.load()["modules"][0]["contentHash"], st["m_a"]["contentHash"])
|
||||||
|
|
||||||
|
def test_stale_detection(self):
|
||||||
|
self._project()
|
||||||
|
run("scan.py", "--root", self.d, "--state", self.state, "--write")
|
||||||
|
st = self.load()
|
||||||
|
for m in st["modules"]:
|
||||||
|
if m["id"] == "m_a":
|
||||||
|
m["auditedHash"] = m["contentHash"]
|
||||||
|
m["score"] = 80
|
||||||
|
self.save(st)
|
||||||
|
# unchanged → fresh
|
||||||
|
rep = json.loads(run("scan.py", "--root", self.d, "--state", self.state).stdout)
|
||||||
|
self.assertIn("m_a", rep["fresh"])
|
||||||
|
# change the file → stale
|
||||||
|
write(os.path.join(self.d, "src/a.py"), "x = 1\ny = 2\nz = 3\n")
|
||||||
|
rep = json.loads(run("scan.py", "--root", self.d, "--state", self.state).stdout)
|
||||||
|
self.assertIn("m_a", rep["stale"])
|
||||||
|
|
||||||
|
def test_git_changed_modules(self):
|
||||||
|
if shutil.which("git") is None:
|
||||||
|
self.skipTest("git not available")
|
||||||
|
self._project()
|
||||||
|
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||||
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
|
||||||
|
g = lambda *a: subprocess.run(["git", "-C", self.d, *a], capture_output=True, text=True, env=env)
|
||||||
|
g("init", "-q")
|
||||||
|
g("add", "-A"); g("commit", "-qm", "init")
|
||||||
|
head = g("rev-parse", "HEAD").stdout.strip()
|
||||||
|
st = self.load(); st["meta"]["rev"] = head; self.save(st)
|
||||||
|
write(os.path.join(self.d, "src/b.py"), "def f():\n return 99\n")
|
||||||
|
g("add", "-A"); g("commit", "-qm", "change b")
|
||||||
|
rep = json.loads(run("scan.py", "--root", self.d, "--state", self.state).stdout)
|
||||||
|
self.assertIsNotNone(rep["git"])
|
||||||
|
self.assertIn("m_b", rep["git"]["changed_modules"])
|
||||||
|
self.assertNotIn("m_a", rep["git"]["changed_modules"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuery(Base):
|
||||||
|
def _state(self):
|
||||||
|
self.save({"meta": {}, "modules": [
|
||||||
|
{"id": "good", "label": "G", "band": "x", "coupling": "low", "score": 92, "grade": "A", "tags": ["clean"], "findings": [], "paths": []},
|
||||||
|
{"id": "df", "label": "D", "band": "x", "coupling": "low", "score": 70, "grade": "C", "tags": ["dual-format"],
|
||||||
|
"findings": [{"sev": "MED", "loc": "a:1", "text": "x"}], "paths": []},
|
||||||
|
{"id": "bad", "label": "B", "band": "x", "coupling": "low", "score": 48, "grade": "D", "tags": ["stub"],
|
||||||
|
"findings": [{"sev": "HIGH", "loc": "b:2", "text": "y"}], "paths": []},
|
||||||
|
{"id": "new", "label": "N", "band": "x", "coupling": "low", "paths": []},
|
||||||
|
]})
|
||||||
|
|
||||||
|
def test_max_grade(self):
|
||||||
|
self._state()
|
||||||
|
ids = run("query.py", "--state", self.state, "--max-grade", "C", "--format", "ids").stdout.split()
|
||||||
|
self.assertCountEqual(ids, ["df", "bad"])
|
||||||
|
|
||||||
|
def test_tag(self):
|
||||||
|
self._state()
|
||||||
|
ids = run("query.py", "--state", self.state, "--tag", "dual-format", "--format", "ids").stdout.split()
|
||||||
|
self.assertEqual(ids, ["df"])
|
||||||
|
|
||||||
|
def test_sev(self):
|
||||||
|
self._state()
|
||||||
|
ids = run("query.py", "--state", self.state, "--sev", "HIGH", "--format", "ids").stdout.split()
|
||||||
|
self.assertEqual(ids, ["bad"])
|
||||||
|
|
||||||
|
def test_needs_audit(self):
|
||||||
|
self._state()
|
||||||
|
ids = run("query.py", "--state", self.state, "--needs-audit", "--format", "ids").stdout.split()
|
||||||
|
self.assertIn("new", ids)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyAudit(Base):
|
||||||
|
def _state(self):
|
||||||
|
self.save({"meta": {}, "modules": [
|
||||||
|
{"id": "m1", "label": "M", "band": "x", "coupling": "low", "contentHash": "abc", "paths": []},
|
||||||
|
]})
|
||||||
|
|
||||||
|
def apply(self, result):
|
||||||
|
return run("apply_audit.py", "--state", self.state, "--id", "m1", "--json", json.dumps(result))
|
||||||
|
|
||||||
|
def test_valid(self):
|
||||||
|
self._state()
|
||||||
|
r = self.apply({"score": 70, "grade": "C", "tags": ["legacy"],
|
||||||
|
"findings": [{"sev": "LOW", "loc": "f:1", "text": "t"}]})
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
m = self.load()["modules"][0]
|
||||||
|
self.assertEqual(m["score"], 70)
|
||||||
|
self.assertEqual(m["auditedHash"], "abc")
|
||||||
|
|
||||||
|
def test_grade_score_mismatch(self):
|
||||||
|
self._state()
|
||||||
|
r = self.apply({"score": 70, "grade": "A", "tags": ["clean"], "findings": []})
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
def test_unknown_tag(self):
|
||||||
|
self._state()
|
||||||
|
r = self.apply({"score": 70, "grade": "C", "tags": ["not-a-real-tag"],
|
||||||
|
"findings": [{"sev": "LOW", "loc": "f:1", "text": "t"}]})
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
def test_clean_with_bad_tag(self):
|
||||||
|
self._state()
|
||||||
|
r = self.apply({"score": 90, "grade": "A", "tags": ["clean", "legacy"],
|
||||||
|
"findings": [{"sev": "LOW", "loc": "f:1", "text": "t"}]})
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
def test_clean_low_score(self):
|
||||||
|
self._state()
|
||||||
|
r = self.apply({"score": 50, "grade": "D", "tags": ["clean"], "findings": []})
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
def test_finding_missing_text(self):
|
||||||
|
self._state()
|
||||||
|
r = self.apply({"score": 70, "grade": "C", "tags": ["legacy"],
|
||||||
|
"findings": [{"sev": "LOW", "loc": "f:1", "text": ""}]})
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
def test_bad_tag_without_findings(self):
|
||||||
|
self._state()
|
||||||
|
r = self.apply({"score": 70, "grade": "C", "tags": ["legacy"], "findings": []})
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRender(Base):
|
||||||
|
def _render(self, state):
|
||||||
|
self.save(state)
|
||||||
|
out_html = os.path.join(self.d, "out.html")
|
||||||
|
out_md = os.path.join(self.d, "out.md")
|
||||||
|
r = run("render.py", "--state", self.state, "--template", TEMPLATE,
|
||||||
|
"--out-html", out_html, "--out-md", out_md)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
with open(out_html, encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
with open(out_md, encoding="utf-8") as f:
|
||||||
|
md = f.read()
|
||||||
|
return html, md
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
html, md = self._render({"meta": {"project": "Demo"}, "bands": [{"id": "b", "t": "B"}], "spine": [],
|
||||||
|
"modules": [{"id": "m1", "label": "Widget", "band": "b", "coupling": "low", "deps": [],
|
||||||
|
"loc": 5, "score": 80, "grade": "B", "tags": ["clean"], "findings": []}]})
|
||||||
|
self.assertIn("Widget", html) # label present in the DATA
|
||||||
|
self.assertIn("function esc(", html) # the escaper ships
|
||||||
|
self.assertIn("Widget", md)
|
||||||
|
|
||||||
|
def test_script_breakout_blocked(self):
|
||||||
|
# a label containing </script> must not be able to close the data <script> tag
|
||||||
|
html, _ = self._render({"meta": {}, "bands": [{"id": "b", "t": "B"}], "spine": [],
|
||||||
|
"modules": [{"id": "m1", "label": "</script><script>alert(1)</script>", "band": "b",
|
||||||
|
"coupling": "low", "deps": [], "loc": 1, "score": 50, "grade": "D",
|
||||||
|
"tags": ["stub"], "findings": [{"sev": "HIGH", "loc": "a:1", "text": "x"}]}]})
|
||||||
|
self.assertEqual(html.count("</script>"), 1) # only the real closing tag
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user