commit 2d7c7d0afd5d2cfb6667e4ec346bee062ac9761a Author: Xingyu Chen Date: Wed Jun 10 18:07:15 2026 -0700 Initial commit: codemap skill (architecture map + per-module code-quality audit) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b908d4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f69acf --- /dev/null +++ b/README.md @@ -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 0–100 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) | `/.claude/codemap/` | +| `architecture-map.html` | self-contained **interactive map** (health coloring, filters, dependency highlighting, audit report) | `/docs/` | +| `architecture-audit.md` | the written **report** (per-layer scores, LoC table, worst offenders, themes) | `/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 (A–F)**, 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 ~/.claude/skills/codemap +``` + +(Windows PowerShell: `git clone $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 ` | generate tests (regression net) for a module | +| `/codemap fix ` | 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. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..211d50f --- /dev/null +++ b/SKILL.md @@ -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 `/.claude/codemap/modules.json`; the generated outputs at + `/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 \ + --template SKILL_DIR/assets/template.html \ + --out-html --out-md + ``` + +## 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 --max-grade C --format ids +# modules carrying a specific problem (compact table) +python3 SKILL_DIR/scripts/query.py --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 --max-grade D --format paths +# the exact findings to fix for one tag, as text +python3 SKILL_DIR/scripts/query.py --state --tag glue --format findings +# what needs re-auditing +python3 SKILL_DIR/scripts/query.py --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 --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 --id --json '' [--rev ]`. + Batch the audits (dozens of modules → many parallel agents, but stay within sane + concurrency; chunk if needed). +4. **Synthesize `reportThemes`** (4–7 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 --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 --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 ` (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 ` (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 ` (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. diff --git a/assets/template.html b/assets/template.html new file mode 100644 index 0000000..aee41ad --- /dev/null +++ b/assets/template.html @@ -0,0 +1,473 @@ + + + + + + +Functional Architecture Map + + + +
+
+
+
+

Functional Architecture Map

+
+
+
+ + + + + + + +
+
+
+ + + + + + +
+
click a module · scroll to pan
+
+ +
+ + + + diff --git a/reference/DATA_MODEL.md b/reference/DATA_MODEL.md new file mode 100644 index 0000000..ae4c180 --- /dev/null +++ b/reference/DATA_MODEL.md @@ -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). diff --git a/reference/STANDARDS.md b/reference/STANDARDS.md new file mode 100644 index 0000000..3e5849e --- /dev/null +++ b/reference/STANDARDS.md @@ -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 (0–100) → grade + +| Score | Grade | Meaning | +|------:|:-----:|---------| +| 90–100 | A | Clean, well-scoped, idiomatic. No material smells. | +| 75–89 | B | Minor issues: a documented shim, mild bloat, a localized cast. | +| 60–74 | C | Notable hacks/fallbacks, real bloat, or duplication that has a clear owner. | +| 40–59 | D | Significant legacy/stubs/duplication, or a dual-format/protocol violation. | +| 0–39 | 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": "", +> "tags": ["", ...], +> "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 --json ''`. + +## 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":"", + "gaps":"","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": , "ran": "", + "regressions": [{"test":"...","was":"pass","now":"fail|error|missing","evidence":"..."}], + "evidence": ""}`. +- **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 4–7 +`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. diff --git a/scripts/apply_audit.py b/scripts/apply_audit.py new file mode 100644 index 0000000..7d8f18d --- /dev/null +++ b/scripts/apply_audit.py @@ -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() diff --git a/scripts/query.py b/scripts/query.py new file mode 100644 index 0000000..f5f7c49 --- /dev/null +++ b/scripts/query.py @@ -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() diff --git a/scripts/render.py b/scripts/render.py new file mode 100644 index 0000000..74bcaa2 --- /dev/null +++ b/scripts/render.py @@ -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("\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() diff --git a/scripts/scan.py b/scripts/scan.py new file mode 100644 index 0000000..5a9ab02 --- /dev/null +++ b/scripts/scan.py @@ -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()