mirror of
https://github.com/Asixa/codemap-skill.git
synced 2026-08-28 17:01:14 +00:00
Initial commit: codemap skill (architecture map + per-module code-quality audit)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
@@ -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) | `<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 (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 <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.
|
||||
@@ -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`** (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 <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.
|
||||
@@ -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 <90)</option>
|
||||
<option value="75">≤ C (<75)</option>
|
||||
<option value="60">≤ D (<60)</option>
|
||||
<option value="40">F (<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 & 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> (0–100). 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>
|
||||
@@ -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).
|
||||
@@ -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": "<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 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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user