Initial commit: codemap skill (architecture map + per-module code-quality audit)

This commit is contained in:
Xingyu Chen
2026-06-10 18:07:15 -07:00
commit 2d7c7d0afd
10 changed files with 1561 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.DS_Store
+131
View File
@@ -0,0 +1,131 @@
# codemap
A [Claude Code](https://claude.com/claude-code) **Agent Skill** that builds and
incrementally maintains an **interactive architecture map + per-module code-quality
audit** for any codebase.
It decomposes a project into *functional* modules (not files), draws their dependency
graph as a layered, clickable HTML page, and scores each module 0100 for code health —
hunting for monkeypatching, fallbacks, legacy/dead code, stubs, dual-format handling,
bloat, duplication, and glue. Every module's score comes from an **independent
subagent** against a fixed rubric. It's **incremental**: a per-module content hash means
re-runs only re-audit what changed.
## What you get
Three coupled artifacts, kept in sync:
| File | What | Where (default) |
|---|---|---|
| `modules.json` | the **source of truth** (modules, deps, coupling, LoC, hash, score, findings) | `<project>/.claude/codemap/` |
| `architecture-map.html` | self-contained **interactive map** (health coloring, filters, dependency highlighting, audit report) | `<project>/docs/` |
| `architecture-audit.md` | the written **report** (per-layer scores, LoC table, worst offenders, themes) | `<project>/docs/` |
The HTML and MD are **generated** from `modules.json` and must never be hand-edited.
### Interactive map features
- Layered bands top→bottom along the data-flow; click a module to highlight what it
**calls** (downstream) and what **depends on it** (upstream).
- Per-module **health score + grade (AF)**, smell tags, and concrete `file:line` findings.
- Color modes: **coupling** or **health** (problems pop amber/red, healthy modules
recede to a muted green — colorblind-friendly, the cue is saturation not just hue).
- **Filters**: by grade level (≤ B/C/D/F) and by issue tag; live match count.
- **Audit report** view: averages, grade spread, worst offenders, cross-cutting themes.
- **i18n**: set `meta.lang` to `"en"` or `"zh"` (module names are never translated).
## Requirements
- **Python 3** (standard library only — no `pip install`, no external packages).
- **Claude Code** (the skill orchestrates subagents for the audit/fix/test steps).
- A browser to open the generated HTML. That's it.
## Install
A skill is just a folder under `~/.claude/skills/`. Clone this repo into it:
```bash
git clone <this-repo-url> ~/.claude/skills/codemap
```
(Windows PowerShell: `git clone <url> $env:USERPROFILE\.claude\skills\codemap`.)
Restart Claude Code (or start a new session). The skill appears as `/codemap`.
## Usage
Talk to Claude in natural language, or use the subcommands. Claude reads `SKILL.md`
and runs the scripts; the **audit / fix / test** steps spawn independent subagents.
| Command | Does |
|---|---|
| `/codemap generate` | first build: decompose → scan → audit every module → render |
| `/codemap check` | read-only: is the map stale? lists drifted / new / deleted modules |
| `/codemap update` | incremental: re-audit only changed modules, re-render |
| `/codemap test <module>` | generate tests (regression net) for a module |
| `/codemap fix <module>` | regression-gated fix: lock baseline → fix → independent acceptance → re-score |
You can also run the deterministic scripts directly (no AI needed for these):
```bash
S=~/.claude/skills/codemap
# what changed since last audit
python3 $S/scripts/scan.py --root . --state .claude/codemap/modules.json
# find modules to act on without reading the whole state (token-cheap, for agents)
python3 $S/scripts/query.py --state .claude/codemap/modules.json --max-grade C --format ids
python3 $S/scripts/query.py --state .claude/codemap/modules.json --tag dual-format
# regenerate the HTML + MD from the state
python3 $S/scripts/render.py --state .claude/codemap/modules.json \
--template $S/assets/template.html \
--out-html docs/architecture-map.html --out-md docs/architecture-audit.md
```
> On Windows use `python` instead of `python3`.
## How it works
```
modules.json ──scan.py──▶ + LoC & content hash per module (stale = hash != auditedHash)
│ (decomposition + descriptions are authored by the model)
│◀─apply_audit.py── one INDEPENDENT subagent's score per module (fixed rubric)
│◀─query.py────────── token-cheap targeting (by grade / tag / severity / staleness)
└──render.py────────▶ architecture-map.html + architecture-audit.md
```
- **Four separate subagent roles, never merged**: *auditor* (scores), *test-author*
(writes tests), *fixer* (changes code), *acceptance/verifier* (proves no regression).
A `fix` is accepted only when an independent acceptance subagent shows the pre-fix
green tests are still green and the build is clean.
- Tests are excluded from a module's audit scope (they're the regression net, tracked
separately in the module's `tests` field).
## Repository layout
```
codemap/
SKILL.md # the orchestration instructions Claude reads
README.md # this file
reference/
STANDARDS.md # the scoring rubric, smell taxonomy, severity, subagent prompts
DATA_MODEL.md # the modules.json schema
scripts/ # deterministic, stdlib-only Python
scan.py # LoC + content hash + staleness report
query.py # filter modules (grade/tag/severity/...) → ids/paths/findings
apply_audit.py # merge one subagent's audit result into the state
render.py # modules.json → HTML + MD
assets/
template.html # the interactive map shell (data injected at render time)
```
## Customizing the standard
The rubric, smell taxonomy (tags), severity levels, and the exact subagent prompts live
in `reference/STANDARDS.md` — edit there and every future audit uses the new standard.
Add a tag? Also add it to the `BAD_TAGS` set (and `TAGS_ZH` for a label) in
`assets/template.html` so the map colors and counts it.
## Notes
- `modules.json` is meant to be **committed** with your project — it's the audit history
and what makes diffs/incrementality reviewable.
- The engine is **language-agnostic**: `paths` globs and LoC counting work for any stack;
the audit subagent reads whatever code the globs point at.
+221
View File
@@ -0,0 +1,221 @@
---
name: codemap
description: >-
Generate and incrementally maintain an interactive architecture map plus a
per-module code-quality audit (health scores 0-100, smell findings, lines-of-code,
and a clickable dependency graph) for any codebase. Use when the user wants to
visualize a project's functional modules, audit or score code quality, check
whether the architecture map is stale / up to date, incrementally refresh it after
code changes, or auto-fix the findings of a specific module. Module audits are run
by independent subagents against a fixed rubric. Triggers: "architecture map",
"module map", "audit the codebase", "score the code", "visualize the project",
"is the arch map current", "update the architecture diagram", "fix module X".
---
# Architecture Audit & Map
Builds and maintains three coupled artifacts for a project:
1. **`modules.json`** — the source of truth: every *functional* module (not file) with
its paths, dependencies, coupling, LoC, content hash, score, grade, tags, findings.
2. **`architecture-map.html`** — a self-contained interactive map (layered modules,
dependency highlighting, health coloring, audit-report view).
3. **`architecture-audit.md`** — the written report (per-layer scores, per-module LoC
table, worst offenders, cross-cutting themes).
The HTML and MD are **always regenerated** from `modules.json` by `render.py`. Never
hand-edit them. The state file makes everything **incremental**: a content hash per
module tells us exactly what changed and what needs re-auditing.
## Standard
The scoring rubric, smell taxonomy, severity levels, and the required subagent prompt
are fixed in **`reference/STANDARDS.md`** — read it and follow it verbatim. The state
schema is in **`reference/DATA_MODEL.md`**. Do not improvise scoring or invent tags.
## Conventions
- `SKILL_DIR` = this skill's directory. Scripts are at `SKILL_DIR/scripts/*.py`,
template at `SKILL_DIR/assets/template.html`. Use python3, stdlib only.
- Default artifact locations (override if the user/project prefers): state (the data)
at `<project>/.claude/codemap/modules.json`; the generated outputs at
`<project>/dev_docs/architecture-map.html` and `dev_docs/architecture-audit.md`. The
state lives under `.claude/` (tooling data, kept out of the docs tree); only the two
human-facing artifacts go in `dev_docs/`. Set `meta.htmlPath` / `meta.mdPath` so the
reciprocal links are correct.
- A re-render command (run after any state change):
```
python3 SKILL_DIR/scripts/render.py --state <state> \
--template SKILL_DIR/assets/template.html \
--out-html <htmlPath> --out-md <mdPath>
```
## Targeting modules without reading the whole state (`query.py`)
`modules.json` can be large. To decide what to audit/fix/test, DO NOT read the whole
file — use `scripts/query.py` to select exactly the modules you need and get back just
ids, file globs, or findings. This keeps agent context small.
```
# ids of every C-and-below module (feed a fix/audit loop)
python3 SKILL_DIR/scripts/query.py --state <state> --max-grade C --format ids
# modules carrying a specific problem (compact table)
python3 SKILL_DIR/scripts/query.py --state <state> --tag dual-format
# only the file globs to read for the D/F modules → read just those files
python3 SKILL_DIR/scripts/query.py --state <state> --max-grade D --format paths
# the exact findings to fix for one tag, as text
python3 SKILL_DIR/scripts/query.py --state <state> --tag glue --format findings
# what needs re-auditing
python3 SKILL_DIR/scripts/query.py --state <state> --needs-audit --format ids
```
Filters (AND-combined): `--max-grade {A..F}` (that grade and worse), `--min-score/--max-score`,
`--tag T` (repeatable; ANY, or `--match-all`), `--sev HIGH|MED|LOW`, `--band`, `--coupling`,
`--needs-audit`. Output `--format`: `ids | paths | findings | table | json | count`. Use
`--format paths` to read ONLY the relevant source, and `--format ids` to drive the
per-module subagent loop — never load the full `modules.json` just to pick targets.
## Hard rules
1. **Every module score comes from an independent subagent.** One subagent 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).
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).
HTML/MD are generated. Run `scan.py --write` before every render so LoC/hashes are fresh.
4. **Functional modules, not files.** A module is a capability (a store, a handler
group, a feature folder, a plugin). Map each to a glob set in `paths`. Give every
module a 1-line `desc` ("what it does", shown on click) authored in `meta.lang`
(set `meta.lang` to `"zh"`/`"en"`; it localizes the UI chrome — module names/ids are
never translated).
5. **Four separate, independent subagent roles — never merge two:**
**auditor** (scores quality), **test-author** (writes tests), **fixer** (changes
code), **acceptance/verifier** (proves no regression). A fix is accepted ONLY when an
independent acceptance subagent shows the pre-fix green tests are still green and the
build/typecheck is clean. A fixer may not write/edit its own tests or grade its own
work — that defeats the gate.
---
## Command: `generate` (first build)
Use when no `modules.json` exists yet.
1. **Decompose the project into functional modules.** Explore the tree (parallel Explore
agents for big repos). Identify capabilities and group them into **bands** (visual
layers in data-flow order, e.g. UI → stores → transport → │wire│ → app → handlers →
core → persistence → plugins). For each module record `id, label, band, path, paths
(globs), coupling, deps, desc`. Add `bands`, `spine` (the critical request path), and
`meta` (project, htmlPath, mdPath, spineDesc). Write this to `modules.json` (no scores
yet). Coupling = structural centrality (low/med/high/core); core = the spine hubs.
2. **Compute size:** `python3 scripts/scan.py --root <proj> --state <state> --write`.
It reports every module as `unaudited`.
3. **Audit — one independent subagent per module, in parallel.** For each id in
`needs_audit`, spawn a subagent with the `reference/STANDARDS.md` prompt (filled with
the module's label/paths). Collect each JSON result and apply it:
`python3 scripts/apply_audit.py --state <state> --id <id> --json '<result>' [--rev <git rev>]`.
Batch the audits (dozens of modules → many parallel agents, but stay within sane
concurrency; chunk if needed).
4. **Synthesize `reportThemes`** (47 cross-cutting patterns) from the collected findings
and write them into `modules.json`.
5. **Render:** run the render command. Report the result: avg score, grade spread, worst
offenders, and the two artifact paths.
## Command: `check` (is the map current? — read-only)
Use when the user asks "is the architecture map up to date / still accurate?".
1. `python3 scripts/scan.py --root <proj> --state <state>` (no `--write`).
2. Read the JSON: report `up_to_date`, the **stale** list (code changed since audit),
**unaudited** (new modules with no score), and **empty** (paths match nothing →
likely deleted modules). Do **not** modify anything. Tell the user exactly which
modules drifted and offer to run `update`.
3. Also sanity-check for *new* capabilities not yet in `modules.json` (a quick look at
new top-level dirs / large new files). New modules are model-discovered, not scan-detected.
## Command: `update` (incremental refresh)
Use after code changes, or when `check` found drift. Only re-audits what changed.
1. **Reconcile structure first** (cheap): if modules were added/removed/renamed, edit
`modules.json` (add new module entries with `paths`; drop `empty` ones; fix globs).
2. `python3 scripts/scan.py --root <proj> --state <state> --write` → get `needs_audit`
(= stale + unaudited).
3. **Re-audit only those modules**, each with its own independent subagent (same
protocol as `generate` step 3). Apply each via `apply_audit.py`. Fresh modules keep
their cached audit untouched — that is the whole point of the content hash.
4. **Refresh `reportThemes`** if the changes are material (otherwise keep them).
5. **Render.** Summarize what changed: which modules were re-scored and how their score moved.
## Command: `test <module>` (generate tests)
A **test-author subagent** generates tests for a module — independently of fixing. This
is also the prerequisite for a safe `fix` (it builds the regression net). Two modes:
- **characterization** (default before a fix): lock the module's CURRENT observable
behavior so a later change can't silently alter it. Assert "same as today", not
"correct".
- **coverage**: add missing unit tests for the module's public surface and the behaviors
named in its `findings`.
Steps:
1. **Detect the repo's test framework + location** (pytest / jest / vitest / go test / …)
from existing tests near the module; match their style and placement. Do NOT invent a
new framework or harness.
2. **One test-author subagent** writes tests against the module's `paths`, runs them, and
iterates until green on the CURRENT (unmodified) code. It reports: files added, what
behavior is now locked, and a coverage note. If a test only passes by asserting a known
bug, it must FLAG the bug, not bake it in as desired behavior.
3. **Tests are real source** — they stay in the tree (they are the regression net). Record
their globs in the module's `tests` field in `modules.json`. Re-run `scan.py --write`
and `render.py` (test LoC is tracked but excluded from the module's own audit scope).
Keep test-author distinct from fixer and auditor.
## Command: `fix <module-or-finding>` (auto-fix, regression-gated)
Use when the user says "fix the findings in module X" / "auto-fix the worst offenders".
A fix is **only accepted if an independent acceptance subagent proves no regression.**
Four separate subagents (hard rule 5): test-author → fixer → acceptance → auditor.
1. **Scope (via `query.py`).** Resolve the target set with `query.py` instead of reading
the whole state — e.g. `--max-grade C --tag dual-format --format ids` for "all C-and-
below dual-format modules", then `--format findings` for just the findings to fix and
`--format paths` for just the files to read. Confirm with the user before risky fixes
(duplication merges, dual-format removal touching a protocol, deleting "dead" code —
first verify it is truly unused).
2. **Baseline (test-author subagent).** Ensure the module has tests that lock its CURRENT
behavior; if coverage is thin, run `test <module>` (characterization mode) first. Run
the module's tests + the narrowest build/typecheck on the UNMODIFIED code and record
the **green baseline** (which tests pass, build/type status, key outputs). If you can't
get a green baseline, STOP and tell the user — auto-fixing without a behavioral net is
not safe.
3. **Fix (fixer subagent, `isolation: "worktree"`).** Give it the module's paths, findings,
and `reference/STANDARDS.md` rules; implement the fix and preserve behavior. The fixer
**must not edit tests** (no moving the goalposts) and must not touch files outside its
paths without flagging.
4. **Acceptance gate (independent verifier subagent — NOT the fixer).** Re-run the SAME
baseline tests + build/typecheck on the fixed code. Return
`{pass: bool, regressions: [...], evidence: "..."}`. **PASS only if every
baseline-green test is still green and there are no new build/type errors.** On FAIL:
report the regression with evidence and revert / hand back to the fixer — do not accept.
5. **Re-audit (auditor subagent, independent).** Only after PASS: re-score the module →
`scan.py --write` → `apply_audit.py` → render. Show **before/after score AND the
acceptance evidence** (tests run, all green, build clean).
6. **Never auto-commit unless asked.** Report honestly — a fix that fails the gate is
reported as failed, not merged. Record the outcome in the module's `lastFix` field.
---
## Notes
- **Coupling vs score are independent** (structural vs quality) — see DATA_MODEL.md. The
map can color by either (toggle in the header).
- **Big repos:** parallelize decomposition (Explore) and auditing (one agent per module).
Chunk audits if there are many dozens of modules.
- **Determinism:** same `modules.json` → identical HTML/MD. Commit `modules.json` so the
audit history and incremental diffs are reviewable.
- **Languages:** the engine is language-agnostic — `paths` globs and LoC counting work
for any stack; the subagent reads whatever code the globs point at.
+473
View File
@@ -0,0 +1,473 @@
<!DOCTYPE html>
<!--
Functional Architecture Map (interactive). GENERATED from modules.json by the codemap skill.
Do not hand-edit — edit modules.json and re-run render.py.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Functional Architecture Map</title>
<style>
:root{
--bg:#131517; --bg2:#0e1012; --panel:#1b1e21; --panel2:#202428;
--ink:#e8e6e3; --muted:#9aa1a8; --faint:#6b7280;
--border:#2a2e33; --border2:#363b41;
--accent:#f59e0b; --accent-dim:#b4730e; --accent-soft:rgba(245,158,11,.14);
--in:#7c8794; --out:#f59e0b;
--c-low:#525a62; --c-med:#7e8893; --c-high:#c9cdd2; --c-core:#f59e0b;
--mono:"SF Mono",ui-monospace,"Cascadia Code","JetBrains Mono",Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);
color:var(--ink); font-family:var(--sans); -webkit-font-smoothing:antialiased; overflow:hidden;}
.app{display:grid; grid-template-columns:1fr 348px; grid-template-rows:auto 1fr; height:100vh}
header{grid-column:1/3; display:flex; align-items:center; gap:16px; padding:12px 20px;
border-bottom:1px solid var(--border); background:var(--bg);}
header .mark{width:22px;height:22px;border-radius:6px;flex:0 0 auto;background:var(--accent);}
header h1{font-size:14px;font-weight:600;margin:0;letter-spacing:.1px;white-space:nowrap}
header h1 span{color:var(--muted);font-weight:400}
header .sub{font-size:11.5px;color:var(--faint);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
header .spacer{flex:1}
.search{display:flex;align-items:center;gap:8px;background:var(--panel);
border:1px solid var(--border);border-radius:8px;padding:7px 11px;width:230px;}
.search:focus-within{border-color:var(--border2)}
.search input{background:none;border:none;outline:none;color:var(--ink);font-family:var(--sans);font-size:12.5px;width:100%;}
.search svg{flex:0 0 auto;opacity:.6}
.btn{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);
border:1px solid var(--border);border-radius:8px;padding:7px 12px;cursor:pointer;transition:.12s;}
.btn:hover{color:var(--ink);border-color:var(--border2);background:var(--panel2)}
.btn.active{color:#0e0f11;background:var(--accent);border-color:var(--accent)}
.filt{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);
border:1px solid var(--border);border-radius:8px;padding:7px 8px;cursor:pointer;max-width:150px}
.filt:hover{color:var(--ink);border-color:var(--border2);background:var(--panel2)}
.filt.on{color:#1a1a1a;background:var(--accent);border-color:var(--accent)}
.fcount{font-family:var(--mono);font-size:10.5px;color:var(--accent);white-space:nowrap;min-width:52px}
.board-wrap{position:relative;overflow:auto;background:var(--bg2)}
.board{position:relative;min-width:min-content;padding:26px 30px 60px}
svg.edges{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;overflow:visible}
.band{position:relative;z-index:2;margin-bottom:13px}
.band-head{display:flex;align-items:baseline;gap:10px;margin:0 2px 9px;position:sticky;left:0;}
.band-head .t{font-size:11px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--ink)}
.band-head .d{font-size:11px;color:var(--faint)}
.band-head .num{font-size:9.5px;color:var(--faint);border:1px solid var(--border);border-radius:20px;padding:1px 7px;font-family:var(--mono)}
.band-head .gloc{color:var(--muted);border-color:transparent;padding-left:0}
.band-body{display:flex;flex-wrap:wrap;gap:7px}
.wire{position:relative;z-index:2;display:flex;align-items:center;gap:14px;margin:6px 2px 16px;color:var(--accent);}
.wire .line{flex:1;height:1px;background:linear-gradient(90deg,transparent,var(--accent-dim),transparent)}
.wire .lbl{font-family:var(--mono);font-size:10.5px;letter-spacing:1px;color:var(--accent);white-space:nowrap}
.node{position:relative;background:var(--panel);border:1px solid var(--border);border-radius:9px;
padding:8px 30px 8px 12px;cursor:pointer;min-width:104px;
transition:transform .12s, border-color .12s, background .12s, box-shadow .12s, opacity .15s;user-select:none;}
.node::before{content:"";position:absolute;left:0;top:8px;bottom:8px;width:3px;border-radius:3px;background:var(--c-low);}
.node.c-med::before{background:var(--c-med)} .node.c-high::before{background:var(--c-high)}
.node.c-core::before{background:var(--c-core);box-shadow:0 0 8px var(--accent-soft)}
.node .lab{font-size:12px;font-weight:560;line-height:1.25;letter-spacing:.1px}
.node .meta{font-size:9.5px;color:var(--faint);font-family:var(--mono);margin-top:2px}
.node:hover{border-color:var(--border2);background:var(--panel2);transform:translateY(-1px)}
.node .chip{position:absolute;top:6px;right:7px;font-family:var(--mono);font-size:9px;font-weight:700;
line-height:1;padding:2px 4px;border-radius:4px;color:#0c0d0e;background:var(--h,#6b7280);opacity:0;transition:opacity .15s;}
.board.show-health .node::before{background:var(--h)!important;box-shadow:none}
.board.show-health .node .chip{opacity:1}
.board.show-health.has-sel .node:not(.lit) .chip{opacity:.25}
.board.has-sel .node{opacity:.26;filter:saturate(.7)}
.board.has-sel .node.lit{opacity:1;filter:none}
.node.sel{border-color:var(--accent);background:#241d12;box-shadow:0 0 0 1px var(--accent), 0 6px 22px rgba(245,158,11,.18);transform:translateY(-1px);}
.node.dep{border-color:var(--accent-dim)} .node.dependent{border-color:var(--in)}
aside{background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:20px 20px 40px;}
.empty-hint{color:var(--faint);font-size:12.5px;line-height:1.7} .empty-hint b{color:var(--muted);font-weight:600}
.legend{margin-top:22px} .legend h4,aside h4{font-size:10.5px;text-transform:uppercase;letter-spacing:1px;color:var(--faint);margin:0 0 10px}
.legend .row{display:flex;align-items:center;gap:9px;margin-bottom:7px;font-size:12px;color:var(--muted)}
.swatch{width:16px;height:9px;border-radius:3px;flex:0 0 auto}
.ln{width:26px;height:0;border-top-width:2px;border-top-style:solid;flex:0 0 auto}
.detail .kicker{font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:.5px;text-transform:uppercase}
.detail h2{font-size:18px;margin:5px 0 3px;font-weight:650;line-height:1.2}
.detail .path{font-family:var(--mono);font-size:11px;color:var(--faint);word-break:break-all;margin-bottom:13px}
.pill-row{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:15px}
.pill{font-size:10.5px;border:1px solid var(--border2);border-radius:20px;padding:3px 9px;color:var(--muted);font-family:var(--mono)}
.pill.cpl{border-color:var(--accent-dim);color:var(--accent)}
.detail p.desc{font-size:13px;line-height:1.6;color:var(--ink);margin:0 0 16px}
.reltitle{font-size:10.5px;text-transform:uppercase;letter-spacing:1px;color:var(--faint);margin:16px 0 8px;display:flex;align-items:center;gap:7px}
.reltitle .dotc{width:7px;height:7px;border-radius:50%}
.rel{display:flex;flex-direction:column;gap:4px}
.rel button{text-align:left;font:inherit;font-size:12px;color:var(--muted);background:var(--panel);
border:1px solid var(--border);border-radius:7px;padding:6px 9px;cursor:pointer;transition:.13s;
display:flex;justify-content:space-between;align-items:center;gap:8px;}
.rel button:hover{color:var(--ink);border-color:var(--accent-dim);background:var(--panel2)}
.rel button .bnd{font-size:9px;font-family:var(--mono);color:var(--faint)}
.rel .none{font-size:11.5px;color:var(--faint);font-style:italic;padding:2px}
.clearbtn{margin-top:18px;width:100%}
.scorebox{display:flex;align-items:center;gap:12px;margin:2px 0 13px}
.scorebox .big{font-size:34px;font-weight:740;line-height:1;font-family:var(--mono)}
.scorebox .gr{font-size:13px;font-weight:700;border:1.5px solid;border-radius:7px;padding:3px 9px}
.scorebox .bar{flex:1;height:7px;border-radius:5px;background:#23272c;overflow:hidden}
.scorebox .bar i{display:block;height:100%;border-radius:5px}
.findings{display:flex;flex-direction:column;gap:7px;margin-top:4px}
.finding{border:1px solid var(--border);border-left-width:3px;border-radius:7px;padding:7px 9px;background:#16191c}
.finding .top{display:flex;align-items:center;gap:7px;margin-bottom:3px}
.finding .sev{font-family:var(--mono);font-size:8.5px;font-weight:700;padding:1px 5px;border-radius:4px;letter-spacing:.4px}
.finding .loc{font-family:var(--mono);font-size:10px;color:var(--muted);word-break:break-all}
.finding .txt{font-size:11.5px;line-height:1.5;color:#cfd3d8}
.sev-HIGH{background:#3a1714;color:#f0857a} .finding.sev-HIGH{border-left-color:#e0524b}
.sev-MED{background:#3a2c12;color:#e7b35e} .finding.sev-MED{border-left-color:#d9a441}
.sev-LOW{background:#23282d;color:#9aa1a8} .finding.sev-LOW{border-left-color:#525a62}
.tagchips{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 13px}
.tg{font-size:9.5px;font-family:var(--mono);border-radius:5px;padding:2px 7px;background:#23282d;color:#aeb4bb;border:1px solid var(--border)}
.tg.bad{background:#2c1a17;color:#e88;border-color:#4a2420} .tg.ok{background:#1d2420;color:#8a9;border-color:#2a352e}
.report .stat-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;margin:14px 0 6px}
.report .stat{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:9px 6px;text-align:center}
.report .stat .n{font-family:var(--mono);font-size:18px;font-weight:740;line-height:1}
.report .stat .l{font-size:8.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.5px;margin-top:3px}
.report h3{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--faint);margin:20px 0 8px}
.report .theme{font-size:12px;line-height:1.6;color:#cfd3d8;margin:0 0 9px;padding-left:11px;border-left:2px solid var(--accent-dim)}
.report .theme b{color:#fff;font-weight:600}
.grade-A{color:#76b39a;border-color:#3a5249} .grade-B{color:#b9c0c7;border-color:var(--border2)}
.grade-C{color:#d9a441;border-color:#5a4720} .grade-D{color:#e08a4a;border-color:#5a3a20} .grade-F{color:#e0524b;border-color:#5a2420}
.scrollnote{position:absolute;right:14px;bottom:12px;z-index:5;font-size:10.5px;color:var(--faint);font-family:var(--mono);pointer-events:none;background:#0e1012aa;padding:3px 8px;border-radius:6px;border:1px solid var(--border)}
::-webkit-scrollbar{width:11px;height:11px}
::-webkit-scrollbar-thumb{background:#2c3137;border-radius:6px;border:3px solid var(--bg2)}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<div class="app">
<header>
<div class="mark"></div>
<div>
<h1 id="projTitle">Functional Architecture Map</h1>
<div class="sub" id="subLine"></div>
</div>
<div class="spacer"></div>
<div class="search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="search" placeholder="Find a module…" autocomplete="off"/>
</div>
<select class="filt" id="gradeFilter" title="Show only modules at/below this grade">
<option value="">All grades</option>
<option value="90">≤ B (score &lt;90)</option>
<option value="75">≤ C (&lt;75)</option>
<option value="60">≤ D (&lt;60)</option>
<option value="40">F&nbsp;&nbsp;(&lt;40)</option>
</select>
<select class="filt" id="tagFilter" title="Show only modules with this issue"><option value="">Any issue</option></select>
<span class="fcount" id="fcount"></span>
<button class="btn" id="reportBtn">Audit report</button>
<button class="btn" id="healthBtn">Color: coupling</button>
<button class="btn" id="spineBtn">Data-flow spine</button>
</header>
<div class="board-wrap" id="boardWrap">
<div class="board" id="board">
<svg class="edges" id="edges">
<defs>
<marker id="ah-out" markerWidth="9" markerHeight="9" refX="7" refY="4.2" orient="auto"><path d="M0,0 L8,4.2 L0,8.4 Z" fill="var(--out)"/></marker>
<marker id="ah-in" markerWidth="9" markerHeight="9" refX="7" refY="4.2" orient="auto"><path d="M0,0 L8,4.2 L0,8.4 Z" fill="var(--in)"/></marker>
</defs>
</svg>
</div>
<div class="scrollnote">click a module · scroll to pan</div>
</div>
<aside id="aside"><div id="detail"></div></aside>
</div>
<script>
const DATA = __ARCH_DATA__;
const M = DATA.modules || [];
const BANDS = DATA.bands || [];
const SPINE = DATA.spine || [];
const REPORT_THEMES = DATA.reportThemes || [];
const META = DATA.meta || {};
const 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) ---------- */
const LANG = META.lang || "en";
const I18N = {
en:{ mapSuffix:"· Functional Architecture Map", sub:"functional modules · call hierarchy · coupling · quality score · LoC",
report:"report", searchPh:"Find a module…", allGrades:"All grades", anyIssue:"Any issue", scrollNote:"click a module · scroll to pan",
about:"What it does", audit:"Quality audit", clean:"no material issues — clean / well-scoped",
dependsOn:"Depends on / calls →", usedBy:"← Used by (dependents)", none:"— none —", clearSel:"Clear selection",
couplingLbl:"coupling", deps:"dependencies", dependents:"dependents", locUnit:"LoC", coreModules:"Core modules (the spine)",
lgCoupling:"Coupling", lgCore:"core — system spine", lgHigh:"high — many connections", lgMed:"medium", lgLow:"low — leaf / self-contained",
lgEdges:"Edges", lgOut:"depends on / calls →", lgIn:"← used by (dependent)",
introHint:"<b>Click any module</b> to see what it does, its quality score &amp; findings, what it calls (solid amber, downstream) and what depends on it (dashed gray, upstream).<br><br>Each card shows a <b>health score</b> (0100). Toggle the color mode, filter by grade/issue, or open the <b>Audit report</b>.",
criticalPath:"Critical path", spineTitle:"Data-flow spine", path:"Path", healthReport:"Health report", qa:"Quality audit",
worst:"Worst offenders — click to inspect", commonTags:"Most-common smell tags", themes:"Cross-cutting themes", backToMap:"← Back to map", avg:"avg",
filter:"Filter", modulesWord:"modules", allWord:"all", noMatches:"no matches", clearFilters:"Clear filters",
btnReport:"Audit report", btnSpine:"Data-flow spine", colorCoupling:"Color: coupling", colorHealth:"Color: health",
trackedLoc:(l,f)=>`${l} tracked LoC · ${f} files` },
zh:{ mapSuffix:"· 功能架构图", sub:"功能模块 · 调用层级 · 耦合 · 质量评分 · 代码行数",
report:"报告", searchPh:"查找模块…", allGrades:"全部等级", anyIssue:"全部问题", scrollNote:"点击模块 · 滚动平移",
about:"模块简介", audit:"质量审计", clean:"无实质问题 —— 干净、职责单一",
dependsOn:"依赖 / 调用 →", usedBy:"← 被谁依赖", none:"— 无 —", clearSel:"清除选择",
couplingLbl:"耦合", deps:"个依赖", dependents:"个被依赖", locUnit:"行", coreModules:"核心模块(主线)",
lgCoupling:"耦合", lgCore:"core — 系统主线枢纽", lgHigh:"high — 连接很多", lgMed:"medium — 中等", lgLow:"low — 叶子 / 自洽",
lgEdges:"连线", lgOut:"依赖 / 调用 →", lgIn:"← 被依赖",
introHint:"<b>点击任意模块</b>查看它在做什么、质量评分与问题、它调用了谁(实线琥珀=下游)以及谁依赖它(虚线灰=上游)。<br><br>每张卡片都有<b>健康分</b>0–100)。可切换配色模式、按等级或问题筛选,或打开<b>审计报告</b>。",
criticalPath:"关键路径", spineTitle:"数据流主线", path:"路径", healthReport:"健康报告", qa:"质量审计",
worst:"最差模块 —— 点击查看", commonTags:"最常见问题标签", themes:"跨模块共性问题", backToMap:"← 返回地图", avg:"平均",
filter:"筛选", modulesWord:"个模块", allWord:"全部", noMatches:"无匹配", clearFilters:"清除筛选",
btnReport:"审计报告", btnSpine:"数据流主线", colorCoupling:"配色:耦合", colorHealth:"配色:健康度",
trackedLoc:(l,f)=>`${l} 行(已跟踪)· ${f} 个文件` },
};
function tl(k){ const d=I18N[LANG]||I18N.en; return d[k]!=null?d[k]:(I18N.en[k]!=null?I18N.en[k]:k); }
document.documentElement.lang = LANG;
/* smell-tag display labels (the tag id stays English for logic/filtering) */
const TAGS_ZH={ monkeypatch:"猴补丁", fallback:"回退兜底", legacy:"遗留", "dual-format":"双格式",
stub:"占位桩", "fake-output":"伪造输出", bloat:"臃肿", duplication:"重复", glue:"胶水",
"silent-except":"静默吞错", "silent-catch":"静默吞错", "any-escape":"类型逃逸",
"over-fit":"过度特化", "god-component":"上帝组件", placeholder:"占位", clean:"干净" };
function tagLabel(t){ return LANG==="zh" ? (TAGS_ZH[t]||t) : t; }
/* problems POP (saturated red→amber), good RECEDES (pale low-sat green).
The cue is saturation/lightness, not hue — colorblind-friendlier. */
function healthColor(s){
if(s==null) return "#6b7280";
const stops=[[0,[224,78,74]],[42,[226,128,66]],[60,[222,172,52]],
[74,[214,180,72]],[82,[168,180,140]],[100,[150,178,150]]];
s=Math.max(0,Math.min(100,s));
let a=stops[0], b=stops[stops.length-1];
for(let i=0;i<stops.length-1;i++){ if(s>=stops[i][0] && s<=stops[i+1][0]){a=stops[i];b=stops[i+1];break;} }
const t=(s-a[0])/((b[0]-a[0])||1);
const c=a[1].map((v,k)=>Math.round(v+(b[1][k]-v)*t));
return `rgb(${c[0]},${c[1]},${c[2]})`;
}
const byId = Object.fromEntries(M.map(m=>[m.id,m]));
const dependentsOf = id => M.filter(m=>(m.deps||[]).includes(id)).map(m=>m.id);
const board=document.getElementById("board");
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>`:"");
/* localize static header chrome (module names/labels are never translated) */
document.querySelector(".scrollnote").textContent = tl("scrollNote");
document.getElementById("search").placeholder = tl("searchPh");
document.getElementById("reportBtn").textContent = tl("btnReport");
document.getElementById("spineBtn").textContent = tl("btnSpine");
document.getElementById("gradeFilter").options[0].textContent = tl("allGrades");
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>`;
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>`+
`<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";
items.forEach(m=>{
const n=document.createElement("div");
n.className=`node c-${m.coupling||"low"}`; n.dataset.id=m.id;
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.addEventListener("click",e=>{e.stopPropagation();select(m.id)});
body.appendChild(n); cardEl[m.id]=n;
});
band.appendChild(body); board.appendChild(band);
});
function center(el){const r=el.getBoundingClientRect(), br=board.getBoundingClientRect();
return {x:r.left-br.left+r.width/2, y:r.top-br.top+r.height/2, w:r.width, h:r.height};}
function clip(c,from){const dx=from.x-c.x, dy=from.y-c.y, hw=c.w/2+2, hh=c.h/2+2;
if(dx===0&&dy===0) return c;
const s=Math.min(hw/Math.abs(dx||1e-6), hh/Math.abs(dy||1e-6));
return {x:c.x+dx*s, y:c.y+dy*s};}
function edge(aEl,bEl,kind){
const ca=center(aEl), cb=center(bEl), a=clip(ca,cb), b=clip(cb,ca);
const dy=b.y-a.y, k=Math.min(Math.abs(dy)*0.4+30,150);
const p=document.createElementNS("http://www.w3.org/2000/svg","path");
p.setAttribute("d",`M${a.x},${a.y} C${a.x},${a.y+(dy>=0?k:-k)} ${b.x},${b.y-(dy>=0?k:-k)} ${b.x},${b.y}`);
p.setAttribute("fill","none");
p.setAttribute("stroke",kind==="out"?"var(--out)":"var(--in)");
p.setAttribute("stroke-width",kind==="out"?"1.9":"1.5");
p.setAttribute("stroke-opacity",kind==="out"?"0.95":"0.6");
if(kind==="in")p.setAttribute("stroke-dasharray","4 3");
p.setAttribute("marker-end",kind==="out"?"url(#ah-out)":"url(#ah-in)");
svg.appendChild(p);
}
function clearEdges(){[...svg.querySelectorAll("path")].forEach(p=>p.remove());}
let current=null, spineOn=false;
function resetClasses(){board.classList.remove("has-sel");
Object.values(cardEl).forEach(n=>n.classList.remove("sel","dep","dependent","lit"));}
function select(id){
if(current===id){clearSel();return;}
current=id; spineOn=false; document.getElementById("spineBtn").classList.remove("active");
clearEdges(); resetClasses();
const m=byId[id]; const outs=(m.deps||[]); const ins=dependentsOf(id);
board.classList.add("has-sel"); cardEl[id].classList.add("sel","lit");
outs.forEach(d=>{cardEl[d]&&(cardEl[d].classList.add("dep","lit"),edge(cardEl[id],cardEl[d],"out"));});
ins.forEach(d=>{cardEl[d]&&(cardEl[d].classList.add("dependent","lit"),edge(cardEl[d],cardEl[id],"in"));});
renderDetail(m,outs,ins);
cardEl[id].scrollIntoView({block:"nearest",inline:"nearest",behavior:"smooth"});
}
function clearSel(){current=null; clearEdges(); resetClasses(); renderIntro();}
function showSpine(){
current=null; clearEdges(); resetClasses(); board.classList.add("has-sel");
spineOn=true; document.getElementById("spineBtn").classList.add("active");
SPINE.forEach(id=>cardEl[id]&&cardEl[id].classList.add("lit"));
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>
<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>
${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 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("")
: `<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="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>
${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>`:""}
${auditHTML(m)}
<div class="reltitle"><span class="dotc" style="background:var(--out)"></span>${tl('dependsOn')}</div>
<div class="rel">${li(outs)}</div>
<div class="reltitle"><span class="dotc" style="background:var(--in)"></span>${tl('usedBy')}</div>
<div class="rel">${li(ins)}</div>
<button class="btn clearbtn" id="clearBtn">${tl('clearSel')}</button></div>`;
bindGo(); document.getElementById("clearBtn").addEventListener("click",clearSel);
}
function bindGo(){detail.querySelectorAll("[data-go]").forEach(b=>b.addEventListener("click",()=>select(b.dataset.go)));}
function bandTitle(b){return (BANDS.find(x=>x.id===b)||{}).t||b;}
function legendHTML(){
return `<div class="legend"><h4>${tl('lgCoupling')}</h4>
<div class="row"><span class="swatch" style="background:var(--c-core)"></span>${tl('lgCore')}</div>
<div class="row"><span class="swatch" style="background:var(--c-high)"></span>${tl('lgHigh')}</div>
<div class="row"><span class="swatch" style="background:var(--c-med)"></span>${tl('lgMed')}</div>
<div class="row"><span class="swatch" style="background:var(--c-low)"></span>${tl('lgLow')}</div>
<h4 style="margin-top:18px">${tl('lgEdges')}</h4>
<div class="row"><span class="ln" style="border-color:var(--out)"></span>${tl('lgOut')}</div>
<div class="row"><span class="ln" style="border-color:var(--in);border-top-style:dashed"></span>${tl('lgIn')}</div></div>`;
}
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>`:""}
${legendHTML()}</div>`;
bindGo();
}
function renderReport(){
current=null; spineOn=false; clearEdges(); resetClasses();
document.getElementById("spineBtn").classList.remove("active");
const scored=M.filter(m=>m.score!=null);
if(!scored.length){renderIntro();return;}
const avg=Math.round(scored.reduce((a,m)=>a+m.score,0)/scored.length);
const gc={A:0,B:0,C:0,D:0,F:0}; scored.forEach(m=>gc[m.grade]!=null&&gc[m.grade]++);
const worst=[...scored].sort((a,b)=>a.score-b.score).slice(0,10);
const tagCount={}; scored.forEach(m=>(m.tags||[]).forEach(t=>{if(BAD_TAGS.has(t))tagCount[t]=(tagCount[t]||0)+1;}));
const topTags=Object.entries(tagCount).sort((a,b)=>b[1]-a[1]).slice(0,8);
const locLine=META.locLine||(META.tracked_loc?tl('trackedLoc')(Number(META.tracked_loc).toLocaleString(),META.tracked_files||"?"):"");
detail.innerHTML=`<div class="report">
<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>`:""}
<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>
<div class="stat"><div class="n grade-B">${gc.B}</div><div class="l">B</div></div>
<div class="stat"><div class="n grade-C">${gc.C}</div><div class="l">C</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>
<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("")}`:""}
<button class="btn clearbtn" id="toMapBtn">${tl('backToMap')}</button></div>`;
bindGo(); document.getElementById("toMapBtn").addEventListener("click",clearSel);
}
function toggleHealth(){
const on=!board.classList.contains("show-health");
board.classList.toggle("show-health",on);
const b=document.getElementById("healthBtn");
b.classList.toggle("active",on); b.textContent=on?tl('colorHealth'):tl('colorCoupling');
}
/* ---------- filtering: search + grade threshold + issue tag (combined, AND) ---------- */
let filterGrade=null, filterTag=null, searchQuery="";
function filtersActive(){return filterGrade!=null||filterTag!=null||searchQuery!=="";}
function matchesFilter(m){
if(filterGrade!=null && !(m.score!=null && m.score<filterGrade)) return false;
if(filterTag && !(m.tags||[]).includes(filterTag)) return false;
if(searchQuery){const q=searchQuery;
if(!(m.label.toLowerCase().includes(q)||m.id.toLowerCase().includes(q)||(m.desc||"").toLowerCase().includes(q))) return false;}
return true;
}
function runFilter(){
if(!filtersActive()){document.getElementById("fcount").textContent="";clearSel();return;}
current=null; spineOn=false; document.getElementById("spineBtn").classList.remove("active");
clearEdges(); resetClasses(); board.classList.add("has-sel");
document.getElementById("gradeFilter").classList.toggle("on",filterGrade!=null);
document.getElementById("tagFilter").classList.toggle("on",!!filterTag);
let n=0;
M.forEach(m=>{ if(matchesFilter(m)){cardEl[m.id].classList.add("lit");n++;} });
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(" · ");
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>
<button class="btn clearbtn" id="clearFilterBtn">${tl('clearFilters')}</button></div>`;
bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters);
}
function resetFilters(){
filterGrade=null; filterTag=null; searchQuery="";
document.getElementById("gradeFilter").value=""; document.getElementById("gradeFilter").classList.remove("on");
document.getElementById("tagFilter").value=""; document.getElementById("tagFilter").classList.remove("on");
document.getElementById("search").value=""; document.getElementById("fcount").textContent="";
clearSel();
}
/* populate the issue-tag dropdown from the negative tags present, by frequency */
(function(){
const cnt={}; M.forEach(m=>(m.tags||[]).forEach(t=>{if(BAD_TAGS.has(t))cnt[t]=(cnt[t]||0)+1;}));
const sel=document.getElementById("tagFilter");
Object.entries(cnt).sort((a,b)=>b[1]-a[1]).forEach(([t,c])=>{
const o=document.createElement("option"); o.value=t; o.textContent=`${tagLabel(t)} (${c})`; sel.appendChild(o);});
})();
document.getElementById("search").addEventListener("input",e=>{searchQuery=e.target.value.trim().toLowerCase();runFilter();});
document.getElementById("search").addEventListener("keydown",e=>{
if(e.key==="Enter"){const lit=board.querySelector(".node.lit");if(lit)select(lit.dataset.id);}});
document.getElementById("gradeFilter").addEventListener("change",e=>{filterGrade=e.target.value?Number(e.target.value):null;runFilter();});
document.getElementById("tagFilter").addEventListener("change",e=>{filterTag=e.target.value||null;runFilter();});
document.getElementById("healthBtn").addEventListener("click",toggleHealth);
document.getElementById("reportBtn").addEventListener("click",renderReport);
document.getElementById("spineBtn").addEventListener("click",()=>{spineOn?clearSel():showSpine();});
board.addEventListener("click",()=>{filtersActive()?runFilter():clearSel();});
let rt; window.addEventListener("resize",()=>{clearTimeout(rt);rt=setTimeout(()=>{const c=current;if(c){current=null;select(c);}else if(spineOn)showSpine();},120);});
board.classList.add("show-health");
document.getElementById("healthBtn").classList.add("active");
document.getElementById("healthBtn").textContent=tl("colorHealth");
renderIntro();
</script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
# `modules.json` — the state / source of truth
One file per project. The HTML and MD are pure projections of it (regenerated by
`render.py`); never hand-edit the outputs. Edit `modules.json` (or let the scripts and
subagents edit it) and re-render.
```jsonc
{
"meta": {
"project": "My App", // shown in the title (module NAMES are never translated)
"lang": "en", // UI display language: "en"|"zh" (default "en").
// localizes the chrome + report; module `desc`
// should be authored in this language. ids/labels stay.
"subtitle": "short tagline", // optional header sub-line (write in meta.lang)
"generatedAt": "2026-01-01",
"rev": "abc1234", // git rev these artifacts reflect (optional)
"htmlPath": "docs/architecture-map.html", // for reciprocal links
"mdPath": "docs/architecture-audit.md",
"spineDesc": "A user edits … → … → persistence.", // shown on the spine view
"tracked_loc": 184900, // filled by scan.py --write (union, de-duped)
"tracked_files": 980,
"locLine": "≈185k first-party LoC …" // optional override of the report line
},
"excludes": ["__pycache__","/node_modules/", "..."], // optional; sensible defaults if absent
"bands": [ // visual layers, top → bottom (data-flow order)
{"id":"festore","tier":"fe","t":"Frontend · Stores","d":"one store per domain"},
{"id":"wire1","wire":true,"t":"◀ WebSocket · HTTP ▶"}, // a divider, not a band
{"id":"becore","tier":"be","t":"Backend · Core","d":"scene model, protocol"}
],
"spine": ["p_viewport","editorStore","ws_svc","be_main","core_scene","persistence"],
"reportThemes": [
["Dual-format is the most-repeated violation", "snake||camel recurs in N handlers …"],
["Duplication is the dominant theme", "X and Y reimplement …"]
],
"modules": [
{
"id": "core_scene", // stable unique id (used in deps + edges)
"label": "Scene", // shown on the card
"band": "becore", // which band it sits in
"path": "core/scene.py", // human-readable location (shown in detail)
"paths": ["src/core/scene.py"], // globs scan.py counts (root-relative; may use ** wildcards)
"exclude": [], // optional extra excludes for this module
"coupling": "core", // low | med | high | core (manual / structural)
"deps": ["core_object","core_components"], // downstream: what this calls
"desc": "Central data structure …", // 1-line "what it does", shown on click;
// author in meta.lang (zh for this project)
"tests": ["server/.../tests/test_scene.py"], // globs for this module's tests (the
// regression net); NOT counted in the
// module's own audit scope / loc
"lastFix": { // optional: outcome of the most recent `fix`
"at":"2026-06-11","accepted":true,
"ran":"pytest tests/test_scene.py",
"scoreBefore":68,"scoreAfter":84
},
// ---- filled by scan.py --write ----
"loc": 1234,
"contentHash": "ab12…", // hash of current code
// ---- filled by apply_audit.py (one subagent's result) ----
"score": 88, "grade": "B",
"tags": ["legacy"],
"findings": [{"sev":"LOW","loc":"core/scene.py:72","text":"… evidence …"}],
"auditedHash": "ab12…", // contentHash at audit time → staleness check
"auditedAt": "2026-06-10",
"auditedRev": "3837998"
}
]
}
```
## Field ownership
| Field | Written by | When |
|---|---|---|
| `meta.*`, `bands`, `spine`, `coupling`, `deps`, `desc`, `paths`, `label`, `id`, `band` | **model** (decomposition) | `generate`, and when structure changes |
| `loc`, `contentHash`, `meta.tracked_*` | `scan.py --write` | every scan |
| `score`, `grade`, `tags`, `findings`, `auditedHash/At/Rev` | `apply_audit.py` (from an **auditor subagent**) | each (re)audit |
| `tests` | **test-author subagent** | `test`, and the baseline step of `fix` |
| `lastFix` | **model** (after the acceptance gate) | each `fix` |
| `reportThemes` | **model** (synthesis) | after a full/partial audit |
Test files are **excluded from a module's audit scope** by default (so editing a test
does not mark the module stale and test LoC does not inflate the production-code score).
The `tests` field records them separately as the regression net.
## Staleness
A module is **stale** when `contentHash != auditedHash` (its code changed since last
audit), **unaudited** when it has no `score`/`auditedHash`, **empty** when its `paths`
match no files (likely deleted → drop it or fix the globs). `scan.py` reports these;
only stale + unaudited modules need a fresh subagent.
## Coupling vs score
`coupling` (low/med/high/core) is a **structural** property — how central the module is
(degree + role). `score` is a **quality** property — how clean the code is. They are
independent: a `core` module can be clean (A) and a `low` leaf can be broken (F).
+152
View File
@@ -0,0 +1,152 @@
# Audit Standard (canonical)
This is the fixed rubric. Every module audit must follow it verbatim so scores are
comparable across modules, runs, and projects. Do not improvise scoring.
## Scoring rubric (0100) → grade
| Score | Grade | Meaning |
|------:|:-----:|---------|
| 90100 | A | Clean, well-scoped, idiomatic. No material smells. |
| 7589 | B | Minor issues: a documented shim, mild bloat, a localized cast. |
| 6074 | C | Notable hacks/fallbacks, real bloat, or duplication that has a clear owner. |
| 4059 | D | Significant legacy/stubs/duplication, or a dual-format/protocol violation. |
| 039 | F | Broken, fake output, or an unfinished feature wired in as if done. |
Be rigorous and evidence-based, not generous. A module with one HIGH finding rarely
scores above 60; with only LOW findings it usually scores 80+.
## Smell taxonomy (the `tags`)
Use these exact tag strings. `clean` is the only positive tag; the rest are negative
(the map colors them red and counts them in the report).
- `monkeypatch` — runtime mutation of another module / stdlib / vendor; `setattr` on
foreign objects; `sys.modules` / `sys.meta_path` surgery; reassigning store actions.
- `fallback` — "try the real thing, then fake/degrade"; chained `a || b || c` /
`a ?? b` defaults that hide which value is real.
- `silent-except` / `silent-catch``except: pass`, bare `except`, empty `catch {}`
that swallow errors with no log/signal.
- `legacy` — deprecated/back-compat shims, retired vocabulary, dead-but-shipped code,
parallel "old + new" code paths kept side by side.
- `dual-format` — accepting both snake_case and camelCase (or two payload shapes) for
the same field; the classic `display_name || displayName` patch.
- `stub` / `placeholder``NotImplemented`, `TODO: implement`, dead buttons, demo
scripts, hardcoded sample data presented as real.
- `fake-output` — returns random/canned/hardcoded results where real computation is implied.
- `duplication` — logic copy-pasted from a sibling or that an existing shared
abstraction already covers.
- `bloat` / `god-component` — oversized file/function; many responsibilities in one unit.
- `glue` — thin, valueless pass-through / boilerplate forwarding: rows of one-line
wrappers that only forward args to another layer (e.g. dozens of `send({type:...})`
methods), an adapter that copies a payload field-for-field without transforming, a
store/function that only re-exports or delegates to another. The indirection earns
nothing. Distinct from `bloat` (size) and `duplication` (copy-paste): glue is about
forwarding that adds no value.
- `any-escape``as any`, `@ts-ignore`, untyped boundaries used to bypass the type system.
- `over-fit` — hardcoded to one case where a small generalization was expected.
- `clean` — no material issues.
Judgement rules:
- A *documented, bounded* compat shim that deliberately refuses to silently coerce is
`legacy` at most LOW — do not over-penalize disciplined shims.
- A fallback that is a real security control or numeric guard (e.g. identity matrix on
singular input, stripping untrusted shaders) is **not** a smell.
- Native-dependency gating that *raises or returns an error* when a lib is missing is
correct; only `fake-output` if it silently returns fabricated data.
- A `*Placeholder` name is not automatically a stub — read it; it may be a finished
read-only widget.
- A *single* thin delegator, or a genuine boundary normalizer that converts/validates
once, is fine — not `glue`. Flag `glue` only when pass-through wrappers **proliferate**
(many near-identical forwarders that should be collapsed, generated, or replaced by a
generic dispatch) or an adapter forwards with no transformation. Usually MED when it
proliferates, LOW for a one-off.
## Severity (each finding)
- `HIGH` — wrong/dangerous/fake, a protocol or security issue, or a god-file that is a
genuine maintenance hazard.
- `MED` — a real smell a maintainer should fix: a live dual-format patch, an un-migrated
duplicate, an unfinished-but-wired path.
- `LOW` — a documented shim, a cosmetic cast, benign bloat. Worth noting, not urgent.
Always cite `file:line` and quote/paraphrase the offending snippet. Never report a
grep hit as a problem without reading the surrounding code.
## Independent-subagent protocol (REQUIRED)
Every module's score MUST be produced by a separate subagent, never inline in the main
thread, and never reused across modules. One subagent audits one module against the
paths in its state entry. Spawn them in parallel (Explore or general-purpose).
### Subagent prompt template
> You are auditing CODE QUALITY of ONE functional module for an architecture audit.
> Module: **{label}** (`{id}`). Files: {paths}. Project root: {root}.
>
> Read the module's code (grep the markers below, then READ the surrounding code —
> never flag a grep hit you haven't read). Judge it against this rubric:
> {paste the "Scoring rubric", "Smell taxonomy", "Severity" sections above}
>
> Hunt specifically for: monkeypatch / stdlib mutation, fallback chains & silent
> excepts, legacy/deprecated/back-compat shims, dual-format (snake||camel) handling,
> stubs / fake output / unfinished-but-wired code, bloat/god-files, duplication of
> logic that exists elsewhere, thin valueless glue (proliferating pass-through wrappers
> / no-op adapters), and over-fitting. Also state whether the module is appropriately
> generic.
>
> Return ONLY this JSON (no prose):
> {"score": <0-100>, "grade": "<A|B|C|D|F>",
> "tags": ["<from the taxonomy>", ...],
> "findings": [{"sev":"HIGH|MED|LOW","loc":"file:line","text":"concrete issue + evidence"}, ...]}
> If clean, use tags ["clean"] and findings []. Be rigorous, not generous.
Use `schema` on the Agent call to force that JSON shape when available. Then feed each
result to `scripts/apply_audit.py --id <id> --json '<result>'`.
## Test-author protocol (the `test` command + the baseline step of `fix`)
A dedicated **test-author subagent** generates tests for a module. It is separate from
the auditor, fixer, and verifier.
- **Detect, don't invent.** Find the repo's test framework and location from existing
tests near the module (pytest / jest / vitest / go test / …); match their style and
placement. Never introduce a new framework or harness.
- **characterization mode** (default before a fix): capture the module's CURRENT
observable behavior — inputs→outputs, side effects, payload shapes — as assertions of
"same as today", not "correct". Target the public surface; don't pin private internals.
Use snapshot/golden tests only where the repo already does.
- **coverage mode**: cover the public API and the specific behaviors named in the
module's `findings`. Aim for meaningful branches, not line count.
- **Must be GREEN on the current, unmodified code before returning.** If a test you want
to write fails because of a real bug, FLAG it as a finding — do not assert the buggy
output as if it were the desired behavior.
- Tests are real, committed source (the regression net); never delete or weaken them to
move a number.
- Return JSON: `{"framework":"...","files":["..."],"locked":"<behaviors locked>",
"gaps":"<what is still uncovered>","flagged":[{"sev":"...","loc":"...","text":"..."}]}`.
## Acceptance / regression gate (the gate in `fix`)
An **acceptance/verifier subagent**, independent of the fixer, proves a fix introduced no
regression. It does NOT score quality — pass/fail only.
- Re-run the EXACT baseline test set captured before the fix (same commands), plus the
narrowest build/typecheck for the touched area.
- **PASS iff** every test that was green before is green after, AND no new build / type /
lint errors appeared. A baseline-green test that is now failing, errored, skipped,
deleted, or flaky counts as a regression → FAIL (you cannot remove a test to pass).
- New tests the fixer may have added are ignored (and the fixer should not add any).
- Return JSON: `{"pass": <bool>, "ran": "<commands>",
"regressions": [{"test":"...","was":"pass","now":"fail|error|missing","evidence":"..."}],
"evidence": "<short summary>"}`.
- **Gate rule:** no PASS → no re-audit and no rendered score improvement. Report the
failure with evidence; revert or hand back to the fixer.
## Cross-cutting themes
After all modules are scored, the orchestrator (main thread) writes 47
`reportThemes` into `modules.json` — patterns seen across modules (e.g. "dual-format
recurs in N handlers", "duplication between X and Y", "stub backend wired live"). Each
is `[headline, body]`. These are synthesis, not per-module scoring, so the main thread
writes them.
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""apply_audit.py — 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:
{"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.
Accepts the result inline (--json '...') or from a file (--json-file path).
Stdlib only.
"""
import argparse, datetime, json, sys
VALID_GRADES = {"A", "B", "C", "D", "F"}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--state", required=True)
ap.add_argument("--id", required=True, help="module id to update")
ap.add_argument("--json", help="audit result as an inline JSON string")
ap.add_argument("--json-file", help="audit result JSON file")
ap.add_argument("--rev", default="", help="git rev being audited (optional)")
args = ap.parse_args()
if args.json_file:
result = json.load(open(args.json_file, encoding="utf-8"))
elif args.json:
result = json.loads(args.json)
else:
result = json.load(sys.stdin)
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}")
score = int(result["score"])
grade = str(result["grade"]).strip().upper()[:1]
if grade not in VALID_GRADES:
sys.exit(f"invalid grade: {result['grade']}")
if not (0 <= score <= 100):
sys.exit(f"score out of range: {score}")
findings = []
for f in result.get("findings", []):
sev = str(f.get("sev", "LOW")).upper()
if sev not in {"HIGH", "MED", "LOW"}:
sev = "LOW"
findings.append({"sev": sev, "loc": str(f.get("loc", "")),
"text": str(f.get("text", ""))})
mod["score"] = score
mod["grade"] = grade
mod["tags"] = list(result.get("tags", [])) or ["clean"]
mod["findings"] = findings
mod["auditedHash"] = mod.get("contentHash", "")
mod["auditedAt"] = datetime.datetime.now().strftime("%Y-%m-%d")
mod["auditedRev"] = args.rev
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']}")
if __name__ == "__main__":
main()
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""query.py — token-cheap module selector over modules.json.
An agent uses this to find exactly the modules it must act on WITHOUT reading the
whole state file. Filter by grade/score level, by smell tag, by finding severity,
by band/coupling, or by staleness; emit just ids, paths, a compact table, the
findings, or filtered JSON.
Examples:
# all C-and-below modules → just their ids (pipe into a fix loop)
python query.py --state s.json --max-grade C --format ids
# modules with a dual-format problem, worst first, as a table
python query.py --state s.json --tag dual-format
# the file globs to read for every module that has a HIGH finding
python query.py --state s.json --sev HIGH --format paths
# the actual findings to fix for one tag, as text
python query.py --state s.json --tag glue --format findings
# what needs re-auditing (stale or never scored)
python query.py --state s.json --needs-audit --format ids
Filters combine with AND. --tag may be repeated (ANY by default, --match-all for AND).
Stdlib only.
"""
import argparse, json, sys
# include a module if its score is strictly below this bound (grade and worse).
GRADE_BOUND = {"A": 101, "B": 90, "C": 75, "D": 60, "F": 40}
def main():
ap = argparse.ArgumentParser(description="filter modules.json for agents")
ap.add_argument("--state", required=True)
ap.add_argument("--max-grade", choices=list(GRADE_BOUND),
help="include this grade AND worse (e.g. C → C,D,F)")
ap.add_argument("--min-score", type=int)
ap.add_argument("--max-score", type=int)
ap.add_argument("--tag", action="append", default=[],
help="smell tag; repeatable (ANY unless --match-all)")
ap.add_argument("--match-all", action="store_true", help="require ALL --tag")
ap.add_argument("--sev", choices=["HIGH", "MED", "LOW"],
help="has at least one finding of this severity")
ap.add_argument("--band")
ap.add_argument("--coupling", choices=["low", "med", "high", "core"])
ap.add_argument("--needs-audit", action="store_true",
help="stale (contentHash != auditedHash) or never scored")
ap.add_argument("--sort", choices=["score", "loc", "label", "band"], default="score")
ap.add_argument("--desc", action="store_true", help="sort descending")
ap.add_argument("--limit", type=int)
ap.add_argument("--format", choices=["table", "ids", "paths", "findings", "json", "count"],
default="table")
args = ap.parse_args()
state = json.load(open(args.state, encoding="utf-8"))
mods = state.get("modules", [])
bound = GRADE_BOUND[args.max_grade] if args.max_grade else None
tags = set(args.tag)
def keep(m):
s = m.get("score")
if bound is not None and not (s is not None and s < bound):
return False
if args.min_score is not None and not (s is not None and s >= args.min_score):
return False
if args.max_score is not None and not (s is not None and s <= args.max_score):
return False
if tags:
mt = set(m.get("tags") or [])
if args.match_all and not tags <= mt:
return False
if not args.match_all and not (tags & mt):
return False
if args.sev and not any(f.get("sev") == args.sev for f in (m.get("findings") or [])):
return False
if args.band and m.get("band") != args.band:
return False
if args.coupling and m.get("coupling") != args.coupling:
return False
if args.needs_audit:
stale = (m.get("score") is None or not m.get("auditedHash")
or m.get("auditedHash") != m.get("contentHash"))
if not stale:
return False
return True
sel = [m for m in mods if keep(m)]
key = {"score": lambda m: (m.get("score") if m.get("score") is not None else 999),
"loc": lambda m: m.get("loc") or 0,
"label": lambda m: m.get("label", ""),
"band": lambda m: m.get("band", "")}[args.sort]
sel.sort(key=key, reverse=args.desc)
if args.limit:
sel = sel[:args.limit]
if args.format == "count":
print(len(sel))
elif args.format == "ids":
print(" ".join(m["id"] for m in sel))
elif args.format == "paths":
seen, out = set(), []
for m in sel:
for p in (m.get("paths") or []):
if p not in seen:
seen.add(p); out.append(p)
print("\n".join(out))
elif args.format == "findings":
for m in sel:
for f in (m.get("findings") or []):
print(f"{m['id']}\t{f.get('sev')}\t{f.get('loc','')}\t{f.get('text','')}")
elif args.format == "json":
print(json.dumps(sel, ensure_ascii=False, indent=1))
else: # table
print(f"# {len(sel)} of {len(mods)} modules match", file=sys.stderr)
for m in sel:
s = m.get("score")
sg = f"{s if s is not None else '--'}/{m.get('grade','-')}"
tg = ",".join(t for t in (m.get("tags") or []) if t != "clean") or "-"
print(f"{m['id']:<22} {sg:>7} {str(m.get('loc') or 0):>6} {m.get('band',''):<12} {tg}")
if __name__ == "__main__":
main()
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""render.py — regenerate architecture-map.html + architecture-audit.md from modules.json.
modules.json is the single source of truth. The HTML and MD are pure projections
of it and must never be hand-edited. Run scan.py --write before rendering so LoC /
content hashes are current.
Usage:
python render.py --state modules.json \
--template assets/template.html \
--out-html architecture-map.html --out-md architecture-audit.md
Stdlib only.
"""
import argparse, json, os
def health_color(s):
if s is None: return "#6b7280"
if s < 50: return "#e0524b"
if s < 65: return "#e0804a"
if s < 75: return "#d9a441"
if s < 85: return "#8f969d"
return "#5d6b63"
def band_order(state):
return [b["id"] for b in state.get("bands", []) if not b.get("wire")]
def render_html(state, template):
data = {
"meta": state.get("meta", {}),
"bands": state.get("bands", []),
"spine": state.get("spine", []),
"reportThemes": state.get("reportThemes", []),
"modules": [
{k: m.get(k) for k in (
"id", "label", "band", "path", "desc", "coupling", "deps",
"loc", "score", "grade", "tags", "findings")}
for m in state.get("modules", [])
],
}
blob = json.dumps(data, ensure_ascii=False).replace("</", "<\\/")
return template.replace("__ARCH_DATA__", blob)
def render_md(state):
meta = state.get("meta", {})
mods = [m for m in state.get("modules", []) if m.get("score") is not None]
bands = {b["id"]: b for b in state.get("bands", [])}
out = []
proj = meta.get("project", "Project")
out.append("<!--")
out.append(f" This file: {meta.get('mdPath', 'architecture-audit.md')} (written report)")
out.append(f" Interactive map: {meta.get('htmlPath', 'architecture-map.html')}")
out.append("-->\n")
out.append(f"# {proj} — Functional Module Quality Audit\n")
out.append(f"> **Interactive view:** [`{meta.get('htmlPath','architecture-map.html')}`]"
f"({os.path.basename(meta.get('htmlPath','architecture-map.html'))}) — "
"per-module scores, findings, LoC, and the dependency graph. This file is the written report.\n")
gen = meta.get("generatedAt", "")
loc_line = meta.get("locLine") or (
f"{meta.get('tracked_loc','?')} tracked LoC across {meta.get('tracked_files','?')} files")
out.append(f"**Generated:** {gen} · **Modules:** {len(mods)} · **Size:** {loc_line}\n")
# per-layer averages
out.append("## Health by layer\n")
out.append("| Layer | Modules | Avg score |")
out.append("|---|--:|--:|")
for b in state.get("bands", []):
if b.get("wire"):
continue
grp = [m for m in mods if m["band"] == b["id"]]
if not grp:
continue
avg = round(sum(m["score"] for m in grp) / len(grp))
out.append(f"| {b.get('t', b['id'])} | {len(grp)} | {avg} |")
out.append("")
# per-module LoC + score, grouped by band, sorted by loc desc
out.append("## Per-module lines of code & score\n")
out.append("_LoC is the representative file/folder per module; folder-level modules overlap "
"and are not additive._\n")
for b in state.get("bands", []):
if b.get("wire"):
continue
grp = sorted([m for m in mods if m["band"] == b["id"]],
key=lambda m: -(m.get("loc") or 0))
if not grp:
continue
out.append(f"### {b.get('t', b['id'])}\n")
out.append("| Module | LoC | Score | Tags |")
out.append("|---|--:|:--|:--|")
for m in grp:
tags = ", ".join(t for t in (m.get("tags") or []) if t != "clean") or ""
loc = f"{m.get('loc',0):,}"
out.append(f"| {m['label']} | {loc} | {m['score']} {m['grade']} | {tags} |")
out.append("")
# worst offenders
out.append("## Worst offenders\n")
worst = sorted(mods, key=lambda m: m["score"])[:10]
for m in worst:
fnd = m.get("findings") or []
top = next((f for f in fnd if f["sev"] == "HIGH"), fnd[0] if fnd else None)
ev = f"{top['loc']}: {top['text']}" if top else ""
out.append(f"- **{m['label']} ({m['score']}/{m['grade']})**{ev}")
out.append("")
# all findings, by severity
out.append("## All findings\n")
for sev in ("HIGH", "MED", "LOW"):
rows = [(m, f) for m in mods for f in (m.get("findings") or []) if f["sev"] == sev]
if not rows:
continue
out.append(f"### {sev} ({len(rows)})\n")
for m, f in rows:
out.append(f"- **{m['label']}** · `{f['loc']}` — {f['text']}")
out.append("")
# cross-cutting themes
themes = state.get("reportThemes", [])
if themes:
out.append("## Cross-cutting themes\n")
for h, body in themes:
out.append(f"- **{h}.** {body}")
out.append("")
return "\n".join(out) + "\n"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--state", required=True)
ap.add_argument("--template", required=True)
ap.add_argument("--out-html", required=True)
ap.add_argument("--out-md", required=True)
args = ap.parse_args()
state = json.load(open(args.state, encoding="utf-8"))
template = open(args.template, encoding="utf-8").read()
open(args.out_html, "w", encoding="utf-8").write(render_html(state, template))
open(args.out_md, "w", encoding="utf-8").write(render_md(state))
n = len(state.get("modules", []))
scored = sum(1 for m in state.get("modules", []) if m.get("score") is not None)
print(f"rendered {n} modules ({scored} scored) -> {args.out_html} + {args.out_md}")
if __name__ == "__main__":
main()
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""scan.py — compute per-module LoC + content hash and report staleness.
The architecture state file (modules.json) is the source of truth. Each module
declares `paths` (a list of globs, relative to the project root). This script:
* resolves each module's files, counts lines (LoC) and computes a content hash
(sha256 over sorted "relpath:sha256(bytes)" pairs) that is stable across
checkouts (depends on content, not mtime);
* compares the fresh content hash to `auditedHash` (the hash captured the last
time the module was audited) to classify each module as:
fresh code unchanged since last audit
stale code changed since last audit (needs re-audit)
unaudited never audited (no auditedHash / no score)
empty paths match no files (likely deleted / moved)
* with --write, writes the fresh `loc` and `contentHash` back into the state.
Output (stdout): a JSON report the orchestrator uses to decide what to re-audit.
Stdlib only.
"""
import argparse, glob, hashlib, json, os, sys
DEFAULT_EXCLUDES = [
"__pycache__", "/node_modules/", "/dist/", "/build/", "/.git/",
"/vendor/", ".min.js", ".min.css", "/.venv/", "/venv/",
".pytest", "/coverage/", ".map",
# tests are the regression net, not part of a module's audit scope:
"/tests/", "/test/", "/__tests__/", ".test.", ".spec.", "_test.py",
"conftest.py", ".stories.",
]
def iter_files(root, patterns, excludes):
seen = set()
for pat in patterns:
for p in glob.glob(os.path.join(root, pat), recursive=True):
if not os.path.isfile(p):
continue
rp = os.path.relpath(p, root).replace("\\", "/")
low = "/" + rp.lower()
if any(e in low for e in excludes):
continue
if rp in seen:
continue
seen.add(rp)
yield p, rp
def module_stats(root, module, excludes):
pats = module.get("paths") or []
if isinstance(pats, str):
pats = [pats]
excl = list(excludes) + list(module.get("exclude", []))
loc = 0
parts = []
nfiles = 0
for p, rp in sorted(iter_files(root, pats, excl), key=lambda x: x[1]):
try:
data = open(p, "rb").read()
except OSError:
continue
loc += data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)
parts.append(rp + ":" + hashlib.sha256(data).hexdigest())
nfiles += 1
chash = hashlib.sha256("\n".join(parts).encode()).hexdigest() if parts else ""
return loc, chash, nfiles
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--root", default=".", help="project root")
ap.add_argument("--state", required=True, help="path to modules.json")
ap.add_argument("--write", action="store_true",
help="write fresh loc + contentHash back into the state")
args = ap.parse_args()
state = json.load(open(args.state, encoding="utf-8"))
excludes = state.get("excludes", DEFAULT_EXCLUDES)
root = os.path.abspath(args.root)
buckets = {"fresh": [], "stale": [], "unaudited": [], "empty": []}
union_files = {}
for m in state.get("modules", []):
loc, chash, nfiles = module_stats(root, m, excludes)
m["loc"] = loc
m["contentHash"] = chash
# union for an accurate, non-double-counted repo total
for p, rp in iter_files(root, (m.get("paths") or []),
list(excludes) + list(m.get("exclude", []))):
union_files[rp] = p
if nfiles == 0:
buckets["empty"].append(m["id"])
elif not m.get("auditedHash") or m.get("score") is None:
buckets["unaudited"].append(m["id"])
elif m.get("auditedHash") != chash:
buckets["stale"].append(m["id"])
else:
buckets["fresh"].append(m["id"])
tracked_loc = 0
for rp, p in union_files.items():
try:
data = open(p, "rb").read()
tracked_loc += data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)
except OSError:
pass
if args.write:
meta = state.setdefault("meta", {})
meta["tracked_loc"] = tracked_loc
meta["tracked_files"] = len(union_files)
json.dump(state, open(args.state, "w", encoding="utf-8"),
ensure_ascii=False, indent=1)
needs = buckets["stale"] + buckets["unaudited"]
report = {
"modules": len(state.get("modules", [])),
"tracked_loc": tracked_loc,
"tracked_files": len(union_files),
"needs_audit": needs,
"needs_audit_count": len(needs),
"up_to_date": len(needs) == 0 and not buckets["empty"],
**buckets,
}
print(json.dumps(report, ensure_ascii=False, indent=1))
if __name__ == "__main__":
main()