diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6f6b566 --- /dev/null +++ b/.github/workflows/test.yml @@ -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"", html, re.S) + open("/tmp/_t.js", "w", encoding="utf-8").write(m.group(1).replace("__ARCH_DATA__", "{}")) + PY + node --check /tmp/_t.js diff --git a/README.md b/README.md index 6f6dd9a..645b89f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SKILL.md b/SKILL.md index 8f5e8ee..5b25da8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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 - `/.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 `/.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 diff --git a/assets/template.html b/assets/template.html index e751cc7..d29a4ed 100644 --- a/assets/template.html +++ b/assets/template.html @@ -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=>( + {"&":"&","<":"<",">":">",'"':""","'":"'"}[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")+` ${tl('mapSuffix')}`; -document.getElementById("subLine").innerHTML = (META.subtitle||tl('sub'))+ - (META.mdPath?` · ${tl('report')} ↗`:""); +document.getElementById("projTitle").innerHTML = esc(META.project||"Project")+` ${tl('mapSuffix')}`; +document.getElementById("subLine").innerHTML = esc(META.subtitle||tl('sub'))+ + (META.mdPath?` · ${tl('report')} ↗`:""); /* 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=`
${b.t||"boundary"}
`; + w.innerHTML=`
${esc(b.t||"boundary")}
`; 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=`${b.t||b.id}${b.d||""}`+ + head.innerHTML=`${esc(b.t||b.id)}${esc(b.d||"")}`+ `${items.length}${gloc.toLocaleString()} ${tl('locUnit')}`; 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?`${m.score}`:""; const locStr=m.loc!=null?` · ${Number(m.loc).toLocaleString()} ${tl('locUnit')}`:""; - n.innerHTML=`${chip}
${m.label}
${m.grade||"–"} · ${(m.deps||[]).length}→ ${dependentsOf(m.id).length}←${locStr}
`; + n.innerHTML=`${chip}
${esc(m.label)}
${esc(m.grade||"–")} · ${(m.deps||[]).length}→ ${dependentsOf(m.id).length}←${locStr}
`; 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
${tl('criticalPath')}

${tl('spineTitle')}

-

${META.spineDesc||"The system's critical request path, end to end."}

+

${esc(META.spineDesc||"The system's critical request path, end to end.")}

${tl('path')}
-
${SPINE.map(id=>byId[id]?``:"").join("")}
+
${SPINE.map(id=>byId[id]?``:"").join("")}
${legendHTML()}`; bindGo(); } function auditHTML(m){ if(m.score==null) return ""; const col=healthColor(m.score); - const tags=(m.tags||[]).map(t=>`${tagLabel(t)}`).join(""); + const tags=(m.tags||[]).map(t=>`${esc(tagLabel(t))}`).join(""); + const sevCls=s=>({HIGH:"HIGH",MED:"MED",LOW:"LOW"}[s]||"LOW"); const fnd=(m.findings||[]).length - ? m.findings.map(f=>`
${f.sev}${f.loc||""}
${f.text||""}
`).join("") + ? m.findings.map(f=>`
${esc(f.sev)}${esc(f.loc||"")}
${esc(f.text||"")}
`).join("") : `
${tl('clean')}
`; return `
${tl('audit')}
`+`
${m.score} - ${m.grade} + ${esc(m.grade)}
${tags}
${fnd}
`; } function renderDetail(m,outs,ins){ - const li=arr=>arr.length?arr.map(id=>byId[id]?``:"").join(""):`
${tl('none')}
`; - detail.innerHTML=`
${bandTitle(m.band)}
-

${m.label}

${m.path||""}
-
${tl('couplingLbl')}: ${m.coupling} + const li=arr=>arr.length?arr.map(id=>byId[id]?``:"").join(""):`
${tl('none')}
`; + detail.innerHTML=`
${esc(bandTitle(m.band))}
+

${esc(m.label)}

${esc(m.path||"")}
+
${tl('couplingLbl')}: ${esc(m.coupling)} ${m.loc!=null?`${Number(m.loc).toLocaleString()} ${tl('locUnit')}`:""} ${outs.length} ${tl('deps')}${ins.length} ${tl('dependents')}
- ${m.desc?`
${tl('about')}

${m.desc}

`:""} + ${m.desc?`
${tl('about')}

${esc(m.desc)}

`:""} ${auditHTML(m)}
${tl('dependsOn')}
${li(outs)}
@@ -461,7 +467,7 @@ function renderIntro(){ const cores=M.filter(m=>m.coupling==="core"); detail.innerHTML=`
${tl('introHint')}
${cores.length?`
${tl('coreModules')}
-
${cores.map(m=>``).join("")}
`:""} +
${cores.map(m=>``).join("")}
`:""} ${legendHTML()}
`; bindGo(); } @@ -480,7 +486,7 @@ function renderReport(){
${tl('qa')} · ${scored.length} ${tl('modulesWord')}

${tl('healthReport')}

${["monkeypatch","fallback","legacy","stub","bloat","duplication","dual-format"].map(tagLabel).join(" · ")}
- ${locLine?`
${locLine}
`:""} + ${locLine?`
${esc(locLine)}
`:""}
${avg}
${tl('avg')}
${gc.A}
A
@@ -489,9 +495,9 @@ function renderReport(){
${gc.D+gc.F}
D/F

${tl('worst')}

-
${worst.map(m=>``).join("")}
- ${topTags.length?`

${tl('commonTags')}

${topTags.map(([t,n])=>`${tagLabel(t)} ·${n}`).join("")}
`:""} - ${REPORT_THEMES.length?`

${tl('themes')}

${REPORT_THEMES.map(([h,b])=>`

${h}. ${b}

`).join("")}`:""} +
${worst.map(m=>``).join("")}
+ ${topTags.length?`

${tl('commonTags')}

${topTags.map(([t,n])=>`${esc(tagLabel(t))} ·${n}`).join("")}
`:""} + ${REPORT_THEMES.length?`

${tl('themes')}

${REPORT_THEMES.map(([h,b])=>`

${esc(h)}. ${esc(b)}

`).join("")}`:""}
`; 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)=>`
${x.grade}${x.range}${stdText(x)}
`).join(""); - const sev=STDDATA.severities.map((x,i)=>`
${x.key}${stdText(x)}
`).join(""); - const tags=STDDATA.tags.map((x,i)=>`
${LANG==="zh"?(x.labelZh||x.label):x.label}${stdText(x)}${ed?``:''}
`).join(""); - const coup=STDDATA.coupling.map((x,i)=>`
${x.key}${stdText(x)}
`).join(""); + const rubric=STDDATA.rubric.map((x,i)=>`
${esc(x.grade)}${esc(x.range)}${esc(stdText(x))}
`).join(""); + const sev=STDDATA.severities.map((x,i)=>`
${esc(x.key)}${esc(stdText(x))}
`).join(""); + const tags=STDDATA.tags.map((x,i)=>`
${esc(LANG==="zh"?(x.labelZh||x.label):x.label)}${esc(stdText(x))}${ed?``:''}
`).join(""); + const coup=STDDATA.coupling.map((x,i)=>`
${esc(x.key)}${esc(stdText(x))}
`).join(""); document.getElementById("stdModal").innerHTML=`
@@ -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=`
${tl('filter')}

${n} ${tl('modulesWord')}

${crit||tl('allWord')}
-
${matches.map(m=>``).join("")||`
${tl('noMatches')}
`}
+
${matches.map(m=>``).join("")||`
${tl('noMatches')}
`}
`; bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters); } diff --git a/examples/sample-project/codemap.html b/examples/sample-project/codemap.html index 618f4fa..c1d9629 100644 --- a/examples/sample-project/codemap.html +++ b/examples/sample-project/codemap.html @@ -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=>( + {"&":"&","<":"<",">":">",'"':""","'":"'"}[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")+` ${tl('mapSuffix')}`; -document.getElementById("subLine").innerHTML = (META.subtitle||tl('sub'))+ - (META.mdPath?` · ${tl('report')} ↗`:""); +document.getElementById("projTitle").innerHTML = esc(META.project||"Project")+` ${tl('mapSuffix')}`; +document.getElementById("subLine").innerHTML = esc(META.subtitle||tl('sub'))+ + (META.mdPath?` · ${tl('report')} ↗`:""); /* 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=`
${b.t||"boundary"}
`; + w.innerHTML=`
${esc(b.t||"boundary")}
`; 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=`${b.t||b.id}${b.d||""}`+ + head.innerHTML=`${esc(b.t||b.id)}${esc(b.d||"")}`+ `${items.length}${gloc.toLocaleString()} ${tl('locUnit')}`; 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?`${m.score}`:""; const locStr=m.loc!=null?` · ${Number(m.loc).toLocaleString()} ${tl('locUnit')}`:""; - n.innerHTML=`${chip}
${m.label}
${m.grade||"–"} · ${(m.deps||[]).length}→ ${dependentsOf(m.id).length}←${locStr}
`; + n.innerHTML=`${chip}
${esc(m.label)}
${esc(m.grade||"–")} · ${(m.deps||[]).length}→ ${dependentsOf(m.id).length}←${locStr}
`; 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
${tl('criticalPath')}

${tl('spineTitle')}

-

${META.spineDesc||"The system's critical request path, end to end."}

+

${esc(META.spineDesc||"The system's critical request path, end to end.")}

${tl('path')}
-
${SPINE.map(id=>byId[id]?``:"").join("")}
+
${SPINE.map(id=>byId[id]?``:"").join("")}
${legendHTML()}
`; bindGo(); } function auditHTML(m){ if(m.score==null) return ""; const col=healthColor(m.score); - const tags=(m.tags||[]).map(t=>`${tagLabel(t)}`).join(""); + const tags=(m.tags||[]).map(t=>`${esc(tagLabel(t))}`).join(""); + const sevCls=s=>({HIGH:"HIGH",MED:"MED",LOW:"LOW"}[s]||"LOW"); const fnd=(m.findings||[]).length - ? m.findings.map(f=>`
${f.sev}${f.loc||""}
${f.text||""}
`).join("") + ? m.findings.map(f=>`
${esc(f.sev)}${esc(f.loc||"")}
${esc(f.text||"")}
`).join("") : `
${tl('clean')}
`; return `
${tl('audit')}
`+`
${m.score} - ${m.grade} + ${esc(m.grade)}
${tags}
${fnd}
`; } function renderDetail(m,outs,ins){ - const li=arr=>arr.length?arr.map(id=>byId[id]?``:"").join(""):`
${tl('none')}
`; - detail.innerHTML=`
${bandTitle(m.band)}
-

${m.label}

${m.path||""}
-
${tl('couplingLbl')}: ${m.coupling} + const li=arr=>arr.length?arr.map(id=>byId[id]?``:"").join(""):`
${tl('none')}
`; + detail.innerHTML=`
${esc(bandTitle(m.band))}
+

${esc(m.label)}

${esc(m.path||"")}
+
${tl('couplingLbl')}: ${esc(m.coupling)} ${m.loc!=null?`${Number(m.loc).toLocaleString()} ${tl('locUnit')}`:""} ${outs.length} ${tl('deps')}${ins.length} ${tl('dependents')}
- ${m.desc?`
${tl('about')}

${m.desc}

`:""} + ${m.desc?`
${tl('about')}

${esc(m.desc)}

`:""} ${auditHTML(m)}
${tl('dependsOn')}
${li(outs)}
@@ -461,7 +467,7 @@ function renderIntro(){ const cores=M.filter(m=>m.coupling==="core"); detail.innerHTML=`
${tl('introHint')}
${cores.length?`
${tl('coreModules')}
-
${cores.map(m=>``).join("")}
`:""} +
${cores.map(m=>``).join("")}
`:""} ${legendHTML()}
`; bindGo(); } @@ -480,7 +486,7 @@ function renderReport(){
${tl('qa')} · ${scored.length} ${tl('modulesWord')}

${tl('healthReport')}

${["monkeypatch","fallback","legacy","stub","bloat","duplication","dual-format"].map(tagLabel).join(" · ")}
- ${locLine?`
${locLine}
`:""} + ${locLine?`
${esc(locLine)}
`:""}
${avg}
${tl('avg')}
${gc.A}
A
@@ -489,9 +495,9 @@ function renderReport(){
${gc.D+gc.F}
D/F

${tl('worst')}

-
${worst.map(m=>``).join("")}
- ${topTags.length?`

${tl('commonTags')}

${topTags.map(([t,n])=>`${tagLabel(t)} ·${n}`).join("")}
`:""} - ${REPORT_THEMES.length?`

${tl('themes')}

${REPORT_THEMES.map(([h,b])=>`

${h}. ${b}

`).join("")}`:""} +
${worst.map(m=>``).join("")}
+ ${topTags.length?`

${tl('commonTags')}

${topTags.map(([t,n])=>`${esc(tagLabel(t))} ·${n}`).join("")}
`:""} + ${REPORT_THEMES.length?`

${tl('themes')}

${REPORT_THEMES.map(([h,b])=>`

${esc(h)}. ${esc(b)}

`).join("")}`:""}
`; 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)=>`
${x.grade}${x.range}${stdText(x)}
`).join(""); - const sev=STDDATA.severities.map((x,i)=>`
${x.key}${stdText(x)}
`).join(""); - const tags=STDDATA.tags.map((x,i)=>`
${LANG==="zh"?(x.labelZh||x.label):x.label}${stdText(x)}${ed?``:''}
`).join(""); - const coup=STDDATA.coupling.map((x,i)=>`
${x.key}${stdText(x)}
`).join(""); + const rubric=STDDATA.rubric.map((x,i)=>`
${esc(x.grade)}${esc(x.range)}${esc(stdText(x))}
`).join(""); + const sev=STDDATA.severities.map((x,i)=>`
${esc(x.key)}${esc(stdText(x))}
`).join(""); + const tags=STDDATA.tags.map((x,i)=>`
${esc(LANG==="zh"?(x.labelZh||x.label):x.label)}${esc(stdText(x))}${ed?``:''}
`).join(""); + const coup=STDDATA.coupling.map((x,i)=>`
${esc(x.key)}${esc(stdText(x))}
`).join(""); document.getElementById("stdModal").innerHTML=`
@@ -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=`
${tl('filter')}

${n} ${tl('modulesWord')}

${crit||tl('allWord')}
-
${matches.map(m=>``).join("")||`
${tl('noMatches')}
`}
+
${matches.map(m=>``).join("")||`
${tl('noMatches')}
`}
`; bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters); } diff --git a/scripts/apply_audit.py b/scripts/apply_audit.py index 7d8f18d..06bf283 100644 --- a/scripts/apply_audit.py +++ b/scripts/apply_audit.py @@ -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 = int(result["score"]) - grade = str(result["grade"]).strip().upper()[:1] - if grade not in VALID_GRADES: - sys.exit(f"invalid grade: {result['grade']}") + # --- score / grade --- + try: + score = int(result["score"]) + 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__": diff --git a/tests/test_scripts.py b/tests/test_scripts.py new file mode 100644 index 0000000..e60d6ec --- /dev/null +++ b/tests/test_scripts.py @@ -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 must not be able to close the data ", "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(""), 1) # only the real closing tag + + +if __name__ == "__main__": + unittest.main()