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:
Xingyu Chen
2026-06-10 19:31:57 -07:00
parent 1522b3634f
commit 47d9b86c5d
7 changed files with 468 additions and 86 deletions
+51
View File
@@ -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
+23 -5
View File
@@ -9,11 +9,13 @@ helps you **pay down the cruft** — incrementally, one commit at a time.
![Python 3 · stdlib only](https://img.shields.io/badge/python-3%20·%20stdlib%20only-3776ab)
![language agnostic](https://img.shields.io/badge/langs-Py%20·%20TS%20·%20Rust%20·%20C%23%20·%20C%2B%2B-555)
![license MIT](https://img.shields.io/badge/license-MIT-blue)
[![tests](https://github.com/Asixa/codemap-skill/actions/workflows/test.yml/badge.svg)](https://github.com/Asixa/codemap-skill/actions/workflows/test.yml)
> Every codebase accumulates cruft over time — monkeypatches, silent fallbacks, dead
> "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
> 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.
![architecture map](examples/01-map.png)
@@ -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
only re-audit what changed, and `update` shows you the **commits since last time** and
which modules they touched.
- **Cleanup that can't regress.** `fix` runs a four-role loop — lock a test baseline →
fix → an **independent acceptance check** proves the pre-fix tests still pass → re-score.
- **Regression-gated cleanup.** `fix` runs a four-role loop — lock a test baseline →
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
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
**Click any module** to highlight what it calls (downstream) and what depends on it
@@ -155,11 +169,15 @@ codemap/
scripts/ # deterministic, stdlib-only Python
scan.py # LoC + content hash + git diff + staleness
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
assets/
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
+20 -5
View File
@@ -91,10 +91,10 @@ per-module subagent loop — never load the full `modules.json` just to pick tar
## 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
inline in the main thread; never copy one module's score to another. Spawn them in
parallel (one message, multiple Agent calls — Explore or general-purpose).
inline in the main thread; never copy one module's score to another. Run them in
parallel where the platform supports it (see *Capabilities & platform mapping*).
2. **Scripts are deterministic; only decomposition, auditing, and theme-synthesis are
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).
@@ -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
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)
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
`<project>/.codemap/config.json`:
0. **Ask the user for preferences first** (use `AskUserQuestion` if available, else just ask
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
never translated). → `meta.lang`.
- **Output location** — where the HTML/MD go. Default `.codemap/` (kept with the tool
+33 -27
View File
@@ -201,6 +201,11 @@ const BANDS = DATA.bands || [];
const SPINE = DATA.spine || [];
const REPORT_THEMES = DATA.reportThemes || [];
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=>(
{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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"]);
/* ---------- 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 cardEl={};
document.getElementById("projTitle").innerHTML = (META.project||"Project")+` <span>${tl('mapSuffix')}</span>`;
document.getElementById("subLine").innerHTML = (META.subtitle||tl('sub'))+
(META.mdPath?` · <a href="${META.mdPath.split('/').pop()}" style="color:var(--accent);text-decoration:none">${tl('report')} ↗</a>`:"");
document.getElementById("projTitle").innerHTML = esc(META.project||"Project")+` <span>${tl('mapSuffix')}</span>`;
document.getElementById("subLine").innerHTML = esc(META.subtitle||tl('sub'))+
(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) */
document.querySelector(".scrollnote").textContent = tl("scrollNote");
document.getElementById("search").placeholder = tl("searchPh");
@@ -340,14 +345,14 @@ document.getElementById("tagFilter").options[0].textContent = tl("anyIssue");
BANDS.forEach(b=>{
if(b.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;
}
const band=document.createElement("div"); band.className=`band tier-${b.tier||""}`;
const items=M.filter(m=>m.band===b.id);
const gloc=items.reduce((a,m)=>a+(m.loc||0),0);
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>`;
band.appendChild(head);
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));
const chip=m.score!=null?`<span class="chip">${m.score}</span>`:"";
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)});
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]];
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>
<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="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>`;
bindGo();
}
function auditHTML(m){
if(m.score==null) return "";
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
? 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>`;
return `<div class="reltitle" style="margin-top:6px">${tl('audit')}</div>`+`
<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>
<div class="tagchips">${tags}</div><div class="findings">${fnd}</div>`;
}
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>`;
detail.innerHTML=`<div class="detail"><div class="kicker">${bandTitle(m.band)}</div>
<h2>${m.label}</h2><div class="path">${m.path||""}</div>
<div class="pill-row"><span class="pill cpl">${tl('couplingLbl')}: ${m.coupling}</span>
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">${esc(bandTitle(m.band))}</div>
<h2>${esc(m.label)}</h2><div class="path">${esc(m.path||"")}</div>
<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>`:""}
<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)}
<div class="reltitle"><span class="dotc" style="background:var(--out)"></span>${tl('dependsOn')}</div>
<div class="rel">${li(outs)}</div>
@@ -461,7 +467,7 @@ function renderIntro(){
const cores=M.filter(m=>m.coupling==="core");
detail.innerHTML=`<div class="detail"><div class="empty-hint">${tl('introHint')}</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>`;
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>
<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>
${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"><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>
@@ -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>
<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>
${topTags.length?`<h3>${tl('commonTags')}</h3><div class="tagchips">${topTags.map(([t,n])=>`<span class="tg bad">${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("")}`:""}
<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">${esc(tagLabel(t))} ·${n}</span>`).join("")}</div>`:""}
${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>`;
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 renderStd(){
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 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 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 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 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-${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}">${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)">${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">
<div class="xbtn" style="display:flex;gap:6px">
<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;
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 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>
<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>`;
bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters);
}
+33 -27
View File
@@ -201,6 +201,11 @@ const BANDS = DATA.bands || [];
const SPINE = DATA.spine || [];
const REPORT_THEMES = DATA.reportThemes || [];
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=>(
{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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"]);
/* ---------- 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 cardEl={};
document.getElementById("projTitle").innerHTML = (META.project||"Project")+` <span>${tl('mapSuffix')}</span>`;
document.getElementById("subLine").innerHTML = (META.subtitle||tl('sub'))+
(META.mdPath?` · <a href="${META.mdPath.split('/').pop()}" style="color:var(--accent);text-decoration:none">${tl('report')} ↗</a>`:"");
document.getElementById("projTitle").innerHTML = esc(META.project||"Project")+` <span>${tl('mapSuffix')}</span>`;
document.getElementById("subLine").innerHTML = esc(META.subtitle||tl('sub'))+
(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) */
document.querySelector(".scrollnote").textContent = tl("scrollNote");
document.getElementById("search").placeholder = tl("searchPh");
@@ -340,14 +345,14 @@ document.getElementById("tagFilter").options[0].textContent = tl("anyIssue");
BANDS.forEach(b=>{
if(b.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;
}
const band=document.createElement("div"); band.className=`band tier-${b.tier||""}`;
const items=M.filter(m=>m.band===b.id);
const gloc=items.reduce((a,m)=>a+(m.loc||0),0);
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>`;
band.appendChild(head);
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));
const chip=m.score!=null?`<span class="chip">${m.score}</span>`:"";
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)});
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]];
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>
<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="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>`;
bindGo();
}
function auditHTML(m){
if(m.score==null) return "";
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
? 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>`;
return `<div class="reltitle" style="margin-top:6px">${tl('audit')}</div>`+`
<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>
<div class="tagchips">${tags}</div><div class="findings">${fnd}</div>`;
}
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>`;
detail.innerHTML=`<div class="detail"><div class="kicker">${bandTitle(m.band)}</div>
<h2>${m.label}</h2><div class="path">${m.path||""}</div>
<div class="pill-row"><span class="pill cpl">${tl('couplingLbl')}: ${m.coupling}</span>
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">${esc(bandTitle(m.band))}</div>
<h2>${esc(m.label)}</h2><div class="path">${esc(m.path||"")}</div>
<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>`:""}
<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)}
<div class="reltitle"><span class="dotc" style="background:var(--out)"></span>${tl('dependsOn')}</div>
<div class="rel">${li(outs)}</div>
@@ -461,7 +467,7 @@ function renderIntro(){
const cores=M.filter(m=>m.coupling==="core");
detail.innerHTML=`<div class="detail"><div class="empty-hint">${tl('introHint')}</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>`;
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>
<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>
${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"><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>
@@ -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>
<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>
${topTags.length?`<h3>${tl('commonTags')}</h3><div class="tagchips">${topTags.map(([t,n])=>`<span class="tg bad">${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("")}`:""}
<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">${esc(tagLabel(t))} ·${n}</span>`).join("")}</div>`:""}
${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>`;
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 renderStd(){
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 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 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 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 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-${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}">${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)">${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">
<div class="xbtn" style="display:flex;gap:6px">
<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;
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 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>
<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>`;
bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters);
}
+88 -21
View File
@@ -1,24 +1,60 @@
#!/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)
and returned as a small JSON object:
A module audit is produced by an INDEPENDENT subagent (see reference/STANDARDS.md) and
returned as a small JSON object:
{"score": 72, "grade": "C",
"tags": ["duplication","legacy"],
"findings": [{"sev":"HIGH","loc":"path/file.py:120","text":"..."}, ...]}
This script writes that result onto the module and stamps `auditedHash` =
current `contentHash` (so scan.py will treat the module as fresh until its code
changes again), plus `auditedAt` / `auditedRev`. Run scan.py --write FIRST so the
current contentHash is present.
This script REJECTS bad audits before they pollute the state. It checks:
* score in 0..100 and grade in A..F;
* grade matches the score band (rubric: 90+ A, 75+ B, 60+ C, 40+ D, else F);
* 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.
"""
import argparse, datetime, json, sys
import argparse, datetime, json, os, sys
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():
@@ -40,26 +76,57 @@ def main():
state = json.load(open(args.state, encoding="utf-8"))
mod = next((m for m in state.get("modules", []) if m["id"] == args.id), None)
if mod is None:
sys.exit(f"module id not found: {args.id}")
fail(f"module id not found: {args.id}")
# --- score / grade ---
try:
score = int(result["score"])
grade = str(result["grade"]).strip().upper()[:1]
if grade not in VALID_GRADES:
sys.exit(f"invalid grade: {result['grade']}")
except (KeyError, TypeError, ValueError):
fail("missing/invalid integer 'score'")
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 = []
for f in result.get("findings", []):
sev = str(f.get("sev", "LOW")).upper()
for f in result.get("findings") or []:
sev = str(f.get("sev", "")).upper()
if sev not in {"HIGH", "MED", "LOW"}:
sev = "LOW"
findings.append({"sev": sev, "loc": str(f.get("loc", "")),
"text": str(f.get("text", ""))})
fail(f"finding sev must be HIGH/MED/LOW, got {f.get('sev')!r}")
loc, text = str(f.get("loc", "")).strip(), str(f.get("text", "")).strip()
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["grade"] = grade
mod["tags"] = list(result.get("tags", [])) or ["clean"]
mod["tags"] = tags
mod["findings"] = findings
mod["auditedHash"] = mod.get("contentHash", "")
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"),
ensure_ascii=False, indent=1)
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__":
+219
View File
@@ -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()