# x-ray

Generates an x-ray.md pre-audit report covering overview, enhanced threat model (protocol-type profiling, git-weighted attack surfaces, temporal risk analysis, composability dependency mapping), invariants, integrations, docs quality, test analysis, and developer/git history. Triggers on 'x-ray', 'audit readiness', 'readiness report', 'pre-audit report', 'prep this protocol', 'protocol prep', 'summarize this protocol'.

- **Kind:** skill
- **Source:** https://github.com/pashov/skills
- **Page:** https://forefy.com/skills/992f9527-dfca-4114-9060-559cf42e5465
- **API (JSON + files):** https://forefy.com/api/skills/992f9527-dfca-4114-9060-559cf42e5465

---

## README.md

# X-Ray

Know your protocol before auditors do.

Built for:

- **Protocol teams** preparing for an audit — fix the obvious so auditors can focus on what matters
- **Security researchers** starting a new engagement — get the full picture in minutes

Not a vulnerability scanner — it's the briefing you read before opening the first file.

## What You Get

One command produces:

| Output | What's Inside |
|--------|--------------|
| `x-ray.md` | Protocol overview, threat model, test gaps, git history, readiness verdict |
| `entry-points.md` | Every state-changing function classified by access level with call chains |
| `invariants.md` | Full invariant map — enforced guards, single-contract invariants, cross-contract trust assumptions, and higher-order economic properties |
| `architecture.svg` | Visual architecture diagram — contracts, actors, trust boundaries |

## Demo

_Part of an X-Ray report generation shown below_

![Running x-ray in terminal](../static/x_ray.gif)

## Usage

```
Install latest https://github.com/pashov/skills/ and run x-ray on the codebase
```

## Tips

- **Start with the verdict.** The report ends with a tier (FORTIFIED → EXPOSED) and 3-5 action items. If you only read one section, read that.
- **Use entry-points.md as your map.** Start with permissionless functions — those are the highest-risk surface.
- **Check the action items.** The final section highlights concrete next steps — whether you're preparing for an audit or starting one.

## SKILL.md

---
name: x-ray
description: "Generates an x-ray.md pre-audit report covering overview, enhanced threat model (protocol-type profiling, git-weighted attack surfaces, temporal risk analysis, composability dependency mapping), invariants, integrations, docs quality, test analysis, and developer/git history. Triggers on 'x-ray', 'audit readiness', 'readiness report', 'pre-audit report', 'prep this protocol', 'protocol prep', 'summarize this protocol'."
---

# X-Ray

Generate an `x-ray/` folder at the project root containing all output files. Pipeline: 3 steps, always sequential.

`$SKILL_DIR` = the directory containing this SKILL.md file. Resolve it from the path you loaded this skill from (e.g. if this file is at `/path/to/x-ray/SKILL.md`, then `$SKILL_DIR` = `/path/to/x-ray`).

## Progress tracking (MANDATORY)

Before doing anything else, call TodoWrite with these 3 todos (all `pending`):

1. `Phase 1: Enumerate & measure codebase`
2. `Phase 2: Read sources, classify entry points, synthesize invariants`
3. `Phase 3: Write x-ray report files`

Transitions (update via TodoWrite — never batch):
- Mark Phase 1 `in_progress` immediately, before running `enumerate.sh`.
- When Step 1's parallel batch returns, in ONE TodoWrite call mark Phase 1 `completed` and Phase 2 `in_progress`.
- When Step 2 (including 2b–2g) finishes, in ONE TodoWrite call mark Phase 2 `completed` and Phase 3 `in_progress`.
- After all Step 3 output files are written, mark Phase 3 `completed`.

Rule: exactly one todo is `in_progress` at any time. Status updates happen the moment a phase starts or ends.

## Step 1: Enumerate & Measure

If the user specifies a path, use it as project root. Otherwise use cwd. If no `.sol` files or `foundry.toml`/`hardhat.config.*` at root, check one level deep.

**Source directory detection**: Auto-detect from `foundry.toml` (`src = "..."`) or `hardhat.config.*`. If no config, try both `src/` and `contracts/`. Read `foundry.toml` first if present.

**Run enumeration** (single Bash call — includes output directory creation):
```bash
mkdir -p [project-root]/x-ray && bash $SKILL_DIR/scripts/enumerate.sh [project-root] [src-dir]
```

**Immediately after**, launch ALL of the following in a single message (parallel):

**0. Version check** (foreground):
- Read the local `VERSION` file from `$SKILL_DIR/VERSION`
- Bash `curl -sf https://raw.githubusercontent.com/pashov/skills/main/x-ray/VERSION`
- If the remote VERSION fetch succeeds and differs from local, print `⚠️ You are not using the latest version. Please upgrade for best security coverage. See https://github.com/pashov/skills`. If it fails, skip silently.

**1. Coverage** (`run_in_background: true`):

For Foundry:
```bash
cd [project-root] && forge coverage 2>&1 || (echo "RETRYING_WITH_IR_MINIMUM" && forge coverage --ir-minimum 2>&1)
```

For Hardhat:
```bash
cd [project-root] && npx hardhat coverage 2>&1
```

If toolchain is not installed (e.g., `forge: command not found`, `npx: command not found`, missing `node_modules/`), the coverage command will fail. This is expected — test *existence* is already captured by enumeration in the step above. Coverage failure does NOT mean tests are absent.

**2. Git security analysis + JSON read** (foreground, single Bash call):
```bash
cd [project-root] && python3 $SKILL_DIR/scripts/analyze_git_security.py --repo . --src-dir [src-dir] --json x-ray/git-security-analysis.json 2>&1 && cat x-ray/git-security-analysis.json
```

The JSON has 7 sections: `repo_shape`, `fix_candidates`, `dangerous_area_changes`, `late_changes`, `forked_deps`, `tech_debt`, `dev_patterns`.

**3. Preload reference files** (2 parallel Read calls — these must be in context before Step 2d/3a):
- `$SKILL_DIR/references/threats.md` — threat profiles, temporal threats, composability threats
- `$SKILL_DIR/references/templates.md` — output template, entry points template, architecture guide

**4. Spec/whitepaper detection** (1 Glob: `**/{whitepaper,spec,design,protocol,architecture,overview,README}*.{pdf,md}` excluding `node_modules/`, `lib/`, `x-ray/`, `test/`). Skip user-facing docs (tutorials, API refs, changelogs, contribution guides). Then apply size-aware handling:

- **Path A (≤5 docs, each ≤300 lines):** Include them as Read calls in Step 2's parallel message. Direct reads — no subagent needed.
- **Path B (>5 docs OR any doc >300 lines):** Launch a single subagent (`model: "sonnet"`) that reads ALL doc files and returns a structured extraction (max 200 lines). Subagent prompt:
  ```
  Read each doc file listed below. Extract ONLY security-relevant information into this format:
  Files: [list of doc file paths]

  Return this exact structure:
  ### Doc-Stated Global Invariants
  [Bullet list of every invariant, constraint, or guarantee the docs claim must hold globally across calls. Treated like NatSpec-stated invariants by Step 2g — routed directly to §2 / §3 / §4 of invariants.md by shape, NOT into §1 (Enforced Guards, which is per-call preconditions only).]
  ### Actor Definitions
  [Each actor/role with stated permissions and trust level]
  ### Trust Assumptions
  [What the protocol assumes about external systems, oracles, admins, users]
  ### Cross-System Flows
  [How value/data moves between contracts or external systems]
  ### Economic Properties
  [Fee structures, reward mechanisms, tokenomics, bounded parameters]
  ### Key Design Decisions
  [Explicit "we chose X over Y because Z" statements]

  Rules: Quote the source doc for each claim. Omit sections with no relevant content. Max 200 lines total.
  ```
  Include this subagent in Step 1's parallel message. Its output feeds Step 3 report writing.

For both paths, extract only: doc-stated global invariants, actor definitions, cross-system flows, trust assumptions, economic properties, key design decisions. Tag all spec-derived claims in the report with `(per spec)` so auditors know what is code-verified vs spec-stated. Doc-stated global invariants feed Step 2g's NatSpec routing step — they route to §2 / §3 / §4 of `invariants.md` by shape, NOT to §1 (Enforced Guards).

ALL calls (coverage, git analysis, reference reads, spec glob) MUST appear in the same message. Proceed to Step 2 without waiting for forge coverage.

## Step 2: Read Source Files + Entry Point Scan (SINGLE message, ALL tool calls parallel)

CRITICAL: Every tool call — Bash, Agent, Read, Grep — MUST be issued in ONE message so they run concurrently. This includes source file reads, the entry point grep scan, and any spec doc detected in Step 1 (they are all independent).

### Scope Filtering
- Skip interfaces: `interfaces/` dirs or filenames `I` + uppercase letter
- Skip vendored libs: Uniswap FullMath/TickMath, OZ copies
- When uncertain, include it but exclude from scope table

### Path A: ≤20 source files (direct reads)
One Read call per file. Do NOT read README, docs, or foundry.toml (already read in Step 1).

**Extract per file:** contract type & inheritance, roles & access control, value-holding state vars, external calls, fund flows, invariant comments, assert/require, backwards-compatibility indicators (see below), **delta writes** (per function: storage variables and the symbolic delta applied — e.g. `Δ(totalSupply) = +shares, Δ(balanceOf[msg.sender]) = +shares` — same-basic-block only, no cross-function inference; inherited helpers like OZ `_mint`/`_burn` may be resolved only when their effect on `balanceOf`/`_totalSupply` is semantically unambiguous), **guard predicates** (every `require`/`assert`/`if-revert` that references a storage variable, quoted verbatim with line number; skip guards that reference only function parameters), **enum/one-shot transitions** (every `require(state == X); ...; state = Y` pair, recorded as `X@Lx → Y@Ly` — include one-shot latches like `require(addr == address(0)); addr = concrete`).

### Path B: >20 source files (parallel subagents)

**Tier 1 — Small files (≤120 lines):** Batch into single Bash `cat` call.

**Tier 2 — Large files (>120 lines):** Group by subsystem. Launch **one subagent per subsystem** (`model: "sonnet"`, up to 5, max ~10 files each). Subagent prompt:
```
Read each file listed below and return a structured summary. Do NOT analyze — just extract facts.
Files: [list of file paths]
For EACH file, return this exact format:
### [filename]
- **Type**: contract | library | abstract
- **Inherits**: [parent contracts]
- **Imports**: [imported libraries/contracts]
- **Roles/Access**: [onlyOwner, role constants, modifiers]
- **State vars (value-holding)**: [mappings/vars that hold balances, collateral, etc.]
- **External calls**: [calls to other contracts, ERC20 transfers, etc.]
- **Fund flows**: [deposit/withdraw/mint/burn/transfer paths]
- **Invariants**: [require/assert statements, NatSpec invariant comments]
- **Delta writes**: For EACH non-view non-pure function, list storage variables that change and the symbolic delta applied. Use format `Δ(var) = +expr` or `Δ(var) = -expr`. Only report pairs where BOTH writes appear in the same function body with no intervening call to an unknown external contract. Do NOT chase writes through inherited/imported functions unless the semantic effect is unambiguous (OZ `_mint` → `balanceOf` + `_totalSupply` is fine; custom internal helpers are NOT — list those deltas only in the internal helper's own entry). Example:
  - `deposit()`: `Δ(totalSupply) = +shares`, `Δ(balanceOf[msg.sender]) = +shares`
  - `borrow()`: `Δ(totalBorrows) = +amount`, `Δ(underlyingBalance) = -amount`
- **Guard predicates**: Every `require`/`assert`/`if-revert` in the file that references a storage variable. Quote verbatim with line number. Skip guards that reference only function parameters.
  - `Vault.sol:206`: `require(_fee <= 10, "fee is capped at 0.1%")`
- **Enum/one-shot transitions**: Every pattern of `require(var == X); ...; var = Y` where `var` is a storage enum, uint, or address. Record as `X@Lx → Y@Ly`. Include one-shot latches like `require(addr == address(0)); addr = concrete`.
- **Key logic**: [1-2 sentences on what the contract does]
- **Function-level access map** (REQUIRED for contracts, skip for libraries):
  List every public/external non-view non-pure function with its access control:
  - `functionName()` — [modifier name, e.g. `onlyRole(OCT_KEEPER)`] or [NONE — permissionless]
  For functions with NO modifier, also list which external calls they make:
  - `functionName()` — NONE — calls `ContractName.method()`
```

### Entry Point Grep Scan (INCLUDED in the same parallel message as source reads)

Launch these two **Bash** calls in the SAME message as the source file reads above — they are independent and can run concurrently. Commands use **only POSIX ERE + POSIX character classes** (no `-P` / PCRE, no GNU-only escapes like `\s` `\w` `\b`), so they work identically on GNU grep (Linux/WSL), BSD grep (macOS default `/usr/bin/grep`, FreeBSD), and ripgrep:

```bash
# 1. Single-line signatures: function name and visibility on same line
grep -rnE 'function[[:space:]]+[[:alnum:]_]+[[:space:]]*\([^)]*\)[[:space:]]+(external|public)' [src-dir]/ --include='*.sol' \
  | grep -v '/interfaces/' | grep -v '/mock/' \
  | grep -Ev '(^|[^[:alnum:]_])(view|pure)([^[:alnum:]_]|$)'
```

```bash
# 2. Multiline signatures: visibility keyword on the closing-paren line (covers 90%+ of multiline cases)
grep -rnE '^[[:space:]]*\)[[:space:]]+(external|public)' [src-dir]/ --include='*.sol' -B5 \
  | grep -v '/interfaces/' | grep -v '/mock/' \
  | grep -Ev '(^|[^[:alnum:]_])(view|pure)([^[:alnum:]_]|$)'
```
Combine results from both. The multiline grep is critical — Solidity functions often split parameters across lines, putting `external`/`public` on the `)` line while `function name(` is lines above. The trailing `grep -Ev '(^|[^[:alnum:]_])(view|pure)([^[:alnum:]_]|$)'` is the POSIX-portable substitute for `\b(view|pure)\b`: it drops any line where `view` or `pure` appears as a standalone identifier (surrounded by non-identifier chars or line boundaries), while preserving lines that merely contain `view_param` / `pure_x` identifier substrings.

**Portability guarantees:**
- `-E`, `-v`, `-r`, `-n`, `-B` → POSIX (2001+) / supported by macOS, FreeBSD, Linux GNU grep, busybox grep, ripgrep
- `[[:space:]]`, `[[:alnum:]_]` → POSIX character classes, supported by all above
- `--include='*.sol'` → GNU + macOS BSD grep + ripgrep. Not supported by busybox grep (niche; Alpine minimal); if the skill ever needs to run there, replace `--include='*.sol' [src]/` with `$(find [src]/ -name '*.sol')` passed as arguments.

ALL tool calls (source reads/Bash/subagents, BOTH grep scans) MUST be in ONE message.

Do NOT read test files or documentation files.

### Step 2b: Entry Point Classification

Using the grep results already returned from Step 2's parallel message, classify ALL entry points. Do NOT rely solely on subagent summaries — subagents extract facts at the contract level and can misattribute which function makes which external call or which function has which modifier.

**Exclude** from entry points: view/pure functions, interface-only declarations, library internal functions (they're downstream calls, not entry points), mock contracts.

**For each result, classify into one of three categories:**

1. **Permissionless** — no access-control modifier AND no internal caller restriction in the function body. You MUST verify the function body before classifying as permissionless. For **Path A (≤20 files)**, the bodies are already in context from Step 2 reads — classify directly without additional Read calls. For **Path B**, batch all candidate body reads into a SINGLE parallel message. Look for ANY of these patterns that restrict the caller:
   - `require(msg.sender == X)` or `if (msg.sender != X) revert ...`
   - `if (msg.sender != X || ...) revert ...` (compound conditions)
   - Calls to internal functions that check `msg.sender`
   A function without a modifier but WITH an internal `msg.sender` check is **role-gated**, not permissionless. Common examples: `acceptOwnership()`, `acceptMsig()`, `confirmX()` — these often have no modifier but restrict the caller to a specific pending address via `if (msg.sender != pendingX) revert`.
2. **Role-gated** — has a role modifier (`onlyRole(X)`, `onlyOwner`, `onlyRouter`, etc.) OR an internal `msg.sender` restriction (via `require`, `if/revert`, or delegated check). Record which role or address is required.
3. **Admin-only** — gated by `DEFAULT_ADMIN_ROLE`, `onlyOwner` pointing to the protocol admin, or similar top-level authority.

Note: `nonReentrant` alone is NOT access control. `initializer`/`reinitializer` are one-time deployment functions — track separately.

**For each entry point, record:**
- Contract name and function name
- Access level (permissionless / role name / admin)
- Caller (User, Keeper, Admin, LP, etc.)
- Parameters with trust level: `(user-controlled)`, `(user-signed)`, `(keeper-provided)`, `(protocol-derived)`
- Call chain: trace downstream calls using subagent summaries + function-level access maps. Format: `→ Contract.fn() → Contract.fn()`
- State modified: which storage vars/mappings change
- Value flow: `in` (tokens deposited), `out` (tokens withdrawn), `none`
- Reentrancy guard: yes/no

This data feeds TWO outputs:
- The **permissionless entry points** list in `x-ray.md` (Section 2) — use the permissionless subset only
- The full **entry-points.md** file (Step 3c) — uses all categories

### Step 2b-flow: Protocol Flow Path Construction

Using the entry point data already collected in Step 2b, construct flow paths for entry-points.md. This is NOT a separate analysis pass — it reorganizes data you already have.

**For each major user-facing entry point** (permissionless and role-gated functions that move value):
1. Identify its `require` statements and state variable checks
2. For each check, find which function WRITES that state variable (already known from the "State modified" field of other entry points)
3. Chain these backwards: destination ← writer of its precondition ← writer of THAT precondition ← ... ← deployment
4. Note non-function preconditions (time passage, market conditions, external state) with `◄──` annotations

**Output**: Simple arrow chains grouped by actor flow. Reference earlier flows instead of repeating. 15-30 lines total. See the Protocol Flow Paths section in the entry-points.md template for exact format.

The grep scan is a **hard gate**: the permissionless entry points section in the report must match this grep-verified list, not the subagent summaries. If there is a conflict, the grep + code reading result wins.

### Step 2c: Backwards-Compatibility Code Detection

While reading source files, watch for code that appears to be remnants of a removed mechanism kept so the remaining codebase does not break. Common signals: empty or trivial function bodies, state variables declared but never meaningfully read or written, comments containing "deprecated" / "legacy" / "backwards compat" / "no longer used", functions that implement an interface but always return a default, and storage variables preserved solely for proxy storage layout compatibility.

After reading ALL source files, cross-reference candidates against these **mandatory verification checks** before classifying anything as backwards-compatibility. **Batch ALL caller-check Grep calls for all candidates into a SINGLE parallel message** — do not verify them one-by-one:

1. **Caller check (REQUIRED)**: Use Grep to confirm the function/variable has NO active callers in the current codebase. If it IS called from active code paths, it is NOT backwards-compatibility — it is the current design, regardless of whether it returns defaults or zeros.
2. **NatSpec/comment check (REQUIRED)**: If the code has NatSpec or inline comments explaining WHY it behaves a certain way (e.g., "simplified for X mode", "by design", "intentionally zero"), this is documented intentional design, NOT backwards-compatibility code. Do not override explicit developer documentation with heuristic pattern matching.
3. **Interface obligation check**: A function that returns default values but exists because an interface requires it AND is actively called is part of the current architecture, not a remnant.

Only classify code as backwards-compatibility when ALL of: (a) no active callers exist, (b) no NatSpec/comments document the behavior as intentional, and (c) git history shows the mechanism it belonged to was removed.

Do not describe backwards-compatibility code as active features in the report. Instead, note them explicitly in Section 1 (see output template) so auditors know which parts of the codebase are retained for compatibility rather than being live functionality. If no backwards-compatibility code survives the verification checks above, omit the subsection entirely.

### Step 2d: Centralization & Pause Coverage Analysis

After reading source files and classifying entry points, perform two analyses that feed into the Actors table, Trust Boundaries, and Key Attack Surfaces (Section 2). These are NOT standalone sections — the results integrate into existing report sections.

**Centralization analysis** — For each privileged role (admin, owner, operator, keeper, service, etc.):
1. List every operational action the role can take (from the function-level access map)
2. For each action, note whether a timelock, multi-sig, or delay exists. Distinguish between role *transfer* delays (e.g., `AccessControlDefaultAdminRules` 1-day delay) and operational *action* delays — they are not the same. A role transfer delay does NOT protect against a compromised holder using instant operational functions.
3. Identify which actions can extract or redirect user funds (e.g., `emergencyWithdraw`, `setTreasury`, `transferFee`)

Integrate into: **Actors table** (Capabilities column should be specific about what's instant vs timelocked), **Trust Boundaries** (describe what each boundary actually protects vs what bypasses it), **Key Attack Surfaces** (frame as "Admin operational powers" or "[Role] compromise" — the attack surface is the role compromise, not individual functions).

**Pause coverage analysis** — For each critical state-changing function:
1. Check whether `whenNotPaused` (or equivalent) is applied
2. Note which functions are pausable vs not
3. If a function that should logically be pausable is not (e.g., a function callable by a bounded role that operates on user funds), integrate this finding into the relevant attack surface for that role. The missing pause is not itself an attack surface — it's a detail that worsens the relevant role's compromise scenario.

**Anti-pattern: Do NOT create a standalone "Centralization Risks" subsection.** Centralization details belong distributed across Actors, Trust Boundaries, Key Attack Surfaces, and Protocol-Type Concerns. A dedicated section duplicates information already present in those sections. The same applies to pause coverage — integrate into the relevant role's attack surface description.

### Step 2e: Protocol Classification

After reading source, classify the protocol following the procedures in `references/threats.md` (type detection + hybrid classification, phase detection, and external call classification — all in one file).

### Step 2f: nSLOC

Use the exact nSLOC TOTAL from the Step 1 enumerate output (no `~` prefix) in the report header and scope table.

### Step 2g: Invariant Synthesis

Using the delta writes, guard predicates, enum/one-shot transitions, and invariant comments extracted in Step 2 (from direct reads in Path A, or subagent output in Path B), systematically walk the following taxonomy to produce invariant candidates. This is a reasoning pass — no new tool calls needed (except the Grep batch in step 2 Pass B — see below).

**Terminology**: A *guard* is a per-call precondition enforced at a single callsite (e.g., `require(amount >= MIN)`). It is NOT a falsifiable invariant — the code itself guarantees it at that callsite. An *invariant* is a property that must hold globally across any sequence of calls (e.g., "every active position ≥ MIN"). Guards feed §1 of `invariants.md` (Enforced Guards reference) only. Invariants that are *lifted* from guards (see step 2 below) or stated in NatSpec feed §2 / §3 / §4.

**NatSpec routing** (run before the structural walk): For each NatSpec `@invariant` tag or inline comment asserting a global property (e.g., *"totalSupply always equals Σ balances"*, *"fee never exceeds MAX_BP"*, *"only one active epoch at a time"*), route DIRECTLY to §2 (or §3/§4 if the property spans contracts or derives from multiple primitives) by category shape (Conservation / Bound / Ratio / StateMachine / Temporal). Source tag: `NatSpec: Contract.sol:LN`. Do NOT place developer-stated global invariants in §1 — §1 is per-call guard predicates only. After routing, still run the structural scans below — they may confirm (On-chain=Yes) or contradict (On-chain=No) the NatSpec claim.

**Walk order** (each step uses the raw extraction data, not prior-step conclusions):

1. **Conservation scan**: For each function, find delta-write pairs where `Δ(A) = +expr` and `Δ(B) = -expr` (or `Δ(B) = +expr` for a mapping counterpart) in the same function body. Each matched pair is a conservation candidate: `A + B = const` or `A == Σ B[key]`.
   - For mapping writes (`mapping[key] += e` paired with `scalar += e`), infer `scalar == Σ mapping[key]`. Verify the pattern holds across ALL functions that write to either variable — if ANY function writes to one without the other, note the gap as "partial conservation" and split into Yes/No rows.
   - For transfer patterns (`mapping[from] -= e`, `mapping[to] += e` with no scalar change), confirm the mapping sum is self-conserving.
   - **Negative conservation** (important): If a function that *ought* to track a flow (e.g., flashloan pull/push, receive/forward) has zero storage Δ, record this as a Conservation-negative finding. Absence of Δ is itself an invariant observation.

2. **Guard extraction and lift** (two passes over each `require`/`assert`/`if-revert`):

   **Pass A — Extract verbatim (Enforced Guards reference)**: Every `require`/`assert`/`if-revert` becomes a `G-N` row in §1 of `invariants.md`. Quote the predicate verbatim with source location. This is a mechanical dump of per-call preconditions — not falsifiable, not fuzzed. Skip guards that only reference function parameters with no storage tie-back AND have no global implication (pure local input validation with no audit value beyond Pass B).

   **Pass B — Lift to global property, then check all write sites**: For each guard, ask: *"does this imply a property that must hold across any sequence of calls, not just at this callsite?"*
   - If **NO** (the guard only constrains a transient parameter that is consumed by the function and does not tie to persistent storage) → leave in §1 only. Do not promote.
   - If **YES** (the guard implies a persistent property — e.g., `require(amount >= MIN)` at deposit implies "every active position ≥ MIN"; `require(_fee <= 10)` at setter implies "fee ∈ [0, 10]") → rewrite the guard as a global property, then locate ALL write sites of the constrained storage variable using Grep on the variable name across scope files. Batch ALL write-site Greps for all lifted guards into a SINGLE parallel message — do not verify one at a time:
     - If **ALL write sites** enforce an equivalent guard → promote to §2 as a Bound invariant with On-chain=**Yes**. Derivation: cite the guard + confirm all write sites.
     - If **ANY write site** writes the variable without an equivalent guard → promote to §2 as a Bound invariant with On-chain=**No**, and cite the unguarded write site(s) as the gap. **This is the high-signal output** — the gap is simultaneously an invariant and a potential bug.

   Include setter-level bounds where a setter writes to a storage variable constrained by its own parameter check. Run the same all-write-site check — if multiple setters write the same variable but only some enforce the bound, the property is On-chain=No.

3. **Ratio scan**: For each storage write of form `A = B * C / D` where B, C, D are storage variables or function-scoped snapshots of storage, record the ratio. Note whether the snapshot is taken before or after other state changes in the same function (ordering matters — e.g., `totalSupply` snapshotted before `_burn` vs after).

4. **State machine / one-shot scan**: For each enum/uint/address variable in `require(var == X); ... var = Y` patterns, record the transition. Distinguish:
   - **One-shot latch**: `require(var == default); var = concrete` with no path back (e.g., `setStrategy`, `setLeverager`).
   - **Togglable flag**: `require(var == false); var = true` but another function flips it back (e.g., `freeze/unFreeze`, `toggleVaultLeverage`). NOT a state machine invariant — skip.
   - **Cyclic state**: `false → true → false` driven by timing/condition (e.g., `ongoingVestingPosition`). Record as a cycle invariant.

5. **Temporal scan**: For each `block.timestamp` or `block.number` comparison involving a storage variable (deadline, lastUpdate, lockPeriod, interval), extract the temporal constraint. Note whether the constraint is checked-then-updated (safe) or updated-then-checked (potential stale read).

6. **Cross-contract scan**: For each external call where the return value is used in arithmetic or a storage write, record what the caller assumes. Then find the callee's write sites for that state. If the callee can change it independently (via another function), the assumption is unvalidated — record as a cross-contract invariant with On-chain=No. ONLY include rows where BOTH sides (caller assumption + callee write sites) are inside the scope files. Do not speculate about out-of-scope contracts.
   - Also include: **setter-vs-invariant mismatches** — where an admin setter writes a storage value without checking that existing invariants still hold (e.g., `setReserveCapacity` without checking against current liquidity). These are cross-contract in the sense that the setter is one contract/function and the invariant is enforced elsewhere.

7. **Economic derivation**: After steps 1-6, check if any combination of single-contract + cross-contract invariants implies a higher-order property. Each economic invariant must cite the specific I-N / X-N IDs it derives from. If the derivation chain has a gap (one of the source invariants is On-chain=No), the economic invariant is also On-chain=No.

**Verification gate** (MANDATORY before including any inferred invariant):
- Conservation: confirm the Δ-pair exists at the cited lines (same function body).
- Guard (Pass A, §1 row): confirm the require/assert/if-revert is verbatim from code.
- Guard lift (Pass B, §2 row): confirm the lifted global property references persistent storage (not just a transient parameter restatement). Confirm all write sites of the constrained variable have been enumerated via Grep, and the On-chain=Yes/No verdict matches the enumeration — if any write site lacks the guard and the row says On-chain=Yes, the row is invalid.
- NatSpec: confirm the `@invariant` tag or comment exists verbatim at the cited location AND asserts a global property (not a per-call note). If it's a per-call note, drop — do not route to §2.
- Ratio: confirm the formula is exact and the snapshot ordering (before/after other writes in the same function) is noted.
- StateMachine: confirm both sides of the edge exist AND confirm no reverse path. If there IS a reverse path, it's a togglable flag — drop.
- Temporal: confirm the comparison involves a storage variable, not just block.timestamp vs parameters.
- Cross-contract: confirm both caller usage AND callee write site exist in scope.
- Economic: confirm all referenced I-N / X-N IDs are themselves verified.
- If you cannot verify → drop the row. "Could not verify" is not a valid row.

**Output**: Invariant candidates feed directly into `invariants.md` (Step 3a). x-ray.md Section 3 gets Enforced Guards (Reference) + top 3-5 inferred (prioritize On-chain=No from Conservation, Cross-Contract, and lifted-guard gaps; include one high-signal Yes row for structural coverage like a ratio or state-machine latch).

## Step 3: Write Output

### Test existence vs. coverage execution (CRITICAL)

**Test presence** is determined by Step 1 enumeration (`test_files`, `test_functions`, `stateless_fuzz`, `foundry_invariant`, `echidna`, `medusa`, `hardhat_fuzz`, `fork`, `certora`, `halmos`, `hevm` counts). These are file-scan results and are ALWAYS reliable regardless of whether the toolchain can compile or run. Multi-signal categories (`echidna`, `medusa`, `certora`, `halmos`) output as `functions:configs` — e.g., `5:1` means 5 test functions + 1 config file detected.

**Coverage metrics** (line/branch %) come from `forge coverage` or `hardhat coverage` which require installed dependencies, successful compilation, and passing tests. Coverage can fail for many reasons unrelated to test quality:
- Dependencies not installed (`npm install` / `forge install` not run)
- Compiler errors (stack-too-deep, version mismatch)
- Test execution failures (missing RPC, fork config)

**Rules:**
1. Use `test_files`/`test_functions` from Step 1 enumeration for ALL test existence claims. Never infer "no tests" from coverage tool failure.
2. If coverage fails but enumeration shows tests exist, report: `"[N] test files with [M] test functions detected; coverage metrics unavailable — [failure reason]"`.
3. In "Gaps" subsection, only flag missing test categories (stateless_fuzz=0, foundry_invariant=0, echidna=0, medusa=0, certora=0, halmos=0, hevm=0, fork=0), never flag "no tests" when enumeration shows they exist. Prioritize gaps by audit impact: missing stateful fuzz and formal verification for math-heavy/financial logic is higher priority than missing fork tests.
4. In git history "Security Observations", never claim "commits without tests" based on coverage failure. The `test_co_change_rate` from git analysis measures file co-modification in commits, not coverage — qualify it as such.
5. If coverage fails, do NOT let the failure cascade into threat model or risk assessments. Test presence (from enumeration) and coverage metrics (from tooling) are independent signals.

Check forge coverage status: include results if done, failure reason if failed, "pending" if still running. Do NOT wait.

### 3a. Write ALL output files (4 parallel Write calls in ONE message)

All output files go into the `x-ray/` directory. Write ALL FOUR files in a SINGLE message so they are created concurrently:

**1. x-ray/architecture.json** — Follow format and rules in the architecture guide section of `references/templates.md` (already loaded in Step 1).

**2. x-ray/x-ray.md** — Follow template in the output template section of `references/templates.md` (already loaded in Step 1). Under 500 lines. No fabrication. Section 3 (Invariants) is a **POINTER ONLY** to `invariants.md` — do NOT include a Guards table, do NOT list top inferred invariants. One blockquote callout with counts (guards / single-contract / cross-contract / economic) and a strong link to the invariants.md file is the entire §3. The invariant catalog lives exclusively in `invariants.md`; duplicating it in x-ray.md was old V2 behavior and is no longer correct.

**Key Attack Surfaces cross-link requirement**: When writing Section 2 Key Attack Surfaces, cross-reference each surface against the `invariants.md` blocks you just produced. If the surface's cited `file:line` falls within the `Location` / `Derivation` / `Caller side` / `Callee side` window of any G-N / I-N / X-N / E-N block, append the matching IDs as bracketed markdown links immediately after the surface title using LOWERCASE slug fragments: `- **Surface name** &nbsp;&#91;[X-4](invariants.md#x-4), [I-17](invariants.md#i-17)&#93; — ...`. Separate each surface bullet with a blank line. Surfaces that are purely access-control or upgrade-ability concerns may be left unlinked — that is a healthy signal, not a gap. Typical hit rate on non-trivial protocols: ≥70% of surfaces link to at least one invariant.

**3. x-ray/entry-points.md** — Using the full entry point data collected in Step 2b and the flow paths from Step 2b-flow, follow the entry points template section of `references/templates.md` (already loaded in Step 1). Start with the Protocol Flow Paths section (arrow chains showing prerequisite sequences for each major entry point), then the access-level detail sections. Factual only — no threat analysis (that stays in x-ray.md). If the protocol has >30 entry points, use compact tables for role-gated and admin sections instead of per-function detail blocks. Only permissionless entry points get the full detail block treatment regardless of count.

**4. x-ray/invariants.md** — Follow the invariant map template section of `references/templates.md` (already loaded in Step 1). Four sections: Enforced Guards (Reference), Inferred (Single-Contract), Inferred (Cross-Contract), Economic. **Use `#### G-N` / `#### I-N` / `#### X-N` / `#### E-N` heading blocks — NOT tables.** Heading anchors (slug `#g-1`, `#i-17`, …) are the target of cross-file markdown links from x-ray.md attack surfaces; inline `<a id>` anchors inside table cells do NOT work cross-file in VS Code. Each `G-N` block must include a `Purpose` line explaining what the guard protects (not just what it checks). Every inferred block MUST cite a concrete Δ-pair, guard-lift + write-sites, edge, temporal predicate, or NatSpec claim — drop blocks that cannot. Every cross-contract block must cite BOTH caller-side assumption AND callee-side write sites (both must be inside the scope files). Every economic block must derive from specific I-N / X-N IDs. No cap on block count. Factual only — no threat analysis.

**Writing Section 2 (Threat & Trust Model)** — Follow the structure in the output template. Use `references/threats.md` for threat profiles, temporal threats, and composability threats content (all in one file, already loaded in Step 1). For hybrids, merge: primary adversary list first, then unique secondary threats (de-duplicate overlapping ones).

**Verification rules** (apply during Section 2 writing):
- **Permissionless entry points**: Use only the grep-verified list from Step 2b. The Step 2b procedure is the source of truth — do not rely on subagent summaries.
- **Security claims**: Before writing any claim that a security check is missing, incomplete, or bypassable, you MUST trace the actual data flow by reading the relevant code. Specifically: (1) identify all write sites for the variable under question (use Grep), (2) confirm your claim holds against those write sites. Subagent summaries are not sufficient. If you cannot verify, qualify the claim with "could not confirm" rather than stating it as fact.

**Section 7 (Git History)**: Integrate `x-ray/git-security-analysis.json` into: Contributors, Review Signals, Hotspots, Security-Relevant Commits (score >= 5), Dangerous Area Evolution, Forked Dependencies, Tech Debt, Cross-Reference Synthesis (2-4 bullets connecting git signals to Sections 2-3).

### Branch scoping (CRITICAL)

The git analysis is scoped to the **current branch only** (HEAD). The `git_branch` field in the JSON meta tells you which branch was analyzed. All git signals (fix candidates, hotspots, dangerous areas, late changes) reflect ONLY commits reachable from HEAD — not other branches.

**Rules:**
1. State the analyzed branch in the report header or git history section: "Analyzed branch: `[branch]` at `[commit]`".
2. When describing fix commits or code changes from git history, always describe them as what the **current branch code** does — not what a fix "changed" if you cannot see the before/after on this branch.
3. Never describe code state from other branches. The source files you read in Step 2 are the current branch's files — git history describes how those files evolved on this branch only.
4. If the repo shape is `squashed_import` (1 commit), there is no meaningful evolution to describe — state this and skip fix/hotspot analysis.

### 3b. Generate & Validate Architecture SVG

```bash
python3 $SKILL_DIR/scripts/generate_svg.py x-ray/architecture.json x-ray/architecture.svg
```

Then follow the rendering, audit checklist, and fix loop in the architecture guide section of `references/templates.md`. Max 3 iterations. Cleanup temp files after (including `x-ray/git-security-analysis.json`).

### 3c. Terminal Verdict

After all files are written and cleanup is done, read the `## X-Ray Verdict` section from the generated `x-ray/x-ray.md` and print it verbatim to the terminal. Do NOT paraphrase, summarize, or rewrite — copy the exact tier, justification, and key observations as they appear in the file.

## Constraints

- Under 500 lines. Protect threat model, invariants, test gaps, git analysis, verdict — compress other sections if needed.
- No fabrication. Say "could not determine" when uncertain.
- Steps 1-3 fully autonomous. No user interaction required.
- Always group contracts by subsystem in scope table.
- Single pass. No partial outputs.
- Never reference audit platforms, contest rules, or bounty program framing — keep the report vendor-neutral.
- If git security analysis script fails, fall back to bash-only git stats. Never block on a missing script.

---

Before doing anything else, print this exactly:

```
██████╗  █████╗ ███████╗██╗  ██╗ ██████╗ ██╗   ██╗     ███████╗██╗  ██╗██╗██╗     ██╗     ███████╗
██╔══██╗██╔══██╗██╔════╝██║  ██║██╔═══██╗██║   ██║     ██╔════╝██║ ██╔╝██║██║     ██║     ██╔════╝
██████╔╝███████║███████╗███████║██║   ██║██║   ██║     ███████╗█████╔╝ ██║██║     ██║     ███████╗
██╔═══╝ ██╔══██║╚════██║██╔══██║██║   ██║╚██╗ ██╔╝     ╚════██║██╔═██╗ ██║██║     ██║     ╚════██║
██║     ██║  ██║███████║██║  ██║╚██████╔╝ ╚████╔╝      ███████║██║  ██╗██║███████╗███████╗███████║
╚═╝     ╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝ ╚═════╝   ╚═══╝       ╚══════╝╚═╝  ╚═╝╚═╝╚══════╝╚══════╝╚══════╝
```

## VERSION

```

```

## references

```

```

## references/templates.md

# Output Template

Write `x-ray.md` using this exact structure. Every line should tell an auditor something useful — write for someone who has 5 minutes to decide where to look first.

```markdown
# X-Ray Report

> [Protocol Name] | [total in-scope nSLOC] nSLOC | [short-hash] (`[branch]`) | [framework] | [DD/MM/YY]

---

## 1. Protocol Overview

**What it does:** [One sentence — the core mechanism.]

- **Users**: [Who interacts and why]
- **Core flow**: [The main user-facing operation in one bullet]
- **Key mechanism**: [AMM type, vault model, oracle design, etc.]
- **Token model**: [What tokens exist and their roles]
- **Admin model**: [Who controls what — owner, multisig, governance]

[No paragraphs. No fluff. Keep vendor-neutral — no audit platform or bounty program framing.]

For a visual overview of the protocol's architecture, see the [architecture diagram](architecture.svg).

### Contracts in Scope

[Group by subsystem — one row per subsystem, not one row per file. List key contracts in the row.]

| Subsystem | Key Contracts | nSLOC | Role |
|-----------|--------------|------:|------|
| [Subsystem] | [Contract1, Contract2, ...] | [total] | [One-line role of this subsystem] |

[Only protocol-authored contracts and libraries. No interfaces, no vendored libs.]

### Backwards-Compatibility Code

[Include this subsection ONLY if backwards-compatibility remnants were identified in Step 2c. Omit entirely if none found.]

[Some protocols remove a mechanism but leave parts of it in the codebase so the remaining code does not break. List any such code here for clarity, so auditors know these are not active features.]

- `[contract:function/variable]` — [what it was part of, why it's retained, and that it is not active functionality]

[Keep entries short. The goal is clarity — preventing auditors from investigating dead code as if it were live.]

### How It Fits Together

[Start with "The core trick:" — one sentence explaining the protocol's fundamental mechanism.]

[Then show 3-5 key flows as annotated code-block diagrams. Each flow gets:]
[1. A ### subheading (no numbering — order is self-evident)]
[2. A code block showing the call chain with tree-style branching (├─ └─)]
[3. Italic annotations on critical steps (where state changes, where callbacks fire, where payment is verified)]
[Keep it to the 3-5 MOST IMPORTANT flows. Skip governance/admin/oracle flows — those are covered in Section 2. This section is about the core user-facing mechanics only.]

[IMPORTANT: Use concrete contract/library names in call chains, NOT interface names. Write `FuturesManager.addCollateral()`, not `ICollateralManager.addCollateral()`. Write `Vault.depositRequest()`, not `IVault.depositRequest()`. Interfaces are how the caller references the target in code, but the auditor needs to know which actual contract executes. The only exception is calls to genuinely external contracts (e.g. `IERC20.safeTransfer()` for a third-party token) where the concrete contract is outside the protocol's codebase.]

[Focus on flows that span multiple contracts — these are where integration bugs hide.]

[No inheritance lists. No import lists. Those details are in the scope table and the diagram. This section answers: "how does the system actually work, end to end?"]
[No bridge/transition sentences at end of section. No filler lines like "This is the flow an auditor traces..."]

---

## 2. Threat & Trust Model

> **Bullet brevity rule (applies to every bullet-heavy subsection in Sections 2, 3, 6):** one tight sentence per bullet — ideally one line, max two. Don't restate what the `file:line` reference already shows. Example of the pattern to follow:
>
> ✅ `**Historical snapshot mutability (balanceOfAt)** — LockManager.getVotingPowerAtBlock:830-842 caps decay by points[low+1].ts - p.ts; any later user checkpoint shifts reconstructed past VP → moving numerator against frozen denominator in _updateReward:663.`
>
> ❌ Not: *"`LockManager.getVotingPowerAtBlock:830-842` caps the decay window by `points[low+1].ts - p.ts`; any subsequent checkpoint written by the same user (via lock / increase / extend / ragequit) changes the cap and therefore the reconstructed voting power for blocks between `p.blk` and `points[low+1].blk`. `_updateReward:663` reads this value to set per-user share against a frozen denominator. Scoring: this is the #1 surface for reward manipulation."*
>
> The bad version repeats what the code reference already shows. The good version says the mechanism once, points to the code, and stops. **Cut words that restate the file's contents. Code refs carry the evidence — prose must not duplicate them.**

### Protocol Threat Profile

> Protocol classified as: **[Primary type]** with **[Secondary type(s)]** characteristics

[1-2 sentences explaining why this classification, based on code signals detected. For hybrids, merge adversary lists: primary first, then unique secondary threats — de-duplicate overlapping ones.]

### Actors & Adversary Model

| Actor | Trust Level | Capabilities |
|-------|-------------|-------------|
| [Role] | [Trusted / Bounded (reason)] | [What they can do] |

[Only named roles from code. No "Anyone". Never use "Semi-trusted" — use "Bounded (reason)" instead.]

[CENTRALIZATION INTEGRATION: The Capabilities column must be specific about what is instant vs timelocked/delayed. If a role has a transfer delay (e.g., AccessControlDefaultAdminRules) but instant operational functions, state both — "1-day transfer delay, but all operational functions instant." If a role's functions are not subject to pausability, note it in the Trust Level or Capabilities column — e.g., "Bounded (can only complete CREATED swaps with constraints). Not subject to whenNotPaused — can operate during pause." This replaces any standalone "Centralization Risks" section — centralization details belong here, in Trust Boundaries, and in Key Attack Surfaces.]

[CELL BREVITY: Capabilities cells are a scannable reference, NOT a capability paragraph. For roles with many powers, summarise as e.g. "11 instant setters + pause (incl. setTimePerBlock which retroactively shifts every balanceOfAt, setTreasuryAddress, reward-token lifecycle). pause does NOT gate withdraw." — enumerate the dangerous ones inline, don't list every setter name. Aim ≤2 lines per cell.]

**Adversary Ranking** (ordered by threat level for this protocol type, adjusted by git evidence):

1. **[Adversary type]** — [1 sentence: WHO they are and WHY they are relevant to this protocol type.]
2. **[Adversary type]** — [...]
3. [...]

[Include only adversary types relevant to this protocol. Typically 3-5. Keep each entry to ONE sentence — the adversary ranking identifies WHO threatens the protocol. The HOW and WHERE details belong in Key Attack Surfaces below. Do NOT describe attack mechanics or cite specific functions here.]

[Do NOT include a "Permissionless Entry Points" list here — that information lives in entry-points.md. Instead, reference: "See [entry-points.md](entry-points.md) for the full permissionless entry point map."]

### Trust Boundaries

[Where trust transitions happen. For each boundary: what's trusted, what damage if compromised, whether timelock/multisig exists.]
[For admin/privileged boundaries, distinguish what the delay mechanism actually protects. E.g., if AccessControlDefaultAdminRules protects role transfer but operational functions are instant, state: "1-day delay protects the admin seat itself, but all operational actions (emergencyWithdraw, setFee, etc.) execute instantly with no delay."]
[If git analysis shows trust boundary code was frequently modified or has fix-scored commits, note: "*Git signal: N modifications, M fix-scored commits — elevated risk.*"]

[**Per-bullet format** (apply brevity rule above): `**Boundary** — protection status + the single worst instant action it leaves open + code ref; max 2 lines. Don't enumerate every function an admin holds — name the most dangerous one and reference the code.`]

### Key Attack Surfaces

[This is the SINGLE authoritative location for attack surface details. Adversary Ranking above identifies WHO; this section describes WHERE to investigate. Do NOT repeat the same risk in both places.]

[Sorted by priority score (protocol-type relevance + git hotspot + fix history + late changes + dangerous area churn). NOT alphabetical.]
[These are **investigation pointers**, not exploit writeups. The auditor decides whether the concern is real, what the severity is, and how to exploit it. Your job is to name the area worth looking at and give enough context for the auditor to know where to start reading.]
[No RISK labels (HIGH/MEDIUM/LOW). No mitigation analysis. No git evidence per surface.]

- **[Surface name]** &nbsp;&#91;[X-N](invariants.md#x-n), [I-N](invariants.md#i-n)&#93; — [one tight sentence: code ref + the concern (what's unusual, fragile, or worth double-checking) + what an auditor should trace to confirm or dismiss it. Aim for 1 line, max 2.]

[Repeat for each surface, **separating bullets with a blank line** for readability. **Hard cap: 2 lines per surface.** Do not write paragraphs. Do not restate what the file:line already shows.]

[**INVARIANT CROSS-LINK RULE**: If the surface's cited code location falls within the derivation window of any guard/invariant in `invariants.md` (the `Location` or `Derivation` field of G-N / I-N / X-N / E-N blocks), append the matching IDs as bracketed markdown links immediately after the surface title. Use lowercase slugs (`invariants.md#x-4`, not `#X-4`) since VS Code and GitHub normalize heading IDs to lowercase. Example: `**`withdrawFromInvestment` unchecked subtraction** &nbsp;&#91;[X-4](invariants.md#x-4)&#93; — ...`. Surfaces that are purely access-control or upgrade-ability concerns (no state-invariant touched) may be left unlinked — that is a healthy signal, not a gap.]

[**DO-NOT-EXPLOIT RULE (critical):** Attack surfaces must describe the *concern area*, not the specific exploit. The auditor's value is building the attack path; yours is finding the area fast. If your bullet contains phrases like "→ attacker drains X", "→ user trapped", "→ inflated share", "reverts with Y trapping Z", "double-counts W", "leads to understated N" — cut them. Replace with "Worth checking...", "Worth tracing...", "Worth confirming...". Name the asymmetry, the divergence, the unusual pattern, the cross-path bookkeeping — then stop. Let the auditor finish the sentence.]

[Example of the pattern to follow:]

[✅ `- **Epoch-end bookkeeping has two removal paths** — _addToEpochEndLocked:102 and _subtractFromEpochEndLocked:117-138 manage the globalEpochEnds arrays vs. the totalLockedAtEpochEnd mapping; _checkpointExpiredLocksCumulative:140 walks only the arrays. Worth checking that array membership stays in sync with mapping contents across all mutation paths.`]

[❌ Not: `- **globalEpochEnds desynced from totalLockedAtEpochEnd** — _subtractFromEpochEndLocked:117-138 pops the array unconditionally; expired mass at shared epochs never lands in accExpiredLocks → understated decay → inflated global bias.` (This spells out the exploit chain — "never lands in", "understated", "inflated" — leaving the auditor nothing to discover.)]

[FRAMING RULE: Attack surfaces should be named after the root threat area, not individual symptoms or specific exploits. E.g., "SERVICE_ROLE compromise" is a surface — missing pausability on completeSwap is a detail that sits inside it. "Admin operational powers without timelock" is a surface — individual setters are evidence within the description. "Reward accounting crosses user/global symmetry" is a surface — specific numerator/denominator manipulations belong to the auditor. Frame surfaces as the actor/capability/pattern that deserves scrutiny, list the relevant functions inside the description, and stop before naming the exploit.]

### Upgrade Architecture Concerns

[Include if any upgradeable contracts exist (UUPS, transparent proxy, beacon). Concrete concerns tied to this codebase's upgrade patterns.]

- **[Concern]** — [one tight sentence: code ref + risk + affected contracts. Max 2 lines.]

[Typical concerns: uninitialized implementations, storage gap consistency, missing timelock on upgrades, blast radius of upgrading shared contracts, placeholder proxy windows.]

### Protocol-Type Concerns

[Based on the protocol classification from Section 2a. ONLY include concerns that are NOT already covered in Key Attack Surfaces above. This section adds protocol-type-specific technical details (math precision, curve invariants, share accounting, etc.) — not the same risks restated from a type perspective.]

**As a [Primary type]:**
- [One tight line: code ref + the technical concern (math precision, curve edge case, share rounding direction). Max 2 lines.]

**As a [Secondary type]** *(if applicable)*:
- [Same format]

[2-3 bullets per type. If a concern is already an attack surface above, skip it here. No generic protocol-type advice — every bullet must cite a specific contract/function. Do NOT restate what the file:line already shows.]

### Temporal Risk Profile

[ONLY include phases that add NEW information not already in Actors, Attack Surfaces, or Upgrade Architecture. Skip any phase whose risks are already fully covered above. Typical: Deployment & Initialization adds value (empty-state, front-running init); Governance & Upgrade usually does NOT (already covered in Actors + Upgrade Architecture). 1-3 bullets per phase, each citing specific code locations.]

**Deployment & Initialization:**
- [One tight line: code ref + risk + mitigation status. Max 2 lines.]

**Market Stress** *(include only if adding new info beyond Attack Surfaces)*:
- [Same format]

**Deprecation** *(include only if V2/migration evidence exists)*:
- [Same format]

[Per-bullet: single sentence. No multi-sentence paragraphs. No "because the code does X, therefore Y, therefore Z" — the code ref carries the evidence.]

### Composability & Dependency Risks

**Dependency Risk Map:**

[Use blockquote format per dependency — one block each, easy to scan:]

> **[External Name]** — via `[contract:function]`
> - Assumes: [key assumptions about return value / behavior]
> - Validates: [what checks exist] or [NONE]
> - Mutability: [Immutable / Upgradeable by X / Governed by X]
> - On failure: [what happens — revert / fallback / fail-open]

[Repeat for each significant external dependency. Well-mitigated ones can be shorter.]

**Token Assumptions** *(unvalidated only)*:
- [Token type]: assumes [assumption not validated in code] — impact if violated: [consequence]

**Shared State Exposure** *(if applicable)*:
- [Which shared resources (pools, oracles), what other protocols share them, whether this protocol's actions could affect others]

[Do NOT add an "Integration Summary" table — the Dependency Risk Map blockquotes above already cover every external dependency. A summary table would duplicate them.]

---

## 3. Invariants

> ### 📋 Full invariant map: **[invariants.md](invariants.md)**
>
> A dedicated reference file contains the complete invariant analysis — do not look here for the catalog.
>
> - **[N] Enforced Guards** (`G-1` … `G-N`) — per-call preconditions with `Check` / `Location` / `Purpose`
> - **[N] Single-Contract Invariants** (`I-1` … `I-N`) — Conservation, Bound, Ratio, StateMachine, Temporal
> - **[N] Cross-Contract Invariants** (`X-1` … `X-N`) — caller/callee pairs that cross scope boundaries
> - **[N] Economic Invariants** (`E-1` … `E-N`) — higher-order properties deriving from `I-N` + `X-N`
>
> Every inferred block cites a concrete Δ-pair, guard-lift + write-sites, state edge, temporal predicate, or NatSpec quote. The **On-chain=No** blocks are the high-signal ones — each is simultaneously an invariant and a potential bug. Attack-surface bullets above cross-link directly into the relevant blocks (e.g. `[X-4]`, `[I-17]`).

[Section 3 is a POINTER, not a catalog. Do NOT duplicate guards or invariants here — they belong exclusively in `invariants.md`. Fill the bracketed counts from the actual invariants.md output.]

---

## 4. Documentation Quality

| Aspect | Status | Notes |
|--------|--------|-------|
| README | [Present/Missing] | [Filename/path if present] |
| NatSpec | [~N annotations] | [Coverage notes] |
| Spec/Whitepaper | [Present/Missing] | [Filename/path if present] |
| Inline Comments | [Sparse/Adequate/Thorough] | [Notable gaps] |

[Skip user-facing docs (tutorials, API refs, marketing). If a spec/whitepaper was ingested in Step 1, tag derived claims with `(per spec)` vs `(per code)` so auditors know what is code-verified vs spec-stated.]

---

## 5. Test Analysis

| Metric | Value | Source |
|--------|-------|--------|
| Test files | [N] | File scan (always reliable) |
| Test functions | [N] | File scan (always reliable) |
| Line coverage | [N% or "Pending" or "Unavailable — [reason]"] | Coverage tool (requires compilation) |
| Branch coverage | [N% or "Pending" or "Unavailable — [reason]"] | Coverage tool (requires compilation) |

[IMPORTANT: Test file/function counts come from file scanning and are always accurate. Coverage metrics require the toolchain to compile and run — if coverage fails (missing deps, compiler error, stack-too-deep), this does NOT mean tests are absent. State this clearly when coverage is unavailable.]

### Test Depth

| Category | Count | Contracts Covered |
|----------|-------|-------------------|
| Unit | [N] | [List or "broad"] |
| Integration | [N] | [List or "none"] |
| Fork | [N] | [List or "none"] |
| Stateless Fuzz | [N] | [List or "none"] |
| Stateful Fuzz (Foundry) | [N] | [List or "none"] |
| Stateful Fuzz (Echidna) | [N] | [List or "none"] |
| Stateful Fuzz (Medusa) | [N] | [List or "none"] |
| Formal Verification (Certora) | [N] | [List or "none"] |
| Formal Verification (Halmos) | [N] | [List or "none"] |
| Formal Verification (HEVM) | [N] | [List or "none"] |

[Only include rows where the count > 0 or where the absence is notable. For categories with 0, consolidate into the Gaps section instead of showing empty rows. Always include Unit, Stateless Fuzz, Stateful Fuzz (at least one tool), and Formal Verification (at least one tool) — even if 0 — since their absence is audit-relevant. Omit Hardhat Fuzz row unless the package.json dependency was detected.]

[Enumeration output format for multi-signal categories: `echidna`, `medusa`, `certora`, `halmos` output as `functions:configs` (e.g., `5:1` = 5 functions + 1 config file). Report the function/spec count in the table. If configs exist but no functions, note: "[tool] config present but no test functions found".]

### Gaps

[Notable testing gaps. Only flag missing test categories — never claim "no tests" when enumeration found test files. Prioritize gaps by audit impact: missing stateful fuzz and formal verification for math-heavy/financial logic is higher priority than missing fork tests.]

---

## 6. Developer & Git History

> Repo shape: [normal_dev / squashed_import] — [one sentence: e.g., "All source arrived in 1 commit (9fb17ba); no development history visible" or "Normal development history with N source-touching commits over N months"]

### Contributors

| Author | Commits | Source Lines (+/-) | % of Source Changes |
|--------|--------:|--------------------|--------------------:|
| [Name] | [N]     | +[N] / -[N]       | [N%]                |

[Compute % from source line additions. Flag single-developer dominance (>90%), ghost contributors (1 commit), or uneven distribution.]

### Review & Process Signals

| Signal | Value | Assessment |
|--------|-------|------------|
| Unique contributors | [N] | [Single-dev / Small team / Larger team] |
| Merge commits | [N] of [total] ([%]) | [Formal review process / No merge commits — likely no peer review] |
| Repo age | [first] → [last] | [Duration] |
| Recent source activity (30d) | [N] commits | [Active / Quiet / Late burst before audit] |
| Test co-change rate | [N%] | [% of source-changing commits that also modify test files — measures co-modification, NOT coverage] |

### File Hotspots

| File | Modifications | Note |
|------|-------------:|------|
| [path] | [N] | [High churn — prioritize review] |

[Top 5-10 most-modified source files. High modification count correlates with higher defect density.]

### Security-Relevant Commits

[Include ONLY if fix_candidates from git security analysis has entries with score >= 5. For squashed-import repos, skip this subsection and note "No development history — fix detection not applicable."]

**Score** = weighted sum of fix-like signals in a commit: message keywords (fix, bug, reentrancy, overflow...), diff patterns (deletes code, changes `require`/`assert`, touches access control or accounting), and change shape (focused = higher). **10+ warrants a manual diff.**

| SHA | Date | Subject | Score | Key Signal |
|-----|------|---------|------:|------------|
| [hash] | [date] | [subject] | [N] | [top reason from scoring] |

### Dangerous Area Evolution

[Include if the repo has normal development history. Shows which security-sensitive code areas changed most.]

| Security Area | Commits | Key Files |
|--------------|--------:|-----------|
| [fund_flows / access_control / oracle_price / liquidation / signatures / state_machines] | [N] | [top 2-3 files] |

[Areas with high commit counts warrant deeper review — frequent changes to security-critical code correlate with higher defect density.]

### Forked Dependencies

[Include if forked_deps.detected_libs contains internalized libraries. Skip if all libs are standard submodules.]

| Library | Path | Upstream | Status | Notes |
|---------|------|----------|--------|-------|
| [name] | [lib/path] | [Uniswap V2 / OpenZeppelin / etc.] | [Submodule / Internalized] | [Pragma mismatch, modifications from upstream, etc.] |

[Internalized libraries with pragma or logic changes from upstream are hidden attack surface — the team may have introduced bugs while adapting code, and upstream security fixes won't auto-propagate.]

### Technical Debt Markers

[Include if tech_debt.total_count > 0. Skip otherwise.]

| File:Line | Type | Text | Author | Date |
|-----------|------|------|--------|------|
| [path:N] | [TODO/FIXME/HACK/XXX] | [comment text] | [blame author] | [date] |

[TODO/FIXME/HACK comments represent known-but-unresolved issues. These are areas where the developer acknowledged incomplete work.]

### Security Observations

[4-8 bullets — each ONE line: `**Lead-in** — short fact + file/commit ref.` No multi-sentence explanations. The signal is the fact + the ref; skip the "why this matters" gloss unless it's genuinely non-obvious.]
- [Single-developer risk if applicable]
- [Missing code review signals if no merge commits]
- [High-churn files that warrant deeper review]
- [Recent rapid changes / last-minute additions before audit]
- [Large unreviewed commits if detected]
- [Fix commits without corresponding test file changes — residual risk (note: this measures file co-modification, not coverage)]
- [Forked dependencies with divergent pragmas or logic]
- [Technical debt in security-critical paths]

Example good line: `**Two-dev concentration** — 0xKaizendev (47 %) + Rozales (29 %) = 76 % of commits.`
Example bad line (too wordy): `**Single-developer dominance**: 0xKaizendev authored 47 % of all commits; combined with Rozales (29 %), 76 % of development came from two people. Review ergonomics for the veRAAC subsystem depend heavily on these two reviewers understanding each other's intent.`

### Cross-Reference Synthesis

[2-4 bullets connecting git history signals to findings from Sections 2-3. One line each: `**Cross-reference** — signal A + signal B → conclusion.` Use arrows (→) to compress cause-and-effect. Don't restate the findings.]
- [e.g., "**VeRAACToken.sol is #1 in BOTH churn AND attack-surface priority** — all top-4 surfaces route through it → highest-leverage review: `_updateReward`, `_getClaimableAmount`, `distributeRewards`, ragequit functions."]
- [e.g., "**`_lockBiasAt:1087` TODO aligns with I-17** — `amount/maxTime` then multiply loses precision; `//bug same as M-09` tag suggests prior-audit carryover."]

---

## X-Ray Verdict

**[TIER]** — [one sentence justification]

[Tier calculation: take the lowest level across Tests, Docs, Access Control (evidence is in Sections 4-5). If Code Hygiene has TODOs in security-critical paths (Section 6), drop one tier. Absence of TODOs does NOT raise the tier.]

[IMPORTANT: Test tier is based on test EXISTENCE from Step 1 file scan counts (test_files, test_functions, stateless_fuzz, etc.), NOT on whether tests pass or fail at runtime. If enumeration found 23 unit test functions, the Tests signal is "unit tests exist" regardless of compilation or runtime failures.]

[Tier thresholds:]
[Tests: EXPOSED=0 test functions found, FRAGILE=unit only, ADEQUATE=unit + fuzz OR invariant, HARDENED=unit + fuzz + invariant, FORTIFIED=+ formal verification]
[Docs: EXPOSED=no NatSpec + no spec, FRAGILE=sparse NatSpec, ADEQUATE=NatSpec present, HARDENED=+ spec/whitepaper, FORTIFIED=+ thorough inline comments]
[Access Control: EXPOSED=unclear roles, FRAGILE=roles exist + no timelock, ADEQUATE=roles + boundaries clear, HARDENED=+ timelock or multisig, FORTIFIED=+ emergency pause]

**Structural facts:**
1. [Verifiable structural fact — e.g., "15K nSLOC across N subsystems", "N upgradeable contracts", "2 developers wrote N% of code"]
2. [...]
3. [...]
[3-5 items. ONLY measurable, verifiable facts from Sections 1-6. No security claims, no speculation about what "could" happen, no bug hypotheses, no attack scenarios. The verdict describes the codebase's structural posture (tests, docs, access control, complexity) — NOT its security. The auditor forms their own security conclusions.]
```
# Entry Point Map Template

Write `entry-points.md` using this structure. This file is a purely structural reference — no threat analysis, no invariants, no git history. It answers: "what can be called, by whom, and what does it touch."

```markdown
# Entry Point Map

> [Protocol Name] | [N] entry points | [N] permissionless | [N] role-gated | [N] admin-only

---

## Protocol Flow Paths

[Order entry points into expected execution flows — the "story" of the protocol from deployment to steady-state operation. Each major user-facing entry point gets a path showing every step that must happen before it becomes callable. This lets auditors immediately see the full prerequisite chain for any function.]

[Group flows by actor. For each flow, trace backwards from the destination function to deployment, listing every function call that must have succeeded first. Use simple arrow chains — no boxes, no diagrams. Annotate non-obvious preconditions with `◄──` comments.]

[Example format:]

### Setup (Owner)

`initReserve()` → `setLeverager()` → `initVault()` → `setLeverageParams()`

### User Flow

`[owner setup above]` → `Lender.deposit()` → `openPosition()`  ◄── liquidity must exist
                                                    ├─→ `withdraw()`
                                                    └─→ `liquidatePosition()`  ◄── position unhealthy

### Maintenance (Keeper)

`[deposit above]` → [rebalanceInterval passes] → [price in range] → `rebalance()`

[Rules for flow paths:]
[- One chain per major destination function. Branch with `├─→` and `└─→` when a function has multiple exit paths.]
[- Reference earlier flows with `[owner setup above]` or `[deposit above]` instead of repeating the chain.]
[- Add `◄──` annotations for preconditions that are NOT function calls (time passage, market conditions, position health, sufficient liquidity).]
[- Keep it factual — trace from require statements and state variable checks back to the functions that write those variables.]
[- This section should be 15-30 lines. It is an index into the detailed sections below, not a replacement.]

---

## Permissionless

[Entry points callable by any address with no effective access restriction. Sorted by value flow: tokens-in first, tokens-out second, no-token-movement last.]

### `Contract.functionName()`

| Aspect | Detail |
|--------|--------|
| Visibility | [external/public], [nonReentrant if present] |
| Caller | [Who actually calls this — User, Anyone, etc.] |
| Parameters | [paramName (user-controlled), paramName (protocol-derived)] |
| Call chain | `→ Contract.fn() → Contract.fn() → ...` |
| State modified | [storage vars/mappings that change] |
| Value flow | [Tokens: sender → Vault / Vault → recipient / None] |
| Reentrancy guard | [yes / no] |

[Repeat for each permissionless entry point]

---

## Role-Gated

[Entry points restricted by a role modifier. Group by role. Within each role, sort by value flow.]

### `OCT_KEEPER`

#### `Contract.functionName()`

| Aspect | Detail |
|--------|--------|
| Visibility | [external], [modifier name] |
| Caller | [Keeper bot / Relayer / etc.] |
| Parameters | [paramName (user-signed), paramName (keeper-provided), paramName (protocol-derived)] |
| Call chain | `→ Contract.fn() → Contract.fn() → ...` |
| State modified | [storage vars/mappings that change] |
| Value flow | [direction] |
| Reentrancy guard | [yes / no] |

[Repeat for each role and function]

---

## Admin-Only

[Entry points restricted to DEFAULT_ADMIN_ROLE or owner. These configure the protocol rather than operate it.]

[For admin functions, use a compact table instead of per-function detail blocks — auditors need to see the full admin surface at a glance:]

| Contract | Function | Parameters | State Modified |
|----------|----------|------------|----------------|
| [Contract] | `functionName()` | [params] | [what changes] |

[Repeat for each admin function]
```

## Rules

- **No overlap with x-ray.md**: Do not include threat analysis, adversary model, invariants, attack surfaces, git history, test analysis, or documentation quality. Those belong in the readiness report.
- **Factual only**: Extract facts from code. Do not speculate about risks or suggest mitigations.
- **Call chains**: Trace the full downstream path from entry point to leaf (token transfer, storage write, or external call). Use `→` notation. Stop at the first external protocol call or token transfer. Use concrete contract/library names, NOT interface names (e.g. `FuturesManager.addCollateral()`, not `ICollateralManager.addCollateral()`). Interfaces describe how the caller references the target in code, but auditors need to know which contract actually executes.
- **Parameter trust**: Mark each parameter as `(user-controlled)`, `(user-signed)`, `(keeper-provided)`, or `(protocol-derived)`. User-controlled = the caller chooses the value freely. User-signed = value comes from a user's off-chain signature. Keeper-provided = the keeper selects the value (e.g., indexPrice from price feed). Protocol-derived = read from on-chain state.
- **Exclude**: view/pure functions, interface-only functions, library internal functions (they're downstream calls, not entry points), mock contracts.
- **Include initializers separately**: If the protocol uses proxy patterns, list `initialize()` functions in a brief "Initialization" section at the end — these are one-time entry points but still attackable during deployment.

# Invariant Map Template

Write `invariants.md` using this structure. This file is a deep structural reference for invariants only — no threat analysis, no git history, no test analysis. It answers: "what must always be true, what enforces it, and what breaks if it doesn't hold."

```markdown
# Invariant Map

> [Protocol Name] | [N] guards | [N] inferred | [N] not enforced on-chain

---

## 1. Enforced Guards (Reference)

Per-call preconditions. Heading IDs below (`G-N`) are anchor targets from x-ray.md attack surfaces.

[NatSpec-stated global invariants do NOT belong here — they route directly to §2/§3/§4 by shape.]

#### G-1
`require(...)` · `Contract.sol:LN` · [one-line purpose — *why* this guard exists / which invariant or trust boundary it enforces, not what it checks]

[Repeat `#### G-N` for every guard. Two lines per guard: (1) H4 heading with ID only — preserves the `#g-1` anchor used by cross-file links from x-ray.md — and (2) a single body line with three ` · `-separated fields: verbatim predicate in backticks, file:line in backticks, purpose prose. Separate guards with a blank line only — no `---` rules.]

---

## 2. Inferred Invariants (Single-Contract)

Inferred invariants are derived from structural analysis of the source code. Each block below cites one of five extraction methods in its `Derivation` field:

- **Δ-pair (delta-pair) analysis** — two or more storage variables in the same function body that change by equal-and-opposite amounts (e.g. `totalSupply += x` paired with `balances[to] += x`), implying a conservation law like `A == Σ B[key]` or `A + B = const`.

- **Guard lift** — a `require` / `if-revert` on a storage variable, promoted from a per-call precondition to a global property by checking that *every* other write site of that variable enforces an equivalent guard. If any write site lacks it, the lifted invariant is On-chain=**No** (and a candidate bug).

- **State-machine edge** — a storage variable that transitions through discrete values via patterns like `require(state == A); state = B`, with no reverse path. Captures one-shot latches (`setStrategy`) and lifecycle machines (`Pending → Claimable → Claimed`).

- **Temporal predicate** — a check tied to `block.timestamp`, `block.number`, or a stored duration/deadline variable (e.g. `require(block.timestamp < deadline)`).

- **NatSpec-stated global property** — a developer-asserted invariant in a NatSpec `@invariant` tag or inline comment (e.g. *"totalSupply always equals Σ balances"*). Routed directly to this section and then confirmed or contradicted by the structural scan.

Each block is classified into one of five **categories** by shape: `Conservation` · `Bound` · `Ratio` · `StateMachine` · `Temporal`. Category definitions at the end of §2.

---

#### I-1

`Category` · On-chain: **Yes/No**

> [the global property claim — prose or code — in a blockquote for visual emphasis]

**Derivation** — [Δ-pair / guard-lift + write-sites / edge / temporal / NatSpec citation]

**If violated** — [consequence]

---

[Repeat `#### I-N` block for every inferred invariant. Fields separated by blank lines. The small category-and-on-chain meta line sits between the heading and the claim so readers can scan status at a glance.]

**Categories:**
- **Conservation**: Two or more storage variables change by equal-and-opposite amounts in the same function body. Pattern: `Δ(A) = +x, Δ(B) = -x` → `A + B = const`.
- **Bound**: A guard on a storage variable, *lifted to a global property* and enforced across every write site of that variable. Pattern: `require(x <= MAX)` enforced at every writer of `x` → `x ∈ [0, MAX]` globally. On-chain=**No** if any write site lacks the equivalent guard — that unguarded path is a potential bug. Per-call guards with no global implication stay in §1 and are NOT promoted here.
- **Ratio**: A storage variable is defined as a formula of other storage variables. Pattern: `withdrawAmount = totalBalance * shares / totalSupply`.
- **StateMachine**: A storage variable transitions through discrete values with guards preventing reversal. Pattern: `require(state == A); state = B`.
- **Temporal**: A condition depends on `block.timestamp`, `block.number`, or a duration/deadline variable.

**NatSpec-routed blocks**: If an `I-N` block is derived from a NatSpec claim rather than structural scan, cite it as `NatSpec: Contract.sol:LN — "<verbatim comment>"` in Derivation. Still run the structural scans afterward — they determine the On-chain=Yes/No verdict.

---

## 3. Inferred Invariants (Cross-Contract)

Trust assumptions that span contract boundaries. Each block cites both caller-side and callee-side code.

---

#### X-1

On-chain: **Yes/No**

> [what the caller assumes about the callee's return value or state]

**Caller side** — `Caller.sol:LN` — [how the value is used]

**Callee side** — `Callee.sol:LN` — [write sites that could break the assumption]

**If violated** — [consequence]

---

## 4. Economic Invariants

Higher-order properties derived from combinations of §2 and §3 invariants. Every block traces back to concrete invariant IDs.

---

#### E-1

On-chain: **Yes/No**

> [economic property]

**Follows from** — `I-N` + `I-M` [+ `X-N`]

**If violated** — [consequence]

```

## Rules for `invariants.md`

- **Heading-block format, NOT tables**: Each guard/invariant is a `#### G-N` / `#### I-N` / `#### X-N` / `#### E-N` heading. For §2/§3/§4 the heading is followed by bolded field labels (`**Claim**:`, `**Derivation**:`, etc.) separated by blank lines. For §1 the heading is followed by a single compact ` · `-separated body line (predicate · location · purpose) — see the §1 template above. H4 headings produce slug anchors (`#g-1`, `#i-17`, …) that cross-file markdown links in x-ray.md resolve reliably in VS Code, GitHub, and every renderer. Inline `<a id>` anchors inside table cells do NOT work cross-file in VS Code — never use tables for referenced IDs.
- **No overlap with x-ray.md**: x-ray.md Section 3 shows Enforced Guards (Reference) + top 3-5 inferred. This file has the full set.
- **§1 (Enforced Guards) is reference-only** for falsifiability. Each `G-N` entry is exactly two lines: the H4 heading, then one body line with three ` · `-separated fields (verbatim predicate in backticks, `file:line` in backticks, purpose prose). The purpose field MUST explain *why* the guard exists / which invariant or trust boundary it enforces — not a restatement of what the predicate checks. A body line without a purpose field is insufficient.
- **If a guard implies a global property**, that global property goes to §2 as a separate `I-N` Bound block via the guard-lift methodology (see SKILL.md Step 2g, step 2 "Guard extraction and lift").
- **NatSpec routing**: Developer-stated global invariants (NatSpec `@invariant` tags, inline comments asserting properties that must hold across calls) route DIRECTLY to §2, §3, or §4 by shape — never to §1. Source tag: `NatSpec: Contract.sol:LN`.
- **Derivation discipline**: every inferred block MUST cite exactly one of:
  - `Δ-pair: Contract.sol:Lx ↔ Contract.sol:Ly` (conservation)
  - `guard-lift: <verbatim require/if/assert> + <write-site enumeration>` (bound / ratio — the lift citation MUST include all write sites of the constrained variable; a single-callsite guard is not a valid lift)
  - `edge: State@Lx → State@Ly` (state machine)
  - `temporal: <verbatim block.timestamp / deadline check>` (temporal)
  - `NatSpec: Contract.sol:LN — "<verbatim comment>"` (developer-stated global)
  Blocks that cannot cite one of these are dropped. No "implied by semantics."
- **On-chain field**: Yes or No only. If partially enforced, split into two blocks — one for what IS enforced (Yes), one for the gap (No). Guard-lift blocks with any unguarded write site are On-chain=No.
- **No fabrication**: if an invariant cannot be traced to concrete code (or a NatSpec quote), omit it.
- **Cross-contract blocks (§3)**: must cite both sides — the caller-side usage AND the callee-side write sites. Only include blocks where both sides are inside the scope files (do not speculate about out-of-scope contracts).
- **Economic blocks (§4)**: must derive from one or more §2/§3 blocks. The `Follows from` field must reference specific I-N / X-N IDs. Economic invariants that cannot be traced to concrete single-contract invariants are dropped.
- **Anchor slug normalization**: When x-ray.md attack surfaces link to `invariants.md#x-4`, use LOWERCASE because VS Code and GitHub normalize heading IDs to lowercase. The heading itself can be uppercase (`#### X-4`) — only the link fragment needs lowercasing.

# Architecture Diagram Guide

## architecture.json Format

```json
{
  "title": "[Protocol] Architecture",
  "nodes": [
    {"id": "unique_id", "label": "DisplayName", "subtitle": "One-word role", "type": "actor|protocol|external", "row": 0}
  ],
  "edges": [
    {"from": "source_id", "to": "target_id", "label": "action description"}
  ],
  "groups": [
    {"label": "Group Name", "nodes": ["id1", "id2"]}
  ]
}
```

### Node types
- `actor`: users/roles — pill shape
- `protocol`: in-scope contracts — blue accent stripe
- `external`: out-of-scope — amber accent stripe

### subtitle
Optional short role description as second line (e.g. "Coordinator", "Price Feed"). For composite nodes, list individual contracts (e.g. "Aave / Ethena / Lido / Lista").

### row
Assign rows to **minimize edge distance**, not by node type. Place each node on the row adjacent to its primary caller. Actors typically land at the top, leaf dependencies at the bottom, but an external node called only from row 1 belongs on row 2 — NOT on a distant "externals" row.

### groups
Optional. Groups related nodes under a labeled enclosure (e.g. "Vault Layer").

**Group containment rule (CRITICAL):** Every node must be either (a) inside exactly one group, or (b) on a row that has NO group box. The SVG generator draws group boxes around all rows that contain grouped nodes. If an ungrouped node sits on the same row as a group, it will visually escape or overlap the group boundary. To fix: either add the node to the appropriate group, or move it to a different row. When deciding which group a node belongs to, classify by **primary caller** — e.g., an ACL contract called by the coordinator belongs in the coordinator's group, not in a downstream infrastructure group.

---

## Budgets & Layout Rules

### Node & edge budgets

Scale budget based on in-scope contract count (excluding interfaces):

| In-scope contracts | Max nodes | Max edges | Max per row |
|--------------------:|----------:|----------:|------------:|
| ≤10                | 12        | 14        | 4           |
| 11–20              | 16        | 18        | 4           |
| 21–35              | 20        | 22        | 5           |
| 36+                | 24        | 26        | 5           |

**Prioritize completeness over compression.** Every contract that holds funds, gates access, or sits on a critical call path should be visible — either as its own node or clearly named in a composite node's subtitle.

### Compositing rules (when to combine contracts into one node)

Apply in order — use the first tier that fits within the node budget:
- **Tier 1 — Always composite**: Contracts in the same subsystem with identical caller AND callee. Use subsystem name as label, list contracts in subtitle.
- **Tier 2 — When budget requires**: Contracts with same primary caller OR callee. Helper/satellite contracts composite into their parent node.
- **Tier 3 — Last resort**: Same-subsystem contracts with same trust level but different callers/callees.
- **Never composite across trust levels** — combining permissionless and admin-only hides trust boundaries.

### Actor and external node rules

- **Combine actors** only when they share the same trust level AND capabilities. Keep actors separate when trust levels differ.
- **External dependencies** that are sole data sources for critical logic (oracles, price feeds) should be their own node. Others can be composited by type when budget is tight.

### Same-row arc rules
- **≤2 same-row arcs per node**. If 3+ needed, move one target to adjacent row.
- **Balance directions**: 2 same-row arcs from one node → one LEFT, one RIGHT.
- **Automatic below-routing**: The SVG generator detects when a same-row arc would cross intermediate boxes and routes it below the row instead of above. Multiple below-arcs are staggered at different depths to avoid overlapping.

### Hub node layout
When a node has 3+ same-row connections (a "hub"), position it **centrally** among its same-row targets in the JSON `nodes` ordering. This minimizes arc distances and lets the generator route short connections above and long ones below. Example: if `PutManager` connects to `ftACL`, `Oracle`, and `pFT`, place `ftACL` left, `PutManager` center, `Oracle` and `pFT` right — so each arc fans out cleanly.

### Edge rules
- **Every edge label must be unique**. Never repeat the same label on multiple edges.
- **Labels: 2-3 words max**.
- **No row-skipping edges**. Every edge connects adjacent rows or same row. If an edge would span 2+ rows, move the target to an adjacent row.
- Show primary interaction flows only — not every internal call.

---

## SVG Generation & Validation

### Generate
```bash
python3 $SKILL_DIR/scripts/generate_svg.py x-ray/architecture.json x-ray/architecture.svg
```

### Render to PNG for inspection
Try in order (use first that works):
```bash
convert -density 300 x-ray/architecture.svg /tmp/architecture-preview.png
rsvg-convert x-ray/architecture.svg -o /tmp/architecture-preview.png
python3 -c "import cairosvg; cairosvg.svg2png(url='x-ray/architecture.svg', write_to='/tmp/architecture-preview.png', scale=3)"
```
Then `Read` the PNG. If no renderer is available, skip the validation loop.

### Audit checklist (max 3 iterations)

1. **Structure**: Top-to-bottom flow? Actors top, externals bottom, core middle?
2. **Edge labels**: Readable font (≥4.5), dark fill (#1E293B), sitting on their arrows (not pushed away).
3. **Edge routing**: No row-skipping edges, no arrows through boxes. Same-row arcs balanced (one LEFT, one RIGHT). Long same-row arcs that would cross intermediate boxes should route below (the generator does this automatically — verify visually).
4. **No overlapping labels**: Stagger y by ≥8 units if bounding boxes overlap.
5. **Groups**: All group rects aligned (same x-edge/width). No ungrouped node on a row that has a group box.
6. **Centering**: Nodes roughly centered on canvas, balanced across rows.

### Fix types
- **JSON-level** (regenerate): row assignments, node ordering, edges, groups → edit JSON, re-run `generate_svg.py`, re-render.
- **SVG-level** (post-process): label font/color/position → edit SVG directly, re-render.

### Cleanup
```bash
rm -f x-ray/architecture.json x-ray/git-security-analysis.json /tmp/architecture-preview.png
```

## references/threats.md

# Protocol-Type Threat Profiles

> **HOW TO USE THIS FILE**
>
> Treat this file as a threat *identification* library, **not** as a prose template for the final report.
>
> - **In Step 2e (Protocol Classification)** — use the detection-signals table to label the protocol by type.
> - **In Step 3a (Writing Section 2 of x-ray.md)** — use adversary rankings, attack patterns, and critical invariants listed here to know *what to look for* and *who threatens the protocol*, then TRANSLATE that knowledge into the output format.
>
> **DO NOT copy exploit-chain prose verbatim into Key Attack Surfaces.** Phrases like *"Oracle manipulation → inflated collateral → drain the pool"* are intentional here — they teach the threat — but the `templates.md` **DO-NOT-EXPLOIT RULE** forbids them in the report. Convert "→ attacker drains X" into "worth tracing…" / "worth checking…" / "worth confirming…" when writing the bullet. Name the surface and the concern; let the auditor finish the sentence.

This reference provides per-protocol-type threat intelligence. The skill auto-classifies the protocol from code signals in Step 2, then uses the matching profile(s) to weight adversaries, attack patterns, and surfaces in the threat model.

## Protocol Classification Signals

Detect protocol type from function signatures, state variables, and architectural patterns found during source file reading in Step 2. A protocol may match **multiple types** (hybrid). Rank by signal density — the type with the most matches is primary.

| Type | Detection Signals in Code |
|------|--------------------------|
| **Lending/Borrowing** | `borrow()`, `repay()`, `liquidate()`, `liquidationBonus`, `healthFactor`, `collateralFactor`, `LTV`, `debtToken`, `interestRate`, collateral ratio math, health factor calculations, borrow/supply balance tracking |
| **DEX/AMM** | `swap()`, `addLiquidity()`, `removeLiquidity()`, constant-product math (`x * y = k`), stable-swap invariant, `sqrtPriceX96`, `tick`, LP token mint/burn, fee tier, `getAmountOut()`, reserves tracking |
| **Yield Aggregator** | ERC4626 vault pattern (`deposit`/`withdraw`/`convertToShares`/`convertToAssets`), strategy pattern (deposit into external protocol + `harvest()`), yield routing, `totalAssets()`, `strategyDebt`, auto-compound |
| **Stablecoin** | Peg mechanism (mint/burn against collateral), `collateralRatio`, stability fee, `debtCeiling`, redemption mechanism, PSM (peg stability module), `anchor`/`peg`/`target` price references |
| **Derivatives/Perps** | `openPosition()`, `closePosition()`, `increaseSize()`, `decreaseSize()`, `fundingRate`, `margin`, `leverage`, PnL calculation, `markPrice`, `indexPrice`, position struct with size/collateral/entryPrice |
| **Liquid Staking** | `stake()` + derivative token mint, `unstake()`/`requestWithdrawal()`, exchange rate calculation, validator set management, withdrawal queue, rebasing token or share-based token |
| **Bridge** | Cross-chain message passing, `lock()`/`unlock()` or `burn()`/`mint()` pattern, relayer/validator set, message nonce, chain ID checks, merkle proof verification |
| **Governance** | `propose()`, `vote()`, `execute()`, `queue()`, quorum calculation, voting power snapshots, timelock, delegation, `proposalThreshold` |

### Hybrid Classification

Many protocols combine types. When multiple types match:
1. Rank by signal count — more matches = higher weight in threat model
2. The **primary type** determines adversary ranking order
3. **Secondary types** add their unique threats to the model (de-duplicating overlapping ones)
4. In the output, state: "Protocol classified as: **[Primary]** with **[Secondary]** characteristics"

Example: A protocol with `swap()`, `addLiquidity()`, `borrow()`, `liquidate()` → Primary: DEX/AMM, Secondary: Lending/Borrowing.

---

## Threat Profiles by Protocol Type

### Lending / Borrowing

**Primary adversaries** (ranked by historical exploit frequency):
1. **Flash loan attacker** — Borrows unlimited capital in a single transaction to manipulate oracle prices, inflate collateral values, and drain borrow capacity. Flash loans reduce the cost of oracle manipulation to near-zero.
2. **Oracle manipulator** — Manipulates price feeds (spot or TWAP) to make collateral appear more valuable or debt appear less valuable. The oracle is the single source of truth for solvency — if it lies, everything downstream breaks.
3. **Liquidation MEV searcher** — Extracts value from liquidation events through front-running, back-running, or sandwich attacks. If MEV extraction makes liquidation unprofitable for honest liquidators, bad debt accumulates.
4. **Malicious first depositor** — In protocols with share-based accounting (supply tokens, debt tokens), the first depositor can manipulate the share price to steal from subsequent depositors. Classic vault inflation attack applied to lending pools.
5. **Compromised admin** — Can change collateral factors, oracle addresses, interest rate models, or pause liquidations. Any of these can instantly make the protocol insolvent or prevent it from recovering.

**Dominant attack patterns:**
- Oracle manipulation → inflated collateral value → max borrow → drain lending pool
- Flash loan borrow → manipulate spot price → liquidate victim at wrong price → profit from liquidation bonus
- Bad debt accumulation through positions that become unliquidatable (oracle lag, gas price spikes, illiquid collateral)
- Interest rate manipulation via large deposit/withdraw cycles (move utilization to manipulate rates)
- Collateral factor misconfiguration allowing undercollateralized borrowing
- Recursive borrowing: deposit collateral → borrow → deposit borrowed asset as collateral → borrow again → amplified exposure that collapses under price movement

**Critical invariants:**
- `totalBorrows <= totalCollateral * LTV` — always, for every market and every account
- Every position must be liquidatable before it can cause bad debt (health factor trigger > underwater threshold)
- Liquidation must be profitable for liquidators (otherwise bad debt accrues silently)
- Oracle price reflects fair market value within acceptable deviation and freshness bounds
- Interest accrual is monotonic and cannot be manipulated to extract value

**What to look for first:**
1. The complete price calculation path: oracle read → price normalization → collateral value → health factor. Every step is a manipulation point.
2. Can a single transaction borrow, manipulate price, and liquidate? If yes, flash loan attack is viable.
3. Liquidation math: is the bonus sufficient to cover gas + slippage? What happens when collateral is illiquid?
4. Share price calculation for supply/debt tokens: what happens when totalSupply == 0?
5. What can admin change instantly vs. through timelock? Can admin change oracle address?

---

### DEX / AMM

**Primary adversaries** (ranked):
1. **MEV searcher / sandwich attacker** — The dominant threat to DEX users. Monitors mempool for pending swaps, inserts transactions before and after to extract value. Every swap without adequate slippage protection is a guaranteed extraction opportunity.
2. **Flash loan price manipulator** — Uses flash-loaned capital to move pool prices within a single transaction.
3. **Malicious first LP / empty pool attacker** — Manipulates pool initialization or empty-state transitions. In concentrated liquidity: can set initial tick to a manipulated price. In constant-product: can inflate LP share price through donation before the first real deposit.
4. **Liquidity manipulation attacker** — Adds and removes liquidity strategically to extract value from other LPs.
5. **Compromised admin** — Can change fee structures, pause trading, modify routing, or whitelist malicious pools.

**Dominant attack patterns:**
- Sandwich attacks: front-run swap to move price → victim swaps at worse price → back-run to capture difference
- LP share inflation on empty/new pools (donate assets to inflate share price before real deposits)
- Reentrancy through token callbacks (ERC-777, ERC-1155 hooks) during swap execution when pool state is inconsistent
- Price oracle exploitation: other protocols read AMM spot price, attacker manipulates pool in same tx, other protocol uses wrong price
- Concentrated liquidity tick manipulation: force price through tick boundaries to trigger stop-loss-like behavior in other positions
- Fee-on-transfer token accounting errors: pool receives fewer tokens than expected, invariant breaks

**Critical invariants:**
- Pool invariant holds before and after every operation (k = x * y, or curve-specific)
- LP share value is monotonically non-decreasing from fees (absent impermanent loss)
- No tokens can be extracted without proportional LP burn or valid swap math
- Swap output amount matches the invariant-derived calculation exactly (no rounding exploitation)
- Reserves tracked in contract state match actual token balances (no donation attack surface)

**What to look for first:**
1. Swap math: is the invariant correctly maintained? Are there rounding errors that consistently favor one direction?
2. LP mint/burn math: what happens at totalSupply == 0? Is there minimum liquidity enforcement?
3. Does the pool expose `getPrice()`, `observe()`, or similar that other contracts call? If yes, it's an oracle and manipulation has external blast radius.
4. Slippage protection: is it enforced at the router level? Can it be bypassed? What's the default?
5. Reentrancy guards: does the swap update state before making external calls (token transfers)?

---

### Yield Aggregator / Vault

**Primary adversaries** (ranked):
1. **Share inflation attacker (first depositor)** — The canonical vault attack. Deposit 1 wei, donate a large amount directly to the vault (inflating `totalAssets` without minting shares), then when the next user deposits, they receive 0 shares due to rounding and the attacker redeems for the donated + deposited amount.
2. **Malicious/compromised strategy** — Strategies hold the actual funds. A malicious strategy can report fake losses, retain approvals after migration, or transfer funds out.
3. **Reentrancy through external protocol callbacks** — Vault deposits into Aave/Compound/Yearn, which may trigger callbacks during deposit/withdraw. If vault state is inconsistent during the callback window, reentrancy can manipulate share prices.
4. **Donation/direct-transfer attacker** — Sends tokens directly to the vault contract (not through deposit()) to manipulate `totalAssets()` and therefore share price. If `totalAssets` reads `balanceOf(address(this))`, any donation changes the share price.
5. **Compromised admin** — Can add malicious strategies, change allocation weights, set harvester address, or migrate funds to attacker-controlled strategy.

**Dominant attack patterns:**
- ERC4626 share inflation: deposit(1) → donate(large amount) → next depositor gets 0 shares → redeem(all)
- Strategy reports fake gain → inflated share price → attacker deposits at inflated price → strategy reports real value → attacker loses nothing, previous depositors diluted
- Strategy retains token approval after migration to new strategy — old strategy can still pull funds
- Harvest sandwich: front-run harvest() with deposit (get shares cheap), harvest increases totalAssets, back-run with withdraw (redeem at higher share price)
- Vault accounting desync: strategy's real balance differs from vault's recorded allocation due to external protocol behavior (rebasing, slashing, reward accrual)

**Critical invariants:**
- `totalAssets()` accurately reflects real underlying value at all times
- `convertToShares(convertToAssets(shares)) <= shares` — round-trip must not create value
- `convertToAssets(convertToShares(assets)) <= assets` — same in reverse
- Strategy cannot extract more than it was allocated
- Share price can only increase from yield, never from manipulation

**What to look for first:**
1. Share price calculation: `convertToAssets` / `convertToShares`. Is there a virtual offset or minimum deposit to prevent inflation attacks?
2. Strategy interface: what can a strategy do? Can it report arbitrary gain/loss? Who can add/remove strategies?
3. Does `totalAssets()` use `balanceOf(this)` or internal accounting? If balanceOf, donation attacks are possible.
4. Deposit/withdraw: is there reentrancy protection? Are state changes before external calls?
5. Strategy migration: does the old strategy lose all approvals? Is there a cooldown?

---

### Stablecoin

**Primary adversaries** (ranked):
1. **Oracle manipulator** — If collateral price is manipulated upward, attacker can mint stablecoins against less real collateral. If manipulated downward, legitimate positions get liquidated at unfair prices. In algorithmic stablecoins, oracle manipulation can trigger or amplify depegs.
2. **Economic/governance attacker** — Acquires governance power to change collateral parameters (lower ratios, add risky collateral, change stability fees) to extract value or destabilize the peg. Can also manipulate stability mechanisms.
3. **Bank run attacker** — Triggers mass redemption by creating panic or exploiting information asymmetry. If the stablecoin's redemption mechanism has capacity limits, a strategic redemption can drain the best collateral, leaving remaining holders with worse backing.
4. **Flash loan minter** — Flash loans capital to mint stablecoins, manipulates collateral price, and profits from the discrepancy. Especially dangerous if minting has no cooldown or rate limit.
5. **Compromised admin** — Can change collateral types, oracle addresses, debt ceilings, stability fees, or pause redemptions. Any of these can break the peg or trap user funds.

**Dominant attack patterns:**
- Collateral price manipulation → mint at inflated collateral value → sell stablecoins → collateral price returns to normal → protocol is undercollateralized
- Algorithmic death spiral: sell pressure → depeg → collateral value drops → more liquidations → more sell pressure → repeat (LUNA/UST)
- Redemption mechanism DOS: spam redemptions to drain liquid collateral, leaving illiquid collateral backing remaining supply
- Governance attack: change collateral ratio to allow undercollateralized minting
- Oracle staleness exploitation: mint when oracle reports stale (higher) price, redeem when oracle updates to real (lower) price

**Critical invariants:**
- Every stablecoin unit is backed by >= 1:1 collateral value (or >= configured ratio)
- Mint and redeem are inverse operations: round-trip preserves value (no profitable loops)
- Peg mechanism is convergent, not divergent, under sell pressure
- Liquidation can always restore individual position collateralization
- Total supply <= total debt ceiling across all collateral types

**What to look for first:**
1. Minting path: what collateral is accepted → how is it valued (oracle) → what's the ratio → can the ratio be changed?
2. Redemption path: can all stablecoins be redeemed simultaneously? Is there a priority queue? What happens under stress?
3. Liquidation mechanism: is it profitable? What happens if collateral price drops faster than liquidations can execute?
4. What can governance change? How quickly? Is there a peg-break emergency mechanism?
5. Death spiral analysis: if the stablecoin depegs 10%, does the mechanism push it back or amplify the depeg?

---

### Derivatives / Perps

**Primary adversaries** (ranked):
1. **Oracle manipulator** — In derivatives, oracle errors are amplified by leverage. A 1% oracle manipulation on a 50x leveraged position creates a 50% PnL swing.
2. **Liquidation MEV searcher** — Extracts value from liquidation events. In perps, positions can be large and leverage amplifies the liquidation bonus. May also manipulate price to trigger liquidations, then capture the liquidated collateral.
3. **Funding rate manipulator** — Skews open interest to force favorable funding rate payments. With enough capital, can make the funding rate so extreme that opposing positions are forced to close, then reverse to capture the funding.
4. **Position size attacker** — Opens positions larger than the protocol can pay out, or opens positions across multiple accounts to circumvent limits. If the protocol's liquidity pool cannot cover max payout, insolvency results.
5. **Compromised admin** — Can change max leverage, funding rate parameters, liquidation thresholds, or oracle addresses. Can also pause liquidations (creating bad debt) or enable instant position changes that bypass risk checks.

**Dominant attack patterns:**
- Oracle manipulation → cascade liquidation → profit from liquidated positions
- Funding rate manipulation through concentrated one-sided open interest
- Position size exceeding protocol's payout capacity (adversary opens at max leverage, market moves in their favor, protocol can't pay)
- Delayed/stale oracle → risk-free directional bet (see current price off-chain, trade at stale on-chain price)
- Cross-margin exploitation: loss in one position affecting collateral of another, creating liquidation cascades within a single account
- ADL (auto-deleveraging) manipulation: force ADL on profitable opposing positions by creating insolvency conditions

**Critical invariants:**
- Sum of all PnL = 0 (zero-sum between longs and shorts, minus fees)
- Available liquidity >= maximum payout of all open positions under worst-case price movement
- Liquidation triggers before any position can cause bad debt to the system
- Funding rate converges open interest imbalance over time (doesn't diverge)
- Mark price cannot deviate from index price beyond safety bounds

**What to look for first:**
1. PnL calculation: is it correct under all conditions (positive, negative, at leverage limits)?
2. Liquidation threshold vs. actual execution: is there enough margin between liquidation trigger and insolvency?
3. Oracle: mark price vs. index price. How is mark price calculated? Can it be manipulated within a block?
4. Max open interest / position size limits: are they enforced? What happens if total payouts exceed pool?
5. Funding rate: can it be manipulated? What's the maximum rate? Can it drain margin faster than expected?

---

### Liquid Staking

**Primary adversaries** (ranked):
1. **Exchange rate manipulator** — The derivative token's value depends on an exchange rate (stETH/ETH, rETH/ETH). If this rate can be manipulated (through rewards reporting, slashing events, or direct donation), attackers can buy/sell the derivative at wrong prices against protocols that use it as collateral.
2. **Validator set attacker** — Compromises or controls validators that the protocol delegates to. Can trigger slashing events, withhold rewards, or censor transactions. The trust model around validator selection is critical.
3. **Withdrawal queue attacker** — Exploits timing or ordering in the unstaking queue. May front-run large unstake requests to exit first, or manipulate queue mechanics to delay others' withdrawals.
4. **Oracle/rate arbitrageur** — Exploits lag between the on-chain exchange rate and real underlying value. When a slashing event occurs, the on-chain rate may not update immediately — attacker sells derivative at stale (higher) rate before the slash is reflected.
5. **Compromised admin** — Can change validator set, fee parameters, oracle addresses, or withdrawal mechanisms. Can also pause withdrawals, trapping user funds.

**Dominant attack patterns:**
- Rewards/slashing reporting manipulation: report fake rewards to inflate exchange rate, or delay slashing report to exit at stale rate
- Withdrawal queue griefing: spam small unstake requests to delay large withdrawals
- Rebasing token integration bugs: protocols that integrate the liquid staking derivative may not handle rebasing correctly
- Validator collusion: validators withhold blocks or MEV to reduce rewards below expected rate
- Share price manipulation through direct ETH/token transfer to the contract

**Critical invariants:**
- Exchange rate reflects true underlying value (staked assets + rewards - slashing)
- Total derivative supply * exchange rate <= total underlying staked
- Withdrawal queue processes in fair order (no priority manipulation)
- Validator performance doesn't systematically disadvantage stakers
- Slashing events are reflected in exchange rate before any user can exit at stale rate

**What to look for first:**
1. Exchange rate calculation: who reports rewards/slashing? How often? Can it be manipulated?
2. Withdrawal mechanism: is there a queue? What's the delay? Can it be griefed?
3. Validator selection: who chooses validators? Can a malicious validator be added?
4. Does the derivative token rebase or use shares? How do integrating protocols handle this?
5. What happens if a massive slashing event occurs? Is the loss socialized fairly?

---

### Bridge

**Primary adversaries** (ranked):
1. **Validator/relayer set attacker** — Compromises the threshold of validators/relayers needed to approve cross-chain messages. This is the #1 bridge exploit vector by total value lost.
2. **Message replay attacker** — Replays a valid cross-chain message on a different chain or replays the same message multiple times to mint/unlock tokens repeatedly.
3. **Race condition exploiter** — Exploits timing gaps between source and destination chain finality. Initiates action on source chain, front-runs the relay on destination chain, or exploits reorgs to reverse source chain action after destination chain has already processed it.
4. **Fake message crafter** — Crafts a cross-chain message that passes validation but contains malicious data. Exploits weaknesses in message encoding, proof verification, or chain ID validation.
5. **Compromised admin** — Can change validator set, pause bridge (trapping funds), or upgrade contracts to drain locked funds. Bridge admin keys are the highest-value targets in DeFi.

**Dominant attack patterns:**
- Validator key compromise → forge cross-chain messages → mint unbacked tokens on destination
- Message replay: same message processed twice (missing nonce check or nonce overflow)
- Proof verification bypass: merkle proof or signature check has edge case that passes invalid proofs
- Chain ID confusion: message valid on chain A gets processed on chain B
- Reorg exploitation: deposit confirmed on source chain → relayed to destination → source chain reorgs → deposit reversed but destination tokens already minted

**Critical invariants:**
- Locked tokens on source chain = minted tokens on destination chain (1:1 backing)
- Every cross-chain message is processed exactly once (no replay)
- Message cannot be forged without validator threshold consensus
- Bridge accounting is consistent across chains (no cross-chain double-spend)

**What to look for first:**
1. Validator/relayer trust model: how many validators? What's the threshold? Can they be changed?
2. Message replay protection: is there a nonce? Is it checked correctly? Can it overflow?
3. Proof verification: merkle proof, signature scheme. Are there edge cases?
4. Finality assumptions: does the bridge wait for finality on source chain?
5. What can the admin do? Can they drain locked funds? Change validators instantly?

---

### Governance

**Primary adversaries** (ranked):
1. **Flash loan governance attacker** — Borrows governance tokens via flash loan, votes on a proposal, and returns tokens in the same transaction. Only possible if voting power is measured at current block rather than a snapshot.
2. **Governance capture attacker** — Gradually accumulates voting power (buying tokens, borrowing from lending protocols, receiving delegations) to pass malicious proposals. Patient, multi-block attack with potentially massive payoff.
3. **Proposal spam / griefing attacker** — Submits many proposals to exhaust voter attention, or submits proposals that appear benign but have hidden malicious effects (e.g., "update parameter to X" where X causes insolvency).
4. **Timelock exploitation attacker** — Monitors queued proposals and positions to exploit parameter changes the instant they execute.
5. **Compromised admin/guardian** — Can cancel proposals, pause governance, or execute emergency actions that bypass normal governance flow.

**Dominant attack patterns:**
- Flash loan → vote → return: instant governance control if no snapshot
- Bribe attacks: pay token holders to delegate or vote for malicious proposals (via platforms like Votium)
- Proposal obfuscation: malicious calldata hidden in a seemingly-benign proposal
- Timelock front-running: position before queued proposal executes to profit from parameter changes
- Guardian abuse: emergency powers used to bypass governance for non-emergency purposes

**Critical invariants:**
- Voting power is snapshotted at proposal creation (not measured at vote time)
- Quorum requirements prevent minority capture
- Timelock provides sufficient delay for users to exit before parameter changes take effect
- No single role can bypass governance unilaterally for non-emergency actions
- Proposal calldata matches its description (can be verified on-chain)

**What to look for first:**
1. Voting power: snapshot or current balance? If current, flash loan attack is trivial.
2. Quorum and threshold: are they high enough to prevent capture? What's the token distribution?
3. Timelock: is the delay nonzero? Is it long enough for users to react?
4. What can governance control? List every parameter/action that goes through governance.
5. Emergency powers: who has them? What can they do? Can they drain funds?
# Temporal Threat Dimension

DeFi protocols have a lifecycle, and different threats dominate at different phases. This reference provides per-phase threat intelligence. The skill auto-detects which phases are relevant from code signals and includes the applicable phases in the threat model.

## Phase Detection

Detect which phases are relevant from code patterns found during Step 2 source reading:

| Phase | Include When |
|-------|-------------|
| **Deployment & Initialization** | Always include — every protocol has this phase |
| **Steady State** | Always include — this is the baseline |
| **Market Stress** | Oracle integration exists, OR liquidation logic exists, OR collateral/debt tracking exists, OR any price-dependent calculation |
| **Governance & Upgrade Windows** | Timelock exists, OR governance contract exists, OR proxy pattern (UUPS/transparent/beacon) exists, OR `propose()`/`vote()`/`execute()` functions exist |
| **Deprecation & Wind-down** | V2/migration in contract names or comments, OR `migrate()` function exists, OR deprecated contract references, OR multi-version architecture |

---

## Phase 1: Deployment & Initialization

The most dangerous 24-48 hours. The protocol transitions from code to live system with real money. Attackers actively monitor deployment transactions.

### Threats

**Initialization front-running:**
Attacker watches the mempool for `initialize()` calls and front-runs with malicious parameters. Critical for UUPS proxies where `initialize()` sets the owner. Also applies to pool creation, market listing, and oracle setup.

What to look for: `initialize()` / `init()` functions without access control or without `initializer` modifier. Proxy deployment where `initialize` is called in a separate transaction from deployment. Pool/market creation that can be called by anyone.

**Parameter misconfiguration:**
Protocol deployed with testing parameters still active. DELAY=0 in timelocks, test oracle addresses, overly permissive access control, dev-mode fee settings. The code is correct but the configuration creates the vulnerability.

What to look for: Hardcoded constants that look like test values (0 delays, max uint fees, known test addresses like 0xdead). Constructor/initializer parameters without validation. Default values that are insecure.

**Ownership not transferred:**
Contract deployed with deployer EOA as owner, intended to transfer to multisig, but transfer hasn't happened yet. Creates a window where a single key controls everything.

What to look for: `Ownable` without `transferOwnership()` in deployment scripts. Two-step ownership transfer that hasn't been accepted. Role-based access where roles haven't been granted to the intended addresses.

**Empty-state exploitation:**
Protocols behave differently when empty. First depositor can manipulate share prices (vault inflation), set initial pool prices, or establish initial state that disadvantages subsequent users.

What to look for: `if (totalSupply == 0)` branches. Pool creation with attacker-chosen initial prices/ratios. Vault deposit when totalAssets == 0. Missing minimum initial deposit requirements.

**Deployment ordering bugs:**
Contracts deployed in wrong order, missing approvals between contracts, circular dependencies not resolved, proxy pointing at wrong implementation.

What to look for: Deployment scripts with multiple transactions. Contracts that reference each other (circular setup). Approval chains (token approvals, role grants) that must happen in specific order.


---

## Phase 2: Steady State

Normal operation. This is where the existing adversary types (flash loan, MEV, external user, compromised admin) operate. The standard threat model covers this phase — no additional temporal-specific content needed. The protocol-type threat profiles provide the detailed guidance for this phase.

---

## Phase 3: Market Stress

Protocols that work perfectly in calm markets can break catastrophically during volatility. This phase accounts for some of the largest DeFi losses (LUNA/UST, cascading liquidations during Black Thursday).

### Threats

**Oracle latency under volatility:**
Oracle heartbeat periods (1h for some Chainlink pairs) mean prices can be stale during rapid market moves. Every calculation using that price is wrong for the duration. Borrowers can be liquidated at unfair prices, or worse, cannot be liquidated at all (stale price shows healthy position while real value is underwater).

What to look for: Chainlink `latestRoundData()` calls — what staleness threshold is used? Is it appropriate for the asset's volatility? Is the heartbeat period documented/configured or hardcoded? Is there a deviation threshold check? What happens if `updatedAt` is 0 or in the future?

**Liquidation cascade:**
Position A is liquidated → liquidation dumps collateral on market → price drops further → Position B is liquidated → cycle repeats. The protocol's own liquidation mechanism amplifies the crash. Can cause systemic insolvency.

What to look for: Liquidation mechanism — does it sell collateral on-market (creating price impact)? Is there a circuit breaker? Is liquidation throttled? Can the protocol handle 30%+ collateral price drops in a single block?

**Liquidity evaporation:**
During stress, LPs withdraw liquidity. Swaps have worse slippage. Liquidation bots can't efficiently swap collateral. Bad debt accumulates because liquidations become unprofitable at the gas + slippage cost.

What to look for: Liquidation profitability assumptions — are they valid when liquidity is thin? Does the protocol assume swap paths exist with sufficient depth? Is there a minimum liquidity requirement?

**Correlated asset depeg:**
Protocol assumes USDC = $1, stETH = ETH, wBTC = BTC. During stress, these correlations break. A lending protocol that treats stETH as equivalent to ETH suddenly has undercollateralized positions.

What to look for: Hardcoded price equivalences (1:1 assumptions). Missing oracle for derivative assets (using underlying asset's oracle instead). Collateral factors that don't account for depeg risk.

**Gas price spikes:**
Critical operations (liquidations, rebalancing, oracle updates) become prohibitively expensive. Time-sensitive operations fail to execute. Keepers and bots stop operating because gas cost exceeds profit.

What to look for: Gas-sensitive operations (keeper-dependent flows). Liquidation incentive vs. gas cost assumptions. Operations that must execute within a time window. Are there fallback mechanisms for keeper failure?

**Withdrawal stampede:**
Many users try to withdraw simultaneously. If the protocol has limited liquid reserves (funds deployed in strategies, locked in positions), early withdrawers drain liquidity and late withdrawers are stuck.

What to look for: Withdrawal queues, rate limits. What percentage of TVL is liquid vs. deployed? Can strategies be unwound quickly? Is there a withdrawal fee that increases under stress (to discourage runs)?


---

## Phase 4: Governance & Upgrade Windows

Every governance action or upgrade creates a transient vulnerability window. The transition period between "old state" and "new state" is when exploits happen.

### Threats

**Timelock exploitation window:**
A governance proposal is queued with a known timelock delay. Everyone can see what parameters will change. Attackers position before execution to exploit new parameters immediately. Example: if collateral factor increases, max borrow the instant the timelock executes.

What to look for: Timelock durations — are they long enough for users to react? Can users exit positions before parameter changes take effect? Are there parameters that could be exploited if their pending value is publicly known?

**Upgrade storage collision:**
Proxy upgrade changes storage layout, corrupting existing state. Balances become wrong, ownership changes unexpectedly, access control breaks. The new implementation reads old storage through a different layout.

What to look for: UUPS `_authorizeUpgrade`, transparent proxy patterns. Is there storage gap usage? Are upgrades tested with the actual storage layout? Is there an upgrade validation step?

**Flash loan governance:**
Attacker borrows governance tokens via flash loan, votes, and returns tokens in same transaction. Trivial if voting power is measured at current block. Some protocols are immune (snapshot-based voting), others are not.

What to look for: Voting power source — `balanceOf(msg.sender)` (vulnerable) vs. snapshot at proposal creation block (immune). Can governance tokens be borrowed from lending protocols?

**Governance capture (slow):**
Attacker accumulates voting power over time — buying tokens, receiving delegations, borrowing from Aave. Once threshold is reached, passes malicious proposals. The timelock is the last defense.

What to look for: Token distribution — is voting power concentrated? What's the quorum? Can a well-funded attacker buy enough tokens to pass proposals? Is there a guardian that can veto?

**Migration window:**
Protocol migrates from V1 to V2. During migration, funds are in transit. Approval chains exist between old and new contracts. Users who don't migrate lose access or face degraded conditions. The V1→V2 bridge is an attack target.

What to look for: Migration functions, V1→V2 transfer mechanisms. Do V1 contracts retain fund access? Is there a deadline? Can migration be front-run?


---

## Phase 5: Deprecation & Wind-down

Protocols don't live forever. When maintenance stops, a new class of threats emerges. Include this phase only when there's evidence of version transitions, deprecation markers, or multi-version architecture.

### Threats

**Residual funds in deprecated contracts:**
Old contracts still hold tokens but monitoring/maintenance has stopped. Keepers no longer run. Oracles go stale permanently. Any exploitable path in the old contract becomes a free-money opportunity with zero monitoring.

What to look for: Multi-version architecture. Are old versions still accessible? Do they still hold funds? Is there a forced migration mechanism?

**Abandoned approval chains:**
Users who interacted with V1 still have active token approvals to V1 contracts. If V1 has any exploitable path, those user approvals are a liability — attacker can drain user wallets through the deprecated contract.

What to look for: Does the protocol use `approve()` (unlimited) or `permit()`? Is there a mechanism to revoke approvals during migration? Are users notified?

**Dependent protocol breakage:**
Other protocols that integrate with the deprecated protocol don't know it's deprecated. They continue calling functions that return stale data, empty results, or revert unexpectedly.

What to look for: Does this protocol serve as an oracle or data source for others? Is there a deprecation flag or kill switch that integrators can check?

**Frozen state exploitation:**
When governance stops or admin keys are lost, the protocol is frozen in its last configuration. Market conditions change but parameters can't be updated. Interest rates, collateral factors, oracle parameters all become increasingly stale.

What to look for: What happens if no governance proposal passes for 6 months? Are there parameters that must be periodically updated? Is there an automated fallback?

---

## Writing the Temporal Risk Profile

In the output, include a "Temporal Risk Profile" subsection within Section 2. For each applicable phase:

1. **Name the phase** and state why it's relevant to this protocol
2. **List the specific threats** that apply (not all threats from every phase — only those where the code has the relevant patterns)
3. **Cite the code location** where the temporal risk exists
4. **Assess mitigation**: is the risk mitigated, partially mitigated, or unmitigated?

Keep it concise — 2-4 bullets per applicable phase. Phase 2 (Steady State) is covered by the main threat model, so skip it in the temporal section to avoid duplication.
# Cross-Protocol Composability Threats

DeFi's unique property is composability — protocols interact with other protocols, creating emergent risks that don't exist in isolated analysis. This reference provides a systematic framework for identifying and documenting composability threats.

## External Call Classification

During Step 2 source reading, every external call is already extracted. This enhancement **classifies** each call into the composability threat taxonomy. For each external call found, determine:

1. **Target type**: Oracle, DEX/AMM, Lending pool, Yield protocol, Token, Governance, Bridge, Other
2. **Assumptions about return value**: What does this protocol assume the external call returns? (correct price, exact token amount, success, specific format)
3. **Validation present**: Does the code validate the return? (bounds check, staleness check, zero check, success check)
4. **Mutability of external behavior**: Can the external contract's behavior change without this protocol's consent? (upgradeable proxy? governed parameters?)
5. **Fallback on failure**: What happens if the external call fails? (revert, silent failure, fallback value, try/catch with fail-open?)

---

## Layer 1: Direct Dependency Risks

The protocol directly calls external contracts. These are visible in the code — every `interface` import and external call is a direct dependency.

### Oracle Dependency Chain

The protocol reads prices from an oracle. But that oracle aggregates from sources that can be manipulated.

**Threat**: Protocol → Oracle → underlying source(s). If any source in the chain is manipulable within the protocol's trust assumptions, the oracle is effectively manipulable.

**What to look for:**
- What oracle is used? (Chainlink, Uniswap TWAP, Pyth, custom)
- What's the oracle's aggregation method? (median of N sources, TWAP, VWAP)
- Staleness check: is `updatedAt` validated? What threshold? Is the threshold appropriate for the asset?
- Deviation check: is the returned price bounded against a reference? (e.g., within 5% of previous price)
- Zero/negative check: what happens if oracle returns 0?
- Sequencer uptime check: on L2s, is the sequencer uptime feed checked?
- Fallback oracle: if primary fails, is there a fallback? Is the fallback also validated?
- Can admin change the oracle address? Instantly or through timelock?

### Yield Strategy Dependency

Protocol deposits funds into external yield protocols (Aave, Compound, Yearn, Convex, etc.).

**Threat**: The external protocol holds the actual funds. If it gets exploited, paused, or changes behavior, this protocol's funds are at risk. The strategy is the bridge between "our code" and "their code."

**What to look for:**
- What protocols do strategies deposit into? List each one.
- Is the external protocol upgradeable? By whom? Through what process?
- Can the external protocol pause withdrawals? Under what conditions?
- Does the strategy have emergency withdrawal capability?
- What happens if the strategy reports a loss? How is it socialized?
- Can new strategies be added? By whom? Instantly or through timelock?
- Does the old strategy retain approvals after migration?
- Are there reentrancy risks through the external protocol's callbacks?


### Token Behavior Assumptions

Every `token.transfer()`, `token.transferFrom()`, `token.balanceOf()` call carries implicit assumptions about token behavior.

**Threat**: The code assumes standard ERC20 behavior. Non-standard tokens break these assumptions silently — no revert, just wrong accounting.

**Assumption matrix** (check each for every token the protocol handles):

| Assumption | Standard Tokens | Violating Tokens | Impact if Violated |
|-----------|----------------|-----------------|-------------------|
| Transfer sends exact amount | ERC20 | Fee-on-transfer (USDT with fee, PAXG) | Internal accounting > real balance, protocol becomes insolvent |
| Balance doesn't change without transfer | ERC20 | Rebasing (stETH, AMPL, aTokens) | Accounting drift, share price manipulation |
| Transfer always succeeds (or reverts) | ERC20 | USDT (returns false, no revert) | Silent transfer failure, lost funds |
| No callback on transfer | ERC20 | ERC-777, ERC-1155 | Reentrancy through transfer callback |
| 18 decimals | Most tokens | USDC (6), WBTC (8), GUSD (2) | Math errors, massive over/under-valuation |
| Token can't block specific addresses | Most tokens | USDC, USDT (blacklist), cUSDC | Withdrawal blocked, funds trapped |
| Token can't be paused | Most tokens | USDC, USDT | All protocol operations blocked |
| Token is immutable | Most tokens | Upgradeable tokens (USDC proxy) | Behavior changes post-deployment without consent |
| No max supply cap affecting mint | Most tokens | Some algorithmic tokens | Deposit credited but tokens never arrive |

**What to look for:**
- Does the code use `balanceOf(before) - balanceOf(after)` pattern? (handles fee-on-transfer)
- Does the code use SafeERC20? (handles non-reverting tokens)
- Are token decimals dynamic or hardcoded?
- Does the code handle rebasing token balance changes?
- Is there a token whitelist, or can arbitrary tokens be used?

### Callback Reentrancy

External calls can trigger callbacks that re-enter the protocol before state is finalized.

**Threat**: Even with reentrancy guards on direct calls, callbacks through external protocols can bypass them. Token transfer → external protocol callback → re-enter through a different function.

**What to look for:**
- State changes after external calls (violating checks-effects-interactions)
- Reentrancy guards: are they per-function or global? Per-function guards don't protect cross-function reentrancy
- ERC-777 tokens: `tokensReceived` hook fires on transfer
- ERC-1155 tokens: `onERC1155Received` fires on transfer
- Aave/Compound flash loan callbacks
- Uniswap swap callbacks
- Vault deposit/withdraw that triggers strategy interaction which triggers external callback

---

## Layer 2: Shared State Risks

Two or more protocols interact with the same underlying state, creating indirect dependencies that are invisible in isolated code review.

### Liquidity Coupling

**Threat**: Protocol A and Protocol B both use the same Uniswap pool for swaps or pricing. A large action in Protocol A moves the pool price, affecting Protocol B's calculations within the same block.

**What to look for:**
- Does the protocol swap through public pools? Which ones?
- Do those pools have significant TVL relative to the protocol's swap sizes?
- Could a large liquidation in this protocol move a pool price enough to affect other protocols?
- Is the protocol itself a significant LP in pools that other protocols use?

**Example**: Protocol uses Uniswap ETH/USDC pool for liquidation swaps. Large liquidation dumps ETH into the pool, cratering the pool price. Another lending protocol uses the same pool's spot price as an oracle. Cascade.

### Oracle Sharing

**Threat**: Multiple protocols use the same oracle feed. A market event triggers liquidations across all of them simultaneously, creating correlated selling pressure and oracle feedback loops.

**What to look for:**
- Which oracle feeds does this protocol use?
- Are these the same feeds used by major lending/derivatives protocols?
- Could liquidations in this protocol create sell pressure that affects the oracle price?
- Could liquidations triggered by the oracle price in *other* protocols create sell pressure that triggers liquidations *here*?

### Approval Chain Exposure

**Threat**: Users grant token approvals to protocol contracts. If any approved contract has an exploitable path, user funds are at risk even if users never interact with the vulnerable function.

**What to look for:**
- Does the protocol request unlimited approvals? (`type(uint256).max`)
- Are approvals scoped to specific functions or broad?
- If the protocol is upgradeable, an upgrade could add a function that drains approved tokens
- Are there deprecated contracts that still hold user approvals?

---

## Layer 3: Temporal Composability Risks

External protocols change over time. This protocol's assumptions about them can silently become invalid.

### Governance-Induced Behavior Change

**Threat**: An external protocol's governance changes a parameter that this protocol's logic depends on. No contract interaction changed, but economic assumptions broke.

**What to look for:**
- Does this protocol assume specific parameter values from external protocols? (interest rates, collateral factors, fee tiers)
- Are external protocol parameters read dynamically or hardcoded?
- Would an external parameter change require this protocol to update its own parameters?

**Example**: Aave governance changes ETH collateral factor from 80% to 75%. A vault strategy that assumes 80% leverage ratio is now over-leveraged and at liquidation risk.

### Upgrade-Induced Interface Change

**Threat**: External protocol upgrades its implementation. Function signatures are the same, but behavior changes (gas cost, revert conditions, return values, side effects).

**What to look for:**
- Are external dependencies behind upgradeable proxies?
- Does this protocol's error handling account for behavior changes? (try/catch that assumes specific revert reasons)
- Are gas estimates hardcoded that could break if external protocol's gas usage changes?

### Deprecation Without Notification

**Threat**: External protocol deprecates an oracle feed, a pool, or an endpoint. The call doesn't revert — it returns stale/wrong data silently. Or it starts reverting, and this protocol's try/catch falls through to an unsafe default.

**What to look for:**
- Are there freshness checks on all external data sources?
- What's the try/catch fallback behavior? Does it fail-open (use stale data) or fail-closed (revert)?
- Is there monitoring for external dependency health?

### Dependency-of-Dependency Upgrade

**Threat**: This protocol uses Protocol A, which uses Protocol B. Protocol B upgrades. Protocol A's behavior changes. This protocol's behavior changes. No visibility into the root cause.

**What to look for:**
- Map the full dependency chain (2-3 levels deep). For each level:
  - Is it upgradeable?
  - Is it governed?
  - Can its behavior change without this protocol's knowledge?
- The deeper the chain, the less control this protocol has. Flag chains deeper than 2 levels.

---

## scripts

```

```

## scripts/analyze_git_security.py

```python
#!/usr/bin/env python3
"""Git history security analysis for Solidity repositories.

Analyzes git history from a security researcher's perspective: fix commits,
dangerous area changes, forked dependencies, technical debt, and developer
patterns. Outputs structured JSON consumed by the x-ray skill.

Usage:
    python3 analyze_git_security.py --repo . --src-dir contracts
    python3 analyze_git_security.py --repo . --src-dir contracts --json /tmp/out.json
"""

from __future__ import annotations

import argparse
import dataclasses
import json
import os
import re
import subprocess
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path


# ═══════════════════════════════════════════════════════════════
# DATA STRUCTURES
# ═══════════════════════════════════════════════════════════════

@dataclass
class FileChange:
    path: str
    added: int
    deleted: int
    is_source: bool = False
    is_test: bool = False


@dataclass
class Commit:
    sha: str
    short_sha: str
    date: str
    author: str
    subject: str
    files: list[FileChange] = field(default_factory=list)
    is_merge: bool = False

    @property
    def source_files(self) -> list[FileChange]:
        return [f for f in self.files if f.is_source]

    @property
    def test_files(self) -> list[FileChange]:
        return [f for f in self.files if f.is_test]

    @property
    def total_churn(self) -> int:
        return sum(f.added + f.deleted for f in self.files)

    @property
    def source_churn(self) -> int:
        return sum(f.added + f.deleted for f in self.files if f.is_source)


# ═══════════════════════════════════════════════════════════════
# COMMIT CLASSIFICATION — Intent + Structural Impact model
#
# Two-phase approach:
#   Phase 1: Classify commit MESSAGE into a single intent category
#            (first match wins from priority-ordered rules)
#   Phase 2: Analyze DIFF structure for directional code changes
#            (net addition of guards, removal of code paths, etc.)
#
# Final score = intent_base + structural_impact + shape_modifier
#               + security_domain_overlap
# ═══════════════════════════════════════════════════════════════

# Phase 1: Intent classification
# The commit gets ONE primary intent (highest-priority match), plus
# optional topic tags from secondary matches. The primary intent sets
# the base score; topic tags add smaller bonuses for cross-cutting
# concerns (e.g. a "bug fix" that also mentions "oracle" pricing).
#
# This avoids pure additive keyword scoring (every word = points) while
# still capturing the nuance that "fix oracle reentrancy" is more
# interesting than just "fix bug".

# Primary intent: first match wins, sets the base score
_INTENT_RULES: list[tuple[str, list[re.Pattern], int, str]] = [
    # (category, patterns, base_score, reason_label)
    ("security_explicit", [
        re.compile(r"\b(security|vulnerab|exploit|attack|CVE-\d)\b", re.I),
        re.compile(r"\b(reentran|overflow|underflow|front.?run|malleab)\w*", re.I),
    ], 8, "explicit security language"),

    ("urgent_fix", [
        re.compile(r"\b(hotfix|emergency|critical|IMPT)\b", re.I),
    ], 6, "urgent/critical fix"),

    ("bug_fix", [
        re.compile(r"\bfix(es|ed)?\b", re.I),
        re.compile(r"\bbug\b", re.I),
        re.compile(r"\bpatch\b", re.I),
        re.compile(r"\bbroken\b", re.I),
    ], 4, "bug fix"),

    ("hardening", [
        re.compile(r"\b(harden|mitigat|protect|restrict|sanitiz|validat)\w*", re.I),
    ], 2, "hardening/validation"),

    ("feature", [
        re.compile(r"^\s*(feat|add|implement|introduce|support)\b", re.I),
    ], -1, "feature addition"),

    ("maintenance", [
        re.compile(r"^\s*(docs?|chore|ci|test|style|build)\s*:", re.I),
        re.compile(r"\b(readme|typo|format|lint|rename|refactor|cleanup|comment)\b", re.I),
        re.compile(r"\bchange\s+\w+\s+to\s+\w+\b", re.I),
    ], -3, "maintenance/cosmetic"),
]

# Topic tags: checked independently of primary intent. Each adds a
# small bonus (+2) if matched, capturing cross-cutting domain signals.
# E.g. a "bug fix" mentioning "oracle" gets +2 for the oracle topic.
_TOPIC_TAGS: list[tuple[re.Pattern, str]] = [
    (re.compile(r"\b(oracle|price|liquidat|slippage|MEV)\w*", re.I),
     "involves oracle/pricing"),
    (re.compile(r"\b(reentran|overflow|underflow|front.?run)\w*", re.I),
     "involves known vulnerability pattern"),
    (re.compile(r"\b(ecrecover|permit|signature|nonce)\w*", re.I),
     "involves signatures/auth"),
]


def _classify_intent(subject: str) -> tuple[int, list[str]]:
    """Classify commit message: one primary intent + topic tag bonuses.

    Returns (total_score, list_of_reasons).
    Primary intent = first matching category (categorical).
    Topic tags = independent checks for domain relevance (small bonuses).
    """
    # Primary intent (first match wins)
    primary_score = 0
    reasons: list[str] = []
    primary_cat = None
    for cat, patterns, base_score, reason in _INTENT_RULES:
        if any(p.search(subject) for p in patterns):
            primary_score = base_score
            reasons.append(reason)
            primary_cat = cat
            break

    if primary_cat is None:
        reasons.append("unclassified")

    # Topic tags: small bonuses for domain signals not captured by
    # the primary intent. Only applied if primary intent is not
    # already negative (maintenance/feature).
    if primary_score >= 0:
        for pattern, tag_reason in _TOPIC_TAGS:
            if pattern.search(subject) and tag_reason not in reasons:
                # Don't double-count if primary already captured this
                if primary_cat != "security_explicit" or "vulnerability pattern" not in tag_reason:
                    primary_score += 2
                    reasons.append(tag_reason)

    return primary_score, reasons


# Phase 2: Structural diff analysis
# Instead of scanning for keyword presence in diff text, this
# phase counts ADDED vs REMOVED instances of code constructs
# to determine the DIRECTION of change. A commit that adds
# 3 require() and removes 1 is structurally different from one
# that just moves them around.

_GUARD_ADD = re.compile(r"^\+[^+].*\b(require|revert|assert)\s*\(", re.M)
_GUARD_REM = re.compile(r"^-[^-].*\b(require|revert|assert)\s*\(", re.M)
_MOD_ADD = re.compile(
    r"^\+[^+].*\b(onlyOwner|onlyRole|onlyAdmin|nonReentrant|whenNotPaused"
    r"|initializer|modifier\s+only)\b", re.M)
_MOD_REM = re.compile(
    r"^-[^-].*\b(onlyOwner|onlyRole|onlyAdmin|nonReentrant|whenNotPaused"
    r"|initializer|modifier\s+only)\b", re.M)
_XFER_CHANGE = re.compile(
    r"^[+-][^+-].*\b(safeTransfer\w*|\.transfer\(|transferFrom|\.call\{value)", re.M)
_SIG_CHANGE = re.compile(
    r"^[+-][^+-].*\b(ecrecover|permit|ECDSA|EIP.?712|nonce\b)", re.M)
_ACCT_CHANGE = re.compile(
    r"^[+-][^+-].*\b(balance\w*|totalSupply|exchangeRate|index\b|reserve)", re.M)


def _analyze_diff_structure(diff_text: str) -> list[tuple[int, str]]:
    """Detect structural changes in a unified diff.

    Counts added (+) vs removed (-) lines for each construct category.
    Both adding and removing guards are equally security-interesting —
    adding guards may fix a vulnerability, removing guards may introduce
    one. The direction is reported so auditors know what to look for,
    but the score weights both equally.

    Returns list of (score_delta, reason).
    """
    results: list[tuple[int, str]] = []

    # Guards: require/revert/assert — any change is security-relevant
    guards_added = len(_GUARD_ADD.findall(diff_text))
    guards_removed = len(_GUARD_REM.findall(diff_text))
    if guards_added > 0 or guards_removed > 0:
        if guards_added > guards_removed:
            results.append((3, f"adds runtime guards (+{guards_added}/-{guards_removed})"))
        elif guards_removed > guards_added:
            results.append((3, f"removes runtime guards (+{guards_added}/-{guards_removed})"))
        else:
            results.append((2, f"rewrites runtime guards (+{guards_added}/-{guards_removed})"))

    # Access modifiers — tightening and loosening both matter
    mods_added = len(_MOD_ADD.findall(diff_text))
    mods_removed = len(_MOD_REM.findall(diff_text))
    if mods_added > 0 or mods_removed > 0:
        if mods_added > mods_removed:
            results.append((3, f"tightens access control (+{mods_added}/-{mods_removed})"))
        elif mods_removed > mods_added:
            results.append((3, f"loosens access control (+{mods_added}/-{mods_removed})"))
        else:
            results.append((2, f"rewrites access control (+{mods_added}/-{mods_removed})"))

    # Transfer logic changes
    if _XFER_CHANGE.search(diff_text):
        results.append((2, "changes token transfer logic"))

    # Signature/auth changes
    if _SIG_CHANGE.search(diff_text):
        results.append((2, "changes signature/auth handling"))

    # Accounting/balance changes
    if _ACCT_CHANGE.search(diff_text):
        results.append((1, "changes accounting/balance logic"))

    return results

# ═══════════════════════════════════════════════════════════════
# SECURITY AREA CLASSIFICATION
# ═══════════════════════════════════════════════════════════════

SECURITY_AREAS = {
    "access_control": [
        r"onlyOwner", r"onlyRole", r"modifier\s+only", r"OwnableRoles",
        r"AccessControl", r"require\(msg\.sender", r"Ownable2Step",
        r"_checkOwner", r"hasRole", r"_checkRole", r"onlyAdmin",
    ],
    "fund_flows": [
        r"\.deposit\(", r"\.withdraw\(", r"\.transfer\(", r"\.mint\(",
        r"\.burn\(", r"collateral", r"safeTransfer", r"balanceOf",
        r"allowance", r"approve", r"_pay\b", r"_collect\b",
        r"function\s+deposit", r"function\s+withdraw",
    ],
    "oracle_price": [
        r"oracle", r"[Pp]rice", r"[Ff]eed", r"TWAP", r"markPrice",
        r"indexPrice", r"latestRoundData", r"getPrice", r"EMA",
        r"[Pp]rice[Hh]istory",
    ],
    "liquidation": [
        r"liquidat", r"backstop", r"ADL", r"[Dd]eleverage",
        r"[Ii]nsurance", r"insolvenc", r"bankruptcy", r"badDebt",
        r"isLiquidatable",
    ],
    "signatures": [
        r"ecrecover", r"permit", r"[Ss]ignature", r"EIP.?712",
        r"ECDSA", r"nonce", r"digest", r"_hashTypedData", r"v,\s*r,\s*s",
    ],
    "state_machines": [
        r"[Ss]tatus\s*=", r"[Ss]tate\s*=", r"Phase\b", r"Stage\b",
        r"[Ll]ifecycle", r"[Tt]ransition", r"[Pp]aused", r"[Ff]rozen",
        r"isActive", r"onlyActive", r"whenNotPaused",
    ],
}

_AREA_COMPILED = {
    area: [re.compile(p) for p in patterns]
    for area, patterns in SECURITY_AREAS.items()
}

# ═══════════════════════════════════════════════════════════════
# KNOWN LIBRARIES
# ═══════════════════════════════════════════════════════════════

KNOWN_LIBS = {
    "openzeppelin": {
        "patterns": ["openzeppelin-contracts", "openzeppelin"],
        "upstream_pragma": ["0.8."],
        "label": "OpenZeppelin",
    },
    "solady": {
        "patterns": ["solady"],
        "upstream_pragma": ["0.8."],
        "label": "Solady",
    },
    "uniswap_v2": {
        "patterns": ["uniswap", "univ2", "gte-univ2", "v2-core", "v2-periphery"],
        "upstream_pragma": [">=0.5.", "=0.5.", ">=0.6.", "=0.6."],
        "label": "Uniswap V2",
    },
    "uniswap_v3": {
        "patterns": ["v3-core", "v3-periphery", "uniswap-v3"],
        "upstream_pragma": [">=0.5.", "=0.7.", ">=0.7."],
        "label": "Uniswap V3",
    },
    "aave": {
        "patterns": ["aave"],
        "upstream_pragma": ["0.8."],
        "label": "Aave",
    },
    "chainlink": {
        "patterns": ["chainlink"],
        "upstream_pragma": ["0.8.", "0.6."],
        "label": "Chainlink",
    },
    "permit2": {
        "patterns": ["permit2"],
        "upstream_pragma": ["0.8."],
        "label": "Permit2",
    },
}

SKIP_LIB_ANALYSIS = {"forge-std", "ds-test", "forge-std-1"}

# ═══════════════════════════════════════════════════════════════
# PATH CLASSIFICATION
# ═══════════════════════════════════════════════════════════════

SOURCE_SUFFIXES = (".sol", ".vy", ".rs", ".cairo", ".move")
TEST_HINTS = ("test/", "tests/", "spec/", ".t.sol", ".spec.", "__tests__", "fuzz/")
EXCLUDE_DIRS = (
    "/lib/", "/node_modules/", "/forge-std/", "/out/",
    "/broadcast/", "/artifacts/", "/cache/",
)


def classify_path(path: str, src_dir: str) -> tuple[bool, bool]:
    """Classify a path as (is_source, is_test)."""
    lowered = path.lower()
    is_test = any(hint in lowered for hint in TEST_HINTS)

    if not any(path.endswith(s) for s in SOURCE_SUFFIXES):
        return False, is_test

    if any(exc in f"/{path}" for exc in EXCLUDE_DIRS):
        return False, is_test

    is_source = path.startswith(src_dir) and not is_test
    return is_source, is_test


def find_source_files(repo: str, src_dir: str) -> list[str]:
    """Walk filesystem for current .sol files in src_dir."""
    result = []
    src_path = os.path.join(repo, src_dir)
    if not os.path.isdir(src_path):
        return result
    for root, dirs, files in os.walk(src_path):
        # Prune excluded directories
        dirs[:] = [d for d in dirs if d not in (
            "test", "tests", "lib", "node_modules", "forge-std",
            "out", "broadcast", "artifacts", "cache", "script",
        )]
        for fname in files:
            if fname.endswith(".sol"):
                rel = os.path.relpath(os.path.join(root, fname), repo)
                result.append(rel)
    return sorted(result)


# ═══════════════════════════════════════════════════════════════
# GIT DATA COLLECTION
# ═══════════════════════════════════════════════════════════════

def run_git(repo: str, *args: str, allow_fail: bool = False) -> str:
    cmd = ["git", "-C", repo] + list(args)
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, check=True)
        return result.stdout
    except subprocess.CalledProcessError:
        if allow_fail:
            return ""
        raise


def parse_git_log(repo: str, src_dir: str) -> list[Commit]:
    """Parse full git history in a single call."""
    sep = "<<SEP>>"
    # Use %x00 in git format to produce null bytes in output (not in args)
    fmt = f"COMMIT_START{sep}%H{sep}%h{sep}%aI{sep}%aN{sep}%P{sep}%s"
    raw = run_git(repo, "log", "--numstat", f"--format={fmt}")

    commits = []
    current: Commit | None = None
    for line in raw.splitlines():
        if line.startswith(f"COMMIT_START{sep}"):
            if current is not None:
                commits.append(current)
            parts = line.split(sep)
            if len(parts) < 7:
                current = None
                continue
            _, sha, short, date, author, parents, subject = parts[:7]
            is_merge = " " in parents.strip()
            current = Commit(
                sha=sha, short_sha=short, date=date[:10],
                author=author, subject=subject, is_merge=is_merge,
            )
        elif current is not None and line.strip():
            parts = line.split("\t")
            if len(parts) >= 3:
                try:
                    added = int(parts[0]) if parts[0] != "-" else 0
                    deleted = int(parts[1]) if parts[1] != "-" else 0
                except ValueError:
                    continue
                path = parts[2]
                is_src, is_tst = classify_path(path, src_dir)
                current.files.append(FileChange(
                    path=path, added=added, deleted=deleted,
                    is_source=is_src, is_test=is_tst,
                ))

    if current is not None:
        commits.append(current)
    return commits


# ═══════════════════════════════════════════════════════════════
# SECTION 1: REPO SHAPE
# ═══════════════════════════════════════════════════════════════

def analyze_repo_shape(commits: list[Commit], src_dir: str) -> dict:
    if not commits:
        return {
            "classification": "empty",
            "total_commits": 0,
            "source_touching_commits": 0,
            "bulk_import_sha": None,
            "date_spread_days": 0,
            "first_commit_date": None,
            "last_commit_date": None,
            "signals": ["Empty repository"],
        }

    source_commits = [c for c in commits if c.source_files]
    dates = sorted(c.date for c in commits)
    first = dates[0]
    last = dates[-1]

    try:
        d1 = datetime.strptime(first, "%Y-%m-%d")
        d2 = datetime.strptime(last, "%Y-%m-%d")
        spread = (d2 - d1).days
    except ValueError:
        spread = 0

    # Detect bulk import
    bulk_sha = None
    signals = []
    total_source_added = sum(
        sum(f.added for f in c.source_files)
        for c in source_commits
    )
    if source_commits:
        # Sort by source lines added, descending
        biggest = max(source_commits, key=lambda c: sum(f.added for f in c.source_files))
        biggest_added = sum(f.added for f in biggest.source_files)
        if total_source_added > 0 and biggest_added / total_source_added > 0.85:
            bulk_sha = biggest.short_sha
            signals.append(
                f"~{biggest_added} source lines arrived in 1 commit ({bulk_sha})"
            )

    # Classification
    classification = "normal_dev"
    if len(source_commits) <= 1:
        classification = "squashed_import"
        signals.append("Only 1 commit touches source files")
    elif len(source_commits) <= 3 and spread < 7:
        classification = "squashed_import"
        signals.append(f"Only {len(source_commits)} source commits in {spread} days")

    if bulk_sha and classification == "normal_dev":
        # Has bulk import but also real development after
        signals.append("Bulk import detected with subsequent development")

    signals.append(f"Date spread: {spread} days")
    signals.append(f"{len(source_commits)} commits touch source files out of {len(commits)} total")

    return {
        "classification": classification,
        "total_commits": len(commits),
        "source_touching_commits": len(source_commits),
        "bulk_import_sha": bulk_sha,
        "date_spread_days": spread,
        "first_commit_date": first,
        "last_commit_date": last,
        "signals": signals,
    }


# ═══════════════════════════════════════════════════════════════
# SECTION 2: FIX CANDIDATES
# ═══════════════════════════════════════════════════════════════

def score_commit(
    commit: Commit,
    src_dir: str,
    diff_text: str = "",
    file_areas_cache: dict[str, list[str]] | None = None,
) -> tuple[int, list[str]]:
    """Score a commit for security-fix likelihood.

    Uses a multi-phase approach:
      1. Classify message intent (categorical, first-match)
      2. Analyze diff structure (directional: added vs removed guards)
      3. Check security domain overlap (cross-ref with SECURITY_AREAS)
      4. Apply shape modifiers (focus, churn)

    The final score is the sum of phase contributions, floored at 0.
    """
    reasons: list[str] = []

    # ── Phase 1: Intent classification ──────────────────────────
    # Primary intent (categorical) + topic tag bonuses
    intent_score, intent_reasons = _classify_intent(commit.subject)
    reasons.extend(intent_reasons)

    src_files = commit.source_files
    if not src_files:
        return max(intent_score, 0), reasons

    # ── Phase 2: Structural diff analysis ───────────────────────
    # Counts added vs removed code constructs to determine the
    # direction of change, not just presence of keywords
    structural_score = 0
    if diff_text:
        for delta, reason in _analyze_diff_structure(diff_text):
            structural_score += delta
            reasons.append(reason)

    # ── Phase 3: Security domain overlap ────────────────────────
    # Cross-references changed files against SECURITY_AREAS
    # classification. A commit touching multiple security domains
    # (e.g. access_control + fund_flows) is more interesting.
    domain_score = 0
    if file_areas_cache is not None:
        touched_domains: set[str] = set()
        for fc in src_files:
            for area in file_areas_cache.get(fc.path, []):
                touched_domains.add(area)
        if len(touched_domains) >= 2:
            domain_score = 3
            reasons.append(
                f"spans {len(touched_domains)} security domains "
                f"({', '.join(sorted(touched_domains))})"
            )
        elif len(touched_domains) == 1:
            domain_score = 1
            reasons.append(f"touches {next(iter(touched_domains))} code")

    # ── Phase 4: Shape modifiers ────────────────────────────────
    shape_score = 0

    # Focused changes (few files) are more likely targeted fixes
    if 1 <= len(src_files) <= 3:
        shape_score += 2
        reasons.append(f"focused change ({len(src_files)} source files)")

    # Net code removal suggests removing vulnerable paths
    net_deleted = sum(f.deleted - f.added for f in src_files)
    if net_deleted > 0:
        shape_score += 1
        reasons.append("net code removal")

    # Large bulk changes are likely features or refactors
    src_churn = commit.source_churn
    if src_churn > 2000:
        shape_score -= 4
        reasons.append("very large change (>2000 source lines)")
    elif src_churn > 500:
        shape_score -= 2
        reasons.append("large change (>500 source lines)")

    # Test co-change is informative (either direction)
    if commit.test_files:
        shape_score += 1
        reasons.append("includes test changes")

    total = intent_score + structural_score + domain_score + shape_score
    return max(total, 0), _unique(reasons)


def find_fix_candidates(
    commits: list[Commit],
    src_dir: str,
    repo: str,
    limit: int,
    file_areas_cache: dict[str, list[str]] | None = None,
) -> list[dict]:
    """Score all commits, return top N fix candidates.

    Uses intent classification + structural diff analysis + security
    domain cross-referencing to identify likely security fixes.
    """
    candidates = []
    for commit in commits:
        if commit.is_merge:
            continue
        # Get diff text for source-touching commits only
        diff_text = ""
        if commit.source_files:
            diff_text = run_git(
                repo, "show", "--format=", "--unified=0",
                "--no-ext-diff", commit.sha,
                allow_fail=True,
            )
        sc, reasons = score_commit(
            commit, src_dir, diff_text, file_areas_cache,
        )
        if sc > 0:
            candidates.append({
                "sha": commit.short_sha,
                "full_sha": commit.sha,
                "date": commit.date,
                "author": commit.author,
                "subject": commit.subject,
                "score": sc,
                "reasons": reasons,
                "source_files_touched": [f.path for f in commit.source_files],
                "test_changed": bool(commit.test_files),
                "lines_changed": commit.source_churn,
            })

    candidates.sort(key=lambda c: (c["score"], c["date"]), reverse=True)
    if limit > 0:
        candidates = candidates[:limit]
    return candidates


# ═══════════════════════════════════════════════════════════════
# SECTION 3: DANGEROUS AREA CHANGES
# ═══════════════════════════════════════════════════════════════

def _read_file_safe(path: str) -> str:
    try:
        with open(path, "r", errors="replace") as f:
            return f.read()
    except (OSError, IOError):
        return ""


def classify_file_areas(content: str) -> list[str]:
    """Determine which security areas a file's content touches."""
    areas = []
    for area, patterns in _AREA_COMPILED.items():
        for pat in patterns:
            if pat.search(content):
                areas.append(area)
                break
    return areas


def _build_file_areas_cache(repo: str, src_dir: str) -> dict[str, list[str]]:
    """Build a mapping of file paths to their security area classifications.

    Shared by both fix candidate scoring (Phase 3: domain overlap) and
    dangerous area analysis.
    """
    cache: dict[str, list[str]] = {}
    src_path = os.path.join(repo, src_dir)
    if os.path.isdir(src_path):
        for root, dirs, files in os.walk(src_path):
            dirs[:] = [d for d in dirs if d not in (
                "test", "tests", "lib", "node_modules", "script",
            )]
            for fname in files:
                if fname.endswith(".sol"):
                    full = os.path.join(root, fname)
                    rel = os.path.relpath(full, repo)
                    content = _read_file_safe(full)
                    cache[rel] = classify_file_areas(content)

    # Also classify files in lib/ that are source-like
    lib_path = os.path.join(repo, "lib")
    if os.path.isdir(lib_path):
        for root, dirs, files in os.walk(lib_path):
            dirs[:] = [d for d in dirs if d not in (
                "test", "tests", "node_modules", "forge-std",
            )]
            for fname in files:
                if fname.endswith(".sol"):
                    full = os.path.join(root, fname)
                    rel = os.path.relpath(full, repo)
                    if rel not in cache:
                        content = _read_file_safe(full)
                        cache[rel] = classify_file_areas(content)

    return cache


def analyze_dangerous_areas(
    commits: list[Commit],
    src_dir: str,
    repo: str,
    file_areas_cache: dict[str, list[str]] | None = None,
) -> dict:
    """Group commits by security area they affect."""
    if file_areas_cache is None:
        file_areas_cache = _build_file_areas_cache(repo, src_dir)

    # Map commits to areas
    result: dict[str, dict] = {}
    for area in SECURITY_AREAS:
        result[area] = {"commit_count": 0, "files": set(), "commits": []}

    for commit in commits:
        if commit.is_merge:
            continue
        commit_areas: set[str] = set()
        for fc in commit.files:
            areas = file_areas_cache.get(fc.path, [])
            for a in areas:
                commit_areas.add(a)
                result[a]["files"].add(fc.path)

        for a in commit_areas:
            result[a]["commit_count"] += 1
            result[a]["commits"].append({
                "sha": commit.short_sha,
                "date": commit.date,
                "subject": commit.subject[:80],
            })

    # Convert sets to sorted lists, remove empty areas
    final = {}
    for area, data in result.items():
        if data["commit_count"] > 0:
            data["files"] = sorted(data["files"])
            # Cap commit list at 15
            if len(data["commits"]) > 15:
                data["commits"] = data["commits"][:15]
                data["truncated"] = True
            final[area] = data

    return final


# ═══════════════════════════════════════════════════════════════
# SECTION 4: LATE CHANGES
# ═══════════════════════════════════════════════════════════════

def analyze_late_changes(
    commits: list[Commit], src_dir: str, days: int
) -> dict:
    """Find commits touching source in the last N days of repo activity."""
    if not commits:
        return {
            "window_days": days,
            "cutoff_date": None,
            "latest_commit_date": None,
            "late_commits": [],
            "source_without_test_count": 0,
            "total_late_source_commits": 0,
        }

    # Find latest date
    dates = []
    for c in commits:
        try:
            dates.append(datetime.strptime(c.date, "%Y-%m-%d"))
        except ValueError:
            pass

    if not dates:
        return {
            "window_days": days,
            "cutoff_date": None,
            "latest_commit_date": None,
            "late_commits": [],
            "source_without_test_count": 0,
            "total_late_source_commits": 0,
        }

    latest = max(dates)
    cutoff = latest - timedelta(days=days)
    cutoff_str = cutoff.strftime("%Y-%m-%d")
    latest_str = latest.strftime("%Y-%m-%d")

    late = []
    no_test_count = 0
    for c in commits:
        try:
            cdate = datetime.strptime(c.date, "%Y-%m-%d")
        except ValueError:
            continue
        if cdate < cutoff:
            continue
        if not c.source_files:
            continue
        has_test = bool(c.test_files)
        if not has_test:
            no_test_count += 1
        late.append({
            "sha": c.short_sha,
            "date": c.date,
            "author": c.author,
            "subject": c.subject[:80],
            "source_files": [f.path for f in c.source_files][:10],
            "test_changed": has_test,
            "lines_changed": c.source_churn,
        })

    return {
        "window_days": days,
        "cutoff_date": cutoff_str,
        "latest_commit_date": latest_str,
        "late_commits": late,
        "source_without_test_count": no_test_count,
        "total_late_source_commits": len(late),
    }


# ═══════════════════════════════════════════════════════════════
# SECTION 5: FORKED DEPENDENCIES
# ═══════════════════════════════════════════════════════════════

def _detect_lib_identity(dirname: str) -> str | None:
    """Match a lib directory name to a known library."""
    lower = dirname.lower()
    for lib_id, info in KNOWN_LIBS.items():
        for pattern in info["patterns"]:
            if pattern.lower() in lower:
                return lib_id
    return None


def _extract_pragmas(sol_dir: str) -> list[str]:
    """Extract unique pragma versions from .sol files in a directory."""
    pragmas = set()
    if not os.path.isdir(sol_dir):
        return []
    for root, _, files in os.walk(sol_dir):
        for fname in files:
            if not fname.endswith(".sol"):
                continue
            try:
                with open(os.path.join(root, fname), "r", errors="replace") as f:
                    for line in f:
                        m = re.match(r"\s*pragma\s+solidity\s+(.+?)\s*;", line)
                        if m:
                            pragmas.add(m.group(1).strip())
                            break
            except (OSError, IOError):
                continue
    return sorted(pragmas)


def _count_sol_files(dirpath: str) -> int:
    count = 0
    if not os.path.isdir(dirpath):
        return 0
    for root, _, files in os.walk(dirpath):
        for f in files:
            if f.endswith(".sol"):
                count += 1
    return count


def _check_pragma_mismatch(
    found_pragmas: list[str], expected_prefixes: list[str]
) -> list[str]:
    """Check if pragmas differ from expected upstream versions."""
    notes = []
    for pragma in found_pragmas:
        matches_expected = any(
            pragma.startswith(prefix) or prefix in pragma
            for prefix in expected_prefixes
        )
        if not matches_expected:
            notes.append(f"Pragma '{pragma}' differs from expected upstream versions")
    return notes


def analyze_forked_deps(repo: str) -> dict:
    """Detect internalized/forked libraries."""
    lib_dir = os.path.join(repo, "lib")
    detected = []

    # Check current .gitmodules for active submodules
    gitmodules_path = os.path.join(repo, ".gitmodules")
    active_submodules = set()
    if os.path.isfile(gitmodules_path):
        try:
            with open(gitmodules_path, "r") as f:
                for line in f:
                    m = re.match(r"\s*path\s*=\s*(.+)", line)
                    if m:
                        active_submodules.add(m.group(1).strip())
        except (OSError, IOError):
            pass

    # Scan lib/ directories
    if os.path.isdir(lib_dir):
        for entry in sorted(os.listdir(lib_dir)):
            entry_path = os.path.join(lib_dir, entry)
            if not os.path.isdir(entry_path):
                continue
            if entry in SKIP_LIB_ANALYSIS:
                continue

            lib_rel = f"lib/{entry}"
            lib_id = _detect_lib_identity(entry)
            sol_count = _count_sol_files(entry_path)

            if sol_count == 0:
                continue

            is_submodule = lib_rel in active_submodules
            is_internalized = not is_submodule and not os.path.isdir(
                os.path.join(entry_path, ".git")
            )
            # Also check for submodule pointer file (single-line file with commit hash)
            gitfile = os.path.join(entry_path, ".git")
            if os.path.isfile(gitfile):
                is_submodule = True
                is_internalized = False

            pragmas = _extract_pragmas(entry_path)
            notes = []

            if lib_id and lib_id in KNOWN_LIBS:
                label = KNOWN_LIBS[lib_id]["label"]
                expected = KNOWN_LIBS[lib_id]["upstream_pragma"]
                pragma_notes = _check_pragma_mismatch(pragmas, expected)
                notes.extend(pragma_notes)
                if is_internalized:
                    notes.append(f"Internalized (not a submodule) — may contain modifications from upstream {label}")
            else:
                label = entry
                if is_internalized:
                    notes.append("Internalized (not a submodule) — unknown upstream")

            detected.append({
                "name": entry,
                "path": lib_rel,
                "known_upstream": label if lib_id else None,
                "is_submodule": is_submodule,
                "is_internalized": is_internalized,
                "sol_file_count": sol_count,
                "pragma_versions": pragmas,
                "notes": notes,
            })

    # Check git history for removed submodules
    removed = []
    gitmodules_log = run_git(
        repo, "log", "-p", "--", ".gitmodules",
        allow_fail=True,
    )
    if gitmodules_log:
        current_sha = None
        current_subject = None
        for line in gitmodules_log.splitlines():
            m = re.match(r"^commit\s+([a-f0-9]+)", line)
            if m:
                current_sha = m.group(1)[:7]
                current_subject = None
                continue
            if line.startswith("    ") and current_subject is None:
                current_subject = line.strip()[:80]
                continue
            # Detect removed submodule path lines
            m = re.match(r"^-\s*path\s*=\s*(.+)", line)
            if m and current_sha:
                removed_path = m.group(1).strip()
                removed.append({
                    "path": removed_path,
                    "removed_in_sha": current_sha,
                    "subject": current_subject or "",
                })

    return {
        "detected_libs": detected,
        "removed_submodules": removed,
    }


# ═══════════════════════════════════════════════════════════════
# SECTION 6: TECH DEBT
# ═══════════════════════════════════════════════════════════════

_DEBT_RE = re.compile(
    r"(?://|/\*)\s*(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)",
    re.IGNORECASE,
)

BLAME_CAP = 20  # Max files to blame (performance guard)


def find_tech_debt(source_files: list[str], repo: str) -> dict:
    """Find TODO/FIXME/HACK/XXX in source files with git blame."""
    items = []
    files_with_debt = set()

    for rel_path in source_files:
        full_path = os.path.join(repo, rel_path)
        try:
            with open(full_path, "r", errors="replace") as f:
                lines = f.readlines()
        except (OSError, IOError):
            continue

        for i, line in enumerate(lines, 1):
            m = _DEBT_RE.search(line)
            if m:
                files_with_debt.add(rel_path)
                items.append({
                    "file": rel_path,
                    "line": i,
                    "type": m.group(1).upper(),
                    "text": m.group(2).strip()[:120] if m.group(2) else "",
                    "blame_author": None,
                    "blame_date": None,
                })

    # Git blame for attribution (capped)
    blame_files = sorted(files_with_debt)[:BLAME_CAP]
    capped = len(files_with_debt) > BLAME_CAP

    # Build a lookup: file -> {line: (author, date)}
    blame_lookup: dict[str, dict[int, tuple[str, str]]] = {}
    for rel_path in blame_files:
        blame_out = run_git(
            repo, "blame", "--porcelain", rel_path,
            allow_fail=True,
        )
        if not blame_out:
            continue
        file_blame: dict[int, tuple[str, str]] = {}
        current_author = ""
        current_date = ""
        current_line = 0
        for bline in blame_out.splitlines():
            # First line of each block: <sha> <orig_line> <final_line> [<count>]
            m = re.match(r"^[a-f0-9]{40}\s+\d+\s+(\d+)", bline)
            if m:
                current_line = int(m.group(1))
                continue
            if bline.startswith("author "):
                current_author = bline[7:].strip()
            elif bline.startswith("author-time "):
                try:
                    ts = int(bline[12:].strip())
                    current_date = datetime.fromtimestamp(
                        ts, tz=timezone.utc
                    ).strftime("%Y-%m-%d")
                except (ValueError, OSError):
                    current_date = ""
            elif bline.startswith("\t"):
                # Content line — save blame for this line number
                if current_line > 0:
                    file_blame[current_line] = (current_author, current_date)

        blame_lookup[rel_path] = file_blame

    # Enrich items with blame
    for item in items:
        bl = blame_lookup.get(item["file"], {})
        info = bl.get(item["line"])
        if info:
            item["blame_author"] = info[0]
            item["blame_date"] = info[1]

    return {
        "total_count": len(items),
        "items": items,
        "files_with_debt": len(files_with_debt),
        "capped": capped,
    }


# ═══════════════════════════════════════════════════════════════
# SECTION 7: DEV PATTERNS
# ═══════════════════════════════════════════════════════════════

def analyze_dev_patterns(
    commits: list[Commit],
    source_files: list[str],
    repo: str,
    src_dir: str,
    fix_candidates: list[dict],
    bulk_import_sha: str | None,
) -> dict:
    """Compute developer pattern metrics."""
    # Filter out merge commits and optionally bulk import
    non_merge = [c for c in commits if not c.is_merge]
    source_commits = [c for c in non_merge if c.source_files]
    analysis_commits = source_commits
    if bulk_import_sha:
        analysis_commits = [
            c for c in source_commits
            if c.short_sha != bulk_import_sha
        ]

    # 1. Test co-change rate
    if source_commits:
        with_tests = sum(1 for c in source_commits if c.test_files)
        test_co_change = with_tests / len(source_commits)
    else:
        test_co_change = 0.0

    # 2. Fix without test rate
    fix_without_test = None
    if fix_candidates:
        no_test = sum(1 for f in fix_candidates if not f["test_changed"])
        fix_without_test = no_test / len(fix_candidates)

    # 4. Avg commit size (excluding bulk import)
    if analysis_commits:
        avg_size = sum(c.source_churn for c in analysis_commits) / len(analysis_commits)
    else:
        avg_size = 0.0
    size_note = "excluding bulk import" if bulk_import_sha and len(analysis_commits) != len(source_commits) else None

    # 5. Single developer percentage
    author_lines: dict[str, int] = {}
    for c in source_commits:
        added = sum(f.added for f in c.source_files)
        author_lines[c.author] = author_lines.get(c.author, 0) + added

    total_lines = sum(author_lines.values())
    breakdown = []
    if total_lines > 0:
        for author, lines in sorted(
            author_lines.items(), key=lambda x: x[1], reverse=True
        ):
            breakdown.append({
                "author": author,
                "lines_added": lines,
                "pct": round(lines / total_lines, 3),
            })

    top_contributor = breakdown[0]["author"] if breakdown else "unknown"
    single_dev_pct = breakdown[0]["pct"] if breakdown else 0.0

    return {
        "test_co_change_rate": round(test_co_change, 3),
        "fix_without_test_rate": round(fix_without_test, 3) if fix_without_test is not None else None,
        "avg_commit_size": round(avg_size, 1),
        "avg_commit_size_note": size_note,
        "single_developer_pct": round(single_dev_pct, 3),
        "top_contributor": top_contributor,
        "contributor_breakdown": breakdown[:10],
    }


# ═══════════════════════════════════════════════════════════════
# UTILITY
# ═══════════════════════════════════════════════════════════════

def _unique(items: list[str]) -> list[str]:
    seen: set[str] = set()
    result: list[str] = []
    for item in items:
        if item not in seen:
            result.append(item)
            seen.add(item)
    return result


def detect_src_dir(repo: str) -> str:
    """Auto-detect source directory from foundry.toml or common patterns."""
    toml_path = os.path.join(repo, "foundry.toml")
    if os.path.isfile(toml_path):
        try:
            with open(toml_path, "r") as f:
                for line in f:
                    m = re.match(r'\s*src\s*=\s*["\'](.+?)["\']', line)
                    if m:
                        return m.group(1).rstrip("/") + "/"
        except (OSError, IOError):
            pass

    # Fallback: check common directories
    for candidate in ["contracts/", "src/"]:
        if os.path.isdir(os.path.join(repo, candidate)):
            return candidate
    return "src/"


# ═══════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════

def main() -> int:
    parser = argparse.ArgumentParser(
        description="Git history security analysis for Solidity repositories",
    )
    parser.add_argument("--repo", default=".", help="Path to git repository")
    parser.add_argument("--json", default=None, help="Output JSON to file (default: stdout)")
    parser.add_argument("--days", type=int, default=30, help="Late change window (days)")
    parser.add_argument("--limit", type=int, default=10, help="Max fix candidates")
    parser.add_argument("--src-dir", default=None, help="Source directory (auto-detected)")
    args = parser.parse_args()

    repo = os.path.abspath(args.repo)
    src_dir = args.src_dir or detect_src_dir(repo)
    # Ensure trailing slash for consistent prefix matching
    if not src_dir.endswith("/"):
        src_dir += "/"

    t0 = time.monotonic()

    # Verify git repo
    try:
        head = run_git(repo, "rev-parse", "--short", "HEAD").strip()
    except subprocess.CalledProcessError:
        err = {"error": f"{repo} is not a git repository"}
        _write_output(err, args.json)
        return 2

    # Detect current branch name
    try:
        branch = run_git(repo, "rev-parse", "--abbrev-ref", "HEAD").strip()
    except subprocess.CalledProcessError:
        branch = "unknown"

    # Phase 1: Collect git data
    commits = parse_git_log(repo, src_dir)

    # Phase 2: Find source files
    source_files = find_source_files(repo, src_dir)

    # Phase 3: Run analyzers
    repo_shape = analyze_repo_shape(commits, src_dir)

    # Build file→security-area cache once, shared by fix detection
    # and dangerous area analysis
    file_areas_cache = _build_file_areas_cache(repo, src_dir)

    fix_cands = find_fix_candidates(
        commits, src_dir, repo, args.limit, file_areas_cache,
    )
    dangerous = analyze_dangerous_areas(
        commits, src_dir, repo, file_areas_cache,
    )
    late = analyze_late_changes(commits, src_dir, args.days)
    forked = analyze_forked_deps(repo)
    debt = find_tech_debt(source_files, repo)
    patterns = analyze_dev_patterns(
        commits, source_files, repo, src_dir,
        fix_cands, repo_shape.get("bulk_import_sha"),
    )

    elapsed_ms = int((time.monotonic() - t0) * 1000)

    result = {
        "meta": {
            "repo": repo,
            "src_dir": src_dir.rstrip("/"),
            "generated_at": datetime.now(timezone.utc).isoformat(),
            "git_head": head,
            "git_branch": branch,
            "total_commits": len(commits),
            "total_source_files": len(source_files),
            "analysis_time_ms": elapsed_ms,
        },
        "repo_shape": repo_shape,
        "fix_candidates": fix_cands,
        "dangerous_area_changes": dangerous,
        "late_changes": late,
        "forked_deps": forked,
        "tech_debt": debt,
        "dev_patterns": patterns,
    }

    _write_output(result, args.json)
    return 0


def _write_output(data: dict, filepath: str | None) -> None:
    text = json.dumps(data, indent=2)
    if filepath:
        with open(filepath, "w") as f:
            f.write(text)
            f.write("\n")
    else:
        print(text)


if __name__ == "__main__":
    raise SystemExit(main())
```

## scripts/enumerate.sh

```bash

```

## scripts/generate_svg.py

```python
#!/usr/bin/env python3
"""Generate architecture SVG from JSON config. No external dependencies.

Usage: python3 generate_svg.py input.json output.svg

JSON format:
{
  "title": "Protocol Architecture",
  "nodes": [{"id": "x", "label": "Name", "subtitle": "Role", "type": "actor|protocol|external", "row": 0}],
  "edges": [{"from": "x", "to": "y", "label": "description"}],
  "groups": [{"label": "Group Name", "nodes": ["id1", "id2"]}]
}

"row" is optional — if omitted, layers are auto-assigned via longest-path.
"subtitle" is optional — shown as second line inside box.
"groups" is optional — draws an enclosure around the listed nodes.
"""

import json, sys, math
from collections import defaultdict, deque

# ── Dimensions ──
ACTOR_H = 14
BOX_H = 20
BOX_RX = 3
PILL_RX = 7
ACCENT_W = 1.4
ACCENT_PAD = 3
CHAR_W = 4.8
LABEL_PAD = 20
MIN_BOX_W = 60
MIN_ACTOR_W = 56
Y0 = 32
TITLE_Y = 11
LEGEND_STRIP_Y = 16
HGAP = 22             # min horizontal gap between nodes
VGAP = 62             # vertical gap between row origins
GROUP_PAD = 10
MARGIN_X = 30
MARGIN_BOTTOM = 20
SAME_ROW_ARC = 22     # how far same-row arcs rise above connection point
BELOW_ARC_GAP = 12    # base gap below boxes for below-routing of crossing same-row edges

# ── Colors ──
BG = "#F8F9FB"
BOX_FILL = "#fff"
BOX_STROKE = "#E2E5EA"
STROKE_W = 0.5
ACCENT_BLUE = "#3B82F6"
ACCENT_AMBER = "#F59E0B"
ARROW_COLOR = "#94A3B8"
ARROW_W = 0.5
TITLE_COLOR = "#1E293B"
LABEL_COLOR = "#1E293B"
SUB_COLOR = "#64748B"
FLOW_COLOR = "#1E293B"
LEGEND_COLOR = "#94A3B8"
HALO_COLOR = BG
HALO_W = 2
FONT_TITLE = 6
FONT_LABEL = 4.8
FONT_SUB = 3.5
FONT_FLOW = 4
FONT_LEGEND = 3
SCALE = 2.5


def esc(s):
    return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


def node_w(n):
    t = n.get("type", "protocol")
    label_w = len(n["label"]) * CHAR_W + LABEL_PAD
    sub = n.get("subtitle", "")
    if sub:
        label_w = max(label_w, len(sub) * (CHAR_W * 0.75) + LABEL_PAD)
    if t == "actor":
        return max(MIN_ACTOR_W, label_w)
    return max(MIN_BOX_W, label_w)


def node_h(n):
    return ACTOR_H if n.get("type", "protocol") == "actor" else BOX_H


# ═══════════════════════════════════════════════════════════════
# LAYER ASSIGNMENT
# ═══════════════════════════════════════════════════════════════

def auto_layers(node_ids, fwd, bwd):
    layer = {}
    visited = set()

    def dfs(n):
        if n in visited:
            return layer.get(n, 0)
        visited.add(n)
        if not bwd[n]:
            layer[n] = 0
            return 0
        d = max(dfs(p) for p in bwd[n]) + 1
        layer[n] = d
        return d

    for nid in node_ids:
        if nid not in visited:
            dfs(nid)
    mx = max(layer.values(), default=0)
    for nid in node_ids:
        layer.setdefault(nid, mx + 1)
    return layer


# ═══════════════════════════════════════════════════════════════
# DUMMY NODES for multi-layer edges
# ═══════════════════════════════════════════════════════════════

def insert_dummy_nodes(node_ids, nodes, edges, row_of, fwd, bwd):
    new_edges = []
    dummy_id = 0
    dummy_chains = {}

    for e in edges:
        fi, ti = e["from"], e["to"]
        fr, tr = row_of.get(fi, 0), row_of.get(ti, 0)
        span = abs(tr - fr)

        if span <= 1:
            new_edges.append(e)
            continue

        direction = 1 if tr > fr else -1
        chain = [fi]
        prev = fi
        for step in range(1, span):
            did = f"__d{dummy_id}"
            dummy_id += 1
            r = fr + step * direction
            row_of[did] = r
            nodes[did] = {"id": did, "label": "", "type": "__dummy", "row": r}
            node_ids.append(did)
            chain.append(did)
            new_edges.append({"from": prev, "to": did, "label": ""})
            fwd[prev].append(did)
            bwd[did].add(prev)
            prev = did

        new_edges.append({"from": prev, "to": ti, "label": e.get("label", "")})
        fwd[prev].append(ti)
        bwd[ti].add(prev)
        chain.append(ti)
        dummy_chains[(fi, ti)] = chain

    return node_ids, nodes, new_edges, row_of, fwd, bwd, dummy_chains


# ═══════════════════════════════════════════════════════════════
# CROSSING MINIMIZATION
# ═══════════════════════════════════════════════════════════════

def count_crossings(by_row, rows, fwd, row_of):
    total = 0
    for ri in range(len(rows) - 1):
        r, rn = rows[ri], rows[ri + 1]
        top, bot = by_row[r], by_row[rn]
        top_pos = {n: i for i, n in enumerate(top)}
        bot_pos = {n: i for i, n in enumerate(bot)}
        pairs = []
        for n in top:
            for c in fwd.get(n, []):
                if row_of.get(c) == rn and c in bot_pos:
                    pairs.append((top_pos[n], bot_pos[c]))
        for i in range(len(pairs)):
            for j in range(i + 1, len(pairs)):
                if (pairs[i][0] - pairs[j][0]) * (pairs[i][1] - pairs[j][1]) < 0:
                    total += 1
    return total


def barycenter_order(by_row, fwd, bwd, row_of):
    rows = sorted(by_row)
    if len(rows) <= 1:
        return
    order = {}
    for r in rows:
        for i, n in enumerate(by_row[r]):
            order[n] = float(i)

    best_crossings = count_crossings(by_row, rows, fwd, row_of)
    best_order = {r: list(by_row[r]) for r in rows}

    for iteration in range(30):
        for r in rows[1:]:
            for n in by_row[r]:
                pp = [order[p] for p in bwd.get(n, set())
                      if row_of.get(p) == r - 1 and p in order]
                if pp:
                    order[n] = sum(pp) / len(pp)
            by_row[r].sort(key=lambda n: order[n])
            for i, n in enumerate(by_row[r]):
                order[n] = float(i)
        for r in reversed(rows[:-1]):
            for n in by_row[r]:
                cc = [order[c] for c in fwd.get(n, [])
                      if row_of.get(c) == r + 1 and c in order]
                if cc:
                    order[n] = sum(cc) / len(cc)
            by_row[r].sort(key=lambda n: order[n])
            for i, n in enumerate(by_row[r]):
                order[n] = float(i)

        c = count_crossings(by_row, rows, fwd, row_of)
        if c < best_crossings:
            best_crossings = c
            best_order = {r: list(by_row[r]) for r in rows}
        elif iteration > 8 and c > best_crossings:
            break

    for r in rows:
        by_row[r] = list(best_order[r])


# ═══════════════════════════════════════════════════════════════
# COORDINATE ASSIGNMENT
# ═══════════════════════════════════════════════════════════════

def assign_coordinates(by_row, nodes, row_of, fwd, bwd):
    """Two-phase coordinate assignment:
    Phase 1: Place real nodes using parent barycenters, then compact.
    Phase 2: Place dummy nodes at interpolated positions along their edge.
    """
    rows = sorted(by_row)
    dims = {}
    for r in rows:
        for nid in by_row[r]:
            n = nodes[nid]
            if n.get("type") == "__dummy":
                dims[nid] = (0, 0)
            else:
                dims[nid] = (node_w(n), node_h(n))

    pos_x = {}

    # Phase 1: Place real nodes top-down using parent barycenters
    for ri, r in enumerate(rows):
        real_ns = [n for n in by_row[r] if nodes[n].get("type") != "__dummy"]
        dummy_ns = [n for n in by_row[r] if nodes[n].get("type") == "__dummy"]

        if ri == 0:
            # First row: place evenly
            _place_evenly(real_ns, pos_x, dims)
        else:
            # Compute target x from parents in the row above
            target = {}
            for nid in real_ns:
                parents = [p for p in bwd.get(nid, set())
                           if row_of.get(p) == r - 1 and p in pos_x]
                if parents:
                    target[nid] = sum(pos_x[p] for p in parents) / len(parents)
                else:
                    # No parent in row above; try grandparents or use default
                    all_parents = [p for p in bwd.get(nid, set()) if p in pos_x]
                    if all_parents:
                        target[nid] = sum(pos_x[p] for p in all_parents) / len(all_parents)
                    else:
                        target[nid] = None

            # Sort real nodes by target (keeping order for those without targets)
            orig_idx = {n: i for i, n in enumerate(real_ns)}
            def sort_key(n):
                if target.get(n) is not None:
                    return target[n]
                return orig_idx[n] * 1000  # preserve relative order
            real_ns.sort(key=sort_key)

            # Place left-to-right, pulling toward target
            _place_with_targets(real_ns, pos_x, dims, target)

        # Phase 2: Place dummies between their source and target positions
        for did in dummy_ns:
            parents = [p for p in bwd.get(did, set()) if p in pos_x]
            children = [c for c in fwd.get(did, []) if c in pos_x]
            xs = [pos_x[p] for p in parents] + [pos_x[c] for c in children]
            if xs:
                pos_x[did] = sum(xs) / len(xs)
            else:
                # Fallback: interpolate from all neighbors
                pos_x[did] = MARGIN_X

    # Bottom-up refinement: gently pull parents toward children
    for _ in range(3):
        for r in reversed(rows[:-1]):
            real_ns = [n for n in by_row[r] if nodes[n].get("type") != "__dummy"]
            target = {}
            for nid in real_ns:
                children = [c for c in fwd.get(nid, [])
                            if row_of.get(c) == r + 1 and c in pos_x]
                parents = [p for p in bwd.get(nid, set())
                           if row_of.get(p) == r - 1 and p in pos_x]
                xs = [pos_x[c] for c in children] + [pos_x[p] for p in parents]
                if xs:
                    target[nid] = sum(xs) / len(xs)
                else:
                    target[nid] = pos_x[nid]
            # Blend: move 30% toward target
            blended = {}
            for nid in real_ns:
                blended[nid] = pos_x[nid] * 0.7 + target[nid] * 0.3
            real_ns_sorted = sorted(real_ns, key=lambda n: blended[n])
            _place_with_targets(real_ns_sorted, pos_x, dims, blended)
            by_row[r] = _merge_order(by_row[r], real_ns_sorted, nodes)

        # Also update dummies after each pass
        for r in rows:
            for did in by_row[r]:
                if nodes[did].get("type") != "__dummy":
                    continue
                parents = [p for p in bwd.get(did, set()) if p in pos_x]
                children = [c for c in fwd.get(did, []) if c in pos_x]
                xs = [pos_x[p] for p in parents] + [pos_x[c] for c in children]
                if xs:
                    pos_x[did] = sum(xs) / len(xs)

    # Center the whole diagram
    all_xs = []
    for nid in pos_x:
        if nodes[nid].get("type") == "__dummy":
            continue
        w = dims[nid][0]
        all_xs.append(pos_x[nid] - w/2)
        all_xs.append(pos_x[nid] + w/2)
    if all_xs:
        content_left = min(all_xs)
        content_right = max(all_xs)
        content_mid = (content_left + content_right) / 2
        desired_mid = MARGIN_X + (content_right - content_left) / 2
        shift = desired_mid - content_mid
        for nid in pos_x:
            pos_x[nid] += shift

    # Assign y positions
    pos = {}
    for ri, r in enumerate(rows):
        y = Y0 + ri * VGAP
        for nid in by_row[r]:
            pos[nid] = (pos_x[nid], y)

    return pos, dims


def _place_evenly(ns, pos_x, dims):
    """Place nodes evenly starting from MARGIN_X."""
    cx = MARGIN_X
    for nid in ns:
        w = dims[nid][0]
        pos_x[nid] = cx + w / 2
        cx += w + HGAP


def _place_with_targets(ns, pos_x, dims, target):
    """Place nodes left-to-right, pulling toward target positions."""
    if not ns:
        return
    min_x = MARGIN_X
    for nid in ns:
        w = dims[nid][0]
        t = target.get(nid)
        if t is None:
            t = min_x + w / 2
        actual = max(t, min_x + w / 2)
        pos_x[nid] = actual
        min_x = actual + w / 2 + HGAP


def _merge_order(full_row, sorted_real, nodes):
    """Merge sorted real nodes back with dummies preserving dummy relative positions."""
    result = []
    real_iter = iter(sorted_real)
    for nid in full_row:
        if nodes[nid].get("type") == "__dummy":
            result.append(nid)
        else:
            result.append(next(real_iter))
    # Add any remaining real nodes
    for nid in real_iter:
        result.append(nid)
    return result


# ═══════════════════════════════════════════════════════════════
# EDGE ROUTING
# ═══════════════════════════════════════════════════════════════

def _segments_intersect(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2):
    def cross(o, a, b):
        return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
    def on_seg(p, q, r):
        return (min(p[0], r[0]) <= q[0] <= max(p[0], r[0]) and
                min(p[1], r[1]) <= q[1] <= max(p[1], r[1]))
    p1, p2, p3, p4 = (ax1,ay1),(ax2,ay2),(bx1,by1),(bx2,by2)
    d1 = cross(p3,p4,p1); d2 = cross(p3,p4,p2)
    d3 = cross(p1,p2,p3); d4 = cross(p1,p2,p4)
    if ((d1>0 and d2<0) or (d1<0 and d2>0)) and \
       ((d3>0 and d4<0) or (d3<0 and d4>0)):
        return True
    if d1==0 and on_seg(p3,p1,p4): return True
    if d2==0 and on_seg(p3,p2,p4): return True
    if d3==0 and on_seg(p1,p3,p2): return True
    if d4==0 and on_seg(p1,p4,p2): return True
    return False


def line_rect_intersects(x1, y1, x2, y2, rx, ry, rw, rh, pad=3):
    rx -= pad; ry -= pad; rw += 2*pad; rh += 2*pad
    if rx <= x1 <= rx+rw and ry <= y1 <= ry+rh: return True
    if rx <= x2 <= rx+rw and ry <= y2 <= ry+rh: return True
    edges = [(rx,ry,rx+rw,ry),(rx,ry+rh,rx+rw,ry+rh),
             (rx,ry,rx,ry+rh),(rx+rw,ry,rx+rw,ry+rh)]
    for ex1,ey1,ex2,ey2 in edges:
        if _segments_intersect(x1,y1,x2,y2,ex1,ey1,ex2,ey2):
            return True
    return False


def route_edge(x1, y1, x2, y2, boxes, edge_idx):
    """Route an edge avoiding intermediate boxes. Returns waypoints."""
    waypoints = [(x1, y1)]
    collisions = [b for b in boxes
                  if line_rect_intersects(x1, y1, x2, y2, b[0], b[1], b[2], b[3])]
    if not collisions:
        waypoints.append((x2, y2))
        return waypoints

    collisions.sort(key=lambda b: (b[0]+b[2]/2-x1)**2 + (b[1]+b[3]/2-y1)**2)
    cx, cy = x1, y1
    for bx, by, bw, bh, bid in collisions:
        box_cx = bx + bw/2
        offset = 8 + (edge_idx % 3) * 2
        if (cx + x2)/2 < box_cx:
            wp_x = bx - offset
        else:
            wp_x = bx + bw + offset
        waypoints.append((wp_x, by + bh/2))
        cx, cy = wp_x, by + bh/2
    waypoints.append((x2, y2))
    return waypoints


def build_path_svg(x1, y1, x2, y2, same_row, edge_idx):
    """Build SVG path string for an edge. Returns (path_d, label_x, label_y)."""
    if same_row:
        # Horizontal arc above the boxes
        arc_y = min(y1, y2) - SAME_ROW_ARC
        mx = (x1 + x2) / 2
        # Quadratic bezier through the arc peak
        d = f"M{x1:.1f},{y1:.1f} Q{mx:.1f},{arc_y:.1f} {x2:.1f},{y2:.1f}"
        # Label at the arc apex
        lx = mx
        ly = arc_y + 4  # slightly below the peak for readability
        return d, lx, ly
    else:
        # S-curve (cubic bezier)
        ym = (y1 + y2) / 2
        d = f"M{x1:.1f},{y1:.1f} C{x1:.1f},{ym:.1f} {x2:.1f},{ym:.1f} {x2:.1f},{y2:.1f}"
        # Bezier midpoint
        lx = (x1 + 3*x1 + 3*x2 + x2) / 8
        ly = (y1 + 3*ym + 3*ym + y2) / 8
        return d, lx, ly


def build_below_arc_svg(x1, y1, x2, y2, row_bottom, edge_idx):
    """Build SVG path arcing BELOW boxes for same-row edges that cross intermediate nodes.

    Used when an above-arc would visually cross over boxes between source and target.
    Creates a smooth U-curve below the row, in the gap between layer bands.
    """
    gap = BELOW_ARC_GAP + (edge_idx % 3) * 4  # stagger depth for parallel below-arcs
    arc_y = row_bottom + gap
    cx1 = x1 + (x2 - x1) * 0.3
    cx2 = x1 + (x2 - x1) * 0.7
    d = f"M{x1:.1f},{y1:.1f} C{cx1:.1f},{arc_y:.1f} {cx2:.1f},{arc_y:.1f} {x2:.1f},{y2:.1f}"
    lx = (x1 + x2) / 2
    ly = arc_y + 4  # label below the arc peak
    return d, lx, ly


def build_routed_path_svg(waypoints):
    """Build SVG path from routed waypoints (>2 points)."""
    if len(waypoints) == 2:
        x1, y1 = waypoints[0]
        x2, y2 = waypoints[1]
        ym = (y1 + y2) / 2
        return f"M{x1:.1f},{y1:.1f} C{x1:.1f},{ym:.1f} {x2:.1f},{ym:.1f} {x2:.1f},{y2:.1f}"
    if len(waypoints) == 3:
        x1, y1 = waypoints[0]
        mx, my = waypoints[1]
        x2, y2 = waypoints[2]
        return f"M{x1:.1f},{y1:.1f} Q{mx:.1f},{my:.1f} {x2:.1f},{y2:.1f}"
    parts = [f"M{waypoints[0][0]:.1f},{waypoints[0][1]:.1f}"]
    for i in range(1, len(waypoints)):
        parts.append(f"L{waypoints[i][0]:.1f},{waypoints[i][1]:.1f}")
    return " ".join(parts)


def routed_path_midpoint(waypoints):
    """Find midpoint along a polyline path."""
    total = 0
    segs = []
    for i in range(len(waypoints) - 1):
        dx = waypoints[i+1][0] - waypoints[i][0]
        dy = waypoints[i+1][1] - waypoints[i][1]
        sl = math.sqrt(dx*dx + dy*dy)
        segs.append(sl)
        total += sl
    if total == 0:
        return waypoints[0]
    half = total / 2
    accum = 0
    for i, sl in enumerate(segs):
        if accum + sl >= half:
            t = (half - accum) / sl if sl > 0 else 0
            x = waypoints[i][0] + t * (waypoints[i+1][0] - waypoints[i][0])
            y = waypoints[i][1] + t * (waypoints[i+1][1] - waypoints[i][1])
            return (x, y)
        accum += sl
    return waypoints[-1]


# ═══════════════════════════════════════════════════════════════
# LABEL PLACEMENT with collision avoidance
# ═══════════════════════════════════════════════════════════════

def find_label_pos(mx, my, text, box_rects, placed, is_same_row=False):
    """Find collision-free position for an edge label."""
    tw = len(text) * FONT_FLOW * 0.6 + 4
    th = FONT_FLOW + 2

    if is_same_row:
        # For same-row arcs: label at arc peak, try shifts outward
        candidates = [
            (mx, my),
            (mx, my - 4),
            (mx + 14, my),
            (mx - 14, my),
            (mx, my - 8),
            (mx + 22, my - 4),
            (mx - 22, my - 4),
            (mx + 30, my),
            (mx - 30, my),
            (mx, my - 12),
        ]
    else:
        # Labels sit ON the arrow (at the bezier midpoint).
        # First candidate is the exact midpoint; fallbacks slide along the arrow
        # direction rather than perpendicular, keeping text on the path.
        candidates = [
            (mx, my),
            (mx, my - 4),
            (mx, my + 4),
            (mx + 8, my - 2),
            (mx - 8, my - 2),
            (mx + 8, my + 2),
            (mx - 8, my + 2),
            (mx, my - 8),
            (mx, my + 8),
            (mx + 16, my),
            (mx - 16, my),
            (mx + 16, my - 4),
            (mx - 16, my - 4),
            (mx, my + 12),
            (mx, my - 12),
        ]

    for cx, cy in candidates:
        lx = cx - tw/2
        ly = cy - th/2
        ok = True
        # Check boxes
        for bx, by, bw, bh, _ in box_rects:
            if (lx < bx + bw + 3 and lx + tw > bx - 3 and
                ly < by + bh + 3 and ly + th > by - 3):
                ok = False
                break
        # Check other labels
        if ok:
            for plx, ply, plw, plh in placed:
                if (lx < plx + plw + 2 and lx + tw > plx - 2 and
                    ly < ply + plh + 1 and ly + th > ply - 1):
                    ok = False
                    break
        if ok:
            placed.append((lx, ly, tw, th))
            return (cx, cy)

    # Fallback
    cx, cy = candidates[0]
    placed.append((cx - tw/2, cy - th/2, tw, th))
    return (cx, cy)


# ═══════════════════════════════════════════════════════════════
# MAIN GENERATE
# ═══════════════════════════════════════════════════════════════

def generate(cfg):
    nodes_list = cfg["nodes"]
    nodes = {n["id"]: n for n in nodes_list}
    edges = cfg["edges"]
    groups = cfg.get("groups", [])
    title = cfg.get("title", "Architecture")
    node_ids = [n["id"] for n in nodes_list]

    # Build adjacency
    fwd = defaultdict(list)
    bwd = defaultdict(set)
    for e in edges:
        fwd[e["from"]].append(e["to"])
        bwd[e["to"]].add(e["from"])

    # Layer assignment
    if all("row" in n for n in nodes_list):
        row_of = {n["id"]: n["row"] for n in nodes_list}
    else:
        row_of = auto_layers(node_ids, fwd, bwd)

    # Insert dummy nodes
    original_edges = list(edges)
    node_ids, nodes, edges, row_of, fwd, bwd, dummy_chains = \
        insert_dummy_nodes(node_ids, nodes, edges, row_of, fwd, bwd)

    # Group by row
    by_row = defaultdict(list)
    for nid in node_ids:
        by_row[row_of[nid]].append(nid)

    # Crossing minimization
    barycenter_order(by_row, fwd, bwd, row_of)

    # Coordinate assignment
    pos, dims = assign_coordinates(by_row, nodes, row_of, fwd, bwd)

    # Canvas size
    max_x = max((pos[n][0] + dims[n][0]/2) for n in pos if nodes[n].get("type") != "__dummy")
    max_y = max((pos[n][1] + dims[n][1]) for n in pos if nodes[n].get("type") != "__dummy")
    W = max(max_x + MARGIN_X, 200)
    H = max_y + MARGIN_BOTTOM

    pw = int(W * SCALE)
    ph = int(H * SCALE)

    # Collect real node boxes for collision detection
    box_rects = []  # (x, y, w, h, id)
    for nid in node_ids:
        if nodes[nid].get("type") == "__dummy":
            continue
        cx, cy = pos[nid]
        w, h = dims[nid]
        box_rects.append((cx - w/2, cy, w, h, nid))

    # ── SVG Output ──
    o = []
    a = o.append

    a(f'<svg xmlns="http://www.w3.org/2000/svg" width="{pw}" height="{ph}"'
      f' viewBox="0 0 {W:.0f} {H:.0f}" font-family="Arial, sans-serif">')
    a('  <defs>')
    a('    <marker id="ah" viewBox="0 0 8 6" refX="8" refY="3"')
    a('      markerWidth="4" markerHeight="3" orient="auto">')
    a(f'      <path d="M0.5,0.5 L7,3 L0.5,5.5" fill="none" stroke="{ARROW_COLOR}"')
    a('        stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>')
    a('    </marker>')
    a('    <filter id="shadow" x="-6%" y="-8%" width="112%" height="128%">')
    a('      <feDropShadow dx="0" dy="0.5" stdDeviation="0.8"')
    a('        flood-color="#000" flood-opacity="0.05"/>')
    a('    </filter>')
    a('  </defs>')
    a(f'  <rect width="{W:.0f}" height="{H:.0f}" fill="{BG}" rx="3"/>')

    # Title
    a(f'  <text x="{W/2:.0f}" y="{TITLE_Y}" text-anchor="middle"'
      f' font-size="{FONT_TITLE}" font-weight="700"'
      f' fill="{TITLE_COLOR}">{esc(title)}</text>')

    # Legend
    lw = 150
    lcx = W / 2
    lsy = LEGEND_STRIP_Y
    a(f'  <rect x="{lcx-lw/2:.0f}" y="{lsy}" width="{lw}" height="10"'
      f' rx="3" fill="{BOX_FILL}" stroke="{BOX_STROKE}" stroke-width="0.4"/>')
    sx = lcx - lw/2 + 5
    a(f'  <rect x="{sx:.0f}" y="{lsy+2.5}" width="5" height="5" rx="2.5"'
      f' fill="{BOX_FILL}" stroke="{BOX_STROKE}" stroke-width="0.4"/>')
    a(f'  <text x="{sx+8:.0f}" y="{lsy+6.5}" font-size="{FONT_SUB}"'
      f' fill="{FLOW_COLOR}">Actor</text>')
    sx += 40
    a(f'  <rect x="{sx:.0f}" y="{lsy+2.5}" width="5" height="5" rx="1.5"'
      f' fill="{BOX_FILL}" stroke="{BOX_STROKE}" stroke-width="0.4"/>')
    a(f'  <rect x="{sx+1:.0f}" y="{lsy+3.5}" width="0.8" height="3"'
      f' rx="0.4" fill="{ACCENT_BLUE}"/>')
    a(f'  <text x="{sx+8:.0f}" y="{lsy+6.5}" font-size="{FONT_SUB}"'
      f' fill="{FLOW_COLOR}">Protocol</text>')
    sx += 48
    a(f'  <rect x="{sx:.0f}" y="{lsy+2.5}" width="5" height="5" rx="1.5"'
      f' fill="{BOX_FILL}" stroke="{BOX_STROKE}" stroke-width="0.4"/>')
    a(f'  <rect x="{sx+1:.0f}" y="{lsy+3.5}" width="0.8" height="3"'
      f' rx="0.4" fill="{ACCENT_AMBER}"/>')
    a(f'  <text x="{sx+8:.0f}" y="{lsy+6.5}" font-size="{FONT_SUB}"'
      f' fill="{FLOW_COLOR}">External</text>')

    # Group enclosures (largest first, behind everything)
    # Compute uniform left/right boundary across all groups for visual consistency
    group_rects = []
    group_bounds = []
    for grp in groups:
        gnodes = [nid for nid in grp["nodes"] if nid in pos]
        if not gnodes:
            continue
        gx1 = min(pos[n][0] - dims[n][0]/2 for n in gnodes) - GROUP_PAD
        gy1 = min(pos[n][1] for n in gnodes) - GROUP_PAD
        gx2 = max(pos[n][0] + dims[n][0]/2 for n in gnodes) + GROUP_PAD
        gy2 = max(pos[n][1] + dims[n][1] for n in gnodes) + GROUP_PAD
        group_bounds.append((gx1, gy1, gx2, gy2, grp.get("label", "")))
    # Align all groups to the same left edge and width
    if group_bounds:
        uniform_x1 = min(b[0] for b in group_bounds)
        uniform_x2 = max(b[2] for b in group_bounds)
        for gx1, gy1, gx2, gy2, glabel in group_bounds:
            gw = uniform_x2 - uniform_x1
            gh = gy2 - gy1
            group_rects.append((uniform_x1, gy1, gw, gh, glabel))

    # Sort by area descending (largest background first)
    group_rects.sort(key=lambda g: g[2] * g[3], reverse=True)
    for gx, gy, gw, gh, glabel in group_rects:
        a(f'  <rect x="{gx:.1f}" y="{gy:.1f}" width="{gw:.1f}"'
          f' height="{gh:.1f}" rx="5" fill="#EDEEF2"/>')
        # Label at top-right to avoid content overlap
        a(f'  <text x="{gx + gw - 3:.1f}" y="{gy + 5:.1f}"'
          f' text-anchor="end" font-size="{FONT_SUB}"'
          f' fill="{LEGEND_COLOR}" font-weight="500">{esc(glabel)}</text>')

    # ── Edges ──
    edge_data = []  # (path_d, label_text, label_x, label_y, is_same_row)

    for i, e in enumerate(edges):
        fi, ti = e["from"], e["to"]
        if fi not in pos or ti not in pos:
            edge_data.append(None)
            continue

        fx, fy = pos[fi]
        tx, ty = pos[ti]
        fw, fh = dims[fi]
        tw, th = dims[ti]
        lbl = e.get("label", "")

        same_row = (row_of[fi] == row_of[ti])

        if same_row:
            # Connect at sides
            if fx < tx:
                x1 = fx + fw/2 if fw > 0 else fx
                x2 = tx - tw/2 if tw > 0 else tx
            else:
                x1 = fx - fw/2 if fw > 0 else fx
                x2 = tx + tw/2 if tw > 0 else tx
            y1 = fy + fh/2  # vertical center of source
            y2 = ty + th/2  # vertical center of target

            # Check if above-arc would cross intermediate boxes
            edge_left, edge_right = min(x1, x2), max(x1, x2)
            crosses_box = False
            for b in box_rects:
                if b[4] == fi or b[4] == ti:
                    continue
                bx, by, bw, bh, _ = b
                # Box is between source and target horizontally?
                if bx + bw > edge_left and bx < edge_right:
                    crosses_box = True
                    break

            if crosses_box:
                # Route BELOW boxes to avoid crossing
                by1 = fy + fh - 2  # near bottom of source
                by2 = ty + th      # bottom of target
                row_bottom = max(fy + fh, ty + th)
                path_d, lx, ly = build_below_arc_svg(x1, by1, x2, by2, row_bottom, i)
                edge_data.append((path_d, lbl, lx, ly, False))
            else:
                # Normal above-arc (no intermediate boxes to cross)
                path_d, lx, ly = build_path_svg(x1, y1, x2, y2, True, i)
                edge_data.append((path_d, lbl, lx, ly, True))
        else:
            # Vertical: bottom of source → top of target
            if fy < ty:
                x1, y1 = fx, fy + fh
                x2, y2 = tx, ty
            else:
                x1, y1 = fx, fy
                x2, y2 = tx, ty + th

            # Check for intermediate box collisions
            intermediate = [b for b in box_rects if b[4] != fi and b[4] != ti]
            needs_routing = any(
                line_rect_intersects(x1, y1, x2, y2, b[0], b[1], b[2], b[3])
                for b in intermediate
            )

            if needs_routing:
                wps = route_edge(x1, y1, x2, y2, intermediate, i)
                path_d = build_routed_path_svg(wps)
                mx, my = routed_path_midpoint(wps)
                edge_data.append((path_d, lbl, mx, my, False))
            else:
                path_d, lx, ly = build_path_svg(x1, y1, x2, y2, False, i)
                edge_data.append((path_d, lbl, lx, ly, False))

    # Render edge paths
    for ed in edge_data:
        if ed is None:
            continue
        path_d = ed[0]
        a(f'  <path d="{path_d}" fill="none" stroke="{ARROW_COLOR}"'
          f' stroke-width="{ARROW_W}" marker-end="url(#ah)"/>')

    # Render nodes (on top of edges)
    for nid in node_ids:
        n = nodes[nid]
        if n.get("type") == "__dummy":
            continue
        cx, cy = pos[nid]
        w, h = dims[nid]
        t = n.get("type", "protocol")

        if t == "actor":
            a(f'  <g filter="url(#shadow)">')
            a(f'    <rect x="{cx-w/2:.1f}" y="{cy:.1f}" width="{w:.1f}"'
              f' height="{h}" rx="{PILL_RX}" fill="{BOX_FILL}"'
              f' stroke="{BOX_STROKE}" stroke-width="{STROKE_W}"/>')
            a(f'    <text x="{cx:.1f}" y="{cy+h/2+1.8:.1f}"'
              f' text-anchor="middle" font-size="{FONT_LABEL}"'
              f' font-weight="600" fill="{LABEL_COLOR}">'
              f'{esc(n["label"])}</text>')
            a(f'  </g>')
        else:
            accent = ACCENT_AMBER if t == "external" else ACCENT_BLUE
            subtitle = n.get("subtitle", "")
            a(f'  <g filter="url(#shadow)">')
            a(f'    <rect x="{cx-w/2:.1f}" y="{cy:.1f}" width="{w:.1f}"'
              f' height="{h}" rx="{BOX_RX}" fill="{BOX_FILL}"'
              f' stroke="{BOX_STROKE}" stroke-width="{STROKE_W}"/>')
            a(f'    <rect x="{cx-w/2+ACCENT_PAD:.1f}" y="{cy+3:.1f}"'
              f' width="{ACCENT_W}" height="{h-6}" rx="0.6"'
              f' fill="{accent}"/>')
            if subtitle:
                a(f'    <text x="{cx:.1f}" y="{cy+h/2-0.5:.1f}"'
                  f' text-anchor="middle" font-size="{FONT_LABEL}"'
                  f' font-weight="600" fill="{LABEL_COLOR}">'
                  f'{esc(n["label"])}</text>')
                a(f'    <text x="{cx:.1f}" y="{cy+h/2+4.5:.1f}"'
                  f' text-anchor="middle" font-size="{FONT_SUB}"'
                  f' fill="{SUB_COLOR}">{esc(subtitle)}</text>')
            else:
                a(f'    <text x="{cx:.1f}" y="{cy+h/2+1.8:.1f}"'
                  f' text-anchor="middle" font-size="{FONT_LABEL}"'
                  f' font-weight="600" fill="{LABEL_COLOR}">'
                  f'{esc(n["label"])}</text>')
            a(f'  </g>')

    # Render edge labels (on top, with collision avoidance + background gap)
    placed = []
    for ed in edge_data:
        if ed is None:
            continue
        _, lbl, lx, ly, is_sr = ed
        if not lbl:
            continue
        fx, fy = find_label_pos(lx, ly, lbl, box_rects, placed, is_sr)
        # Background rect to create a visual gap in the arrow behind the label
        tw = len(lbl) * FONT_FLOW * 0.6 + 4
        th = FONT_FLOW + 2
        # Determine fill: use group fill if label center is inside a group rect
        bg_fill = BG
        for gx, gy, gw, gh, _ in group_rects:
            if gx <= fx <= gx + gw and gy <= fy <= gy + gh:
                bg_fill = "#EDEEF2"
                break
        a(f'  <rect x="{fx - tw/2:.1f}" y="{fy - th + 1:.1f}"'
          f' width="{tw:.1f}" height="{th:.1f}" rx="1" fill="{bg_fill}"/>')
        a(f'  <text x="{fx:.1f}" y="{fy:.1f}" font-size="{FONT_FLOW}"'
          f' fill="{FLOW_COLOR}" font-weight="500"'
          f' text-anchor="middle">{esc(lbl)}</text>')

    a('</svg>')
    return '\n'.join(o)


if __name__ == '__main__':
    if len(sys.argv) != 3:
        print(f"Usage: {sys.argv[0]} input.json output.svg", file=sys.stderr)
        sys.exit(1)
    with open(sys.argv[1]) as f:
        cfg = json.load(f)
    with open(sys.argv[2], 'w') as f:
        f.write(generate(cfg))
```

