Claude Skill

assess

Assess a codebase's readiness for AI agent contributors using the layered contract model, and generate a complexity hotspot SVG treemap (size = LOC, hue = cyclomatic complexity, saturation = recent git churn). TRIGGER when the user types /assess, asks for an AI-readiness review,

LLM Mart · 0 points · 7 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download bjcoombs-ai-native-toolkit-skills_assess-ef322b2.zip · 717 KB
Part of bjcoombs/ai-native-toolkit — 11 skills

Install

skills CLI npx skills add https://github.com/bjcoombs/ai-native-toolkit/tree/main/skills/assess
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install bjcoombs-ai-native-toolkit@llmmart
Git git clone https://github.com/bjcoombs/ai-native-toolkit.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole bjcoombs/ai-native-toolkit collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

AI Readiness Assessment + Complexity Hotspot

Three artefacts in one pass against a target repo:

  1. Layered contract assessment - 0-8 score across navigability, runtime liveness, code design, linters, architecture tests, CI, coverage, review bots, and AI project management.
  2. Complexity hotspot SVG - Codecov-style treemap of the code. Size = LOC. Colour = cyclomatic complexity. Saturation = recent git churn. Vivid red = complex AND active = riskiest to change.
  3. Doc navigability SVG - a node-graph of the docs. Structure = connectivity (centre = entry, rim = unreachable, dashed ring = orphan, solid edge = link, dotted edge = reference); colour = staleness (vivid red = a frozen doc beside churning code = a lying map); size = file length. Folds navigability and the decaying-map signal into one artifact.

Both SVGs are colour-blind-safe by default (OrRd ramp, no red-green).

All land as files inside the target repo. The skill always writes them locally; after writing, ask the user whether to open a PR in the target repo with the artefacts.

The model: truth-pressure, not presence

Read this before scoring - it changes how you score. Across every layer, the real signal is never presence. It is whether a thing is under active pressure to stay true:

  • Tests keep behaviour honest (CI fails when it's wrong).
  • Retros / feedback loops keep the process honest (Layer 8 scores whether retros are carried out, not merely present).
  • Maintenance keeps docs honest (a wiki tracked against code churn).
  • Telemetry / liveness keeps relevance honest (is this code actually exercised).

So AI-readiness is the degree to which a codebase's self-descriptions are kept honest, not the degree to which scaffolding exists. Score artefacts on maintenance pressure, not existence. A stale-but-present doc scores at or below absent: missing makes the agent go look; confidently-stale makes it navigate fast to a wrong, current-looking conclusion.

The 9 layers (0-8) fall into three bands, ordered by dependency - what must hold for the next band to mean anything:

  • Read-side foundation (L0 navigability, L1 liveness) - can the agent form a true picture before it acts?
  • Write-side enforcement (L2-L7) - can the agent be trusted to produce good output? Only means something once you can trust that what you're reading is real and current.
  • Meta (L8 feedback) - does the system keep itself honest over time? Depends on a working enforced system to improve, so it stays last.

The three write-side tendencies the layers guard against

The write-side scores aren't abstract good practice - each traces to a known tendency of an AI contributor, observed across models. All three are the same defect: a self-description (the file's shape, a comment's promise, a gate's verdict) under no pressure to stay true. The deterministic core turns each into a cross-layer finding so the report names the specific files, not just the category:

  • Accretion - an agent does what is asked, and what is asked is feature after feature; nothing in that loop asks for a refactor, so files only grow. Now fully instrumented via the accretion_ratchet finding: a file whose accumulated line count ratcheted monotonically upward across multiple commits with almost no deletion pressure (deletions below ~15% of total churn). Only top complexity/size-band files are flagged, never documentation, so growth-but-simple is never noise; archive/, archived/ and attic/ paths stay out of the attention list, disclosed in excluded_as_archive. It surfaces on three surfaces - the accretion_ratchet block in run-context.json, the accretion_ratchet cross-layer finding (with its files in the attention list), and a growth-profile line on each flagged hotspot page (hotspots/*.md). The signal disclaims itself (rather than dropping the result) when the git history is degenerate - a shallow clone or squashed import has no meaningful net-delta sequence, so the block carries reliable: false and the hotspot line is marked as possibly incomplete.
  • Unactioned intent - an agent records promises it never returns to keep (TODO / FIXME / "remove after migration"). Instrumented via the unactioned_intent finding: markers aged by the edits they survived without being kept - a lying map of intent.
  • Guardrail erosion - under pressure to make red go green, an agent loosens the check instead of fixing the root (a suppression, a skipped test, a widened threshold), hollowing out the layers meant to protect it while they still read as Present.

Repository archetype (not every repo is software)

The 0-8 model assumes a software repo. A knowledge / document base - markdown sources, an LLM-maintained wiki, a CLAUDE.md schema, and no application code or runtime - has no code surface for the write-side layers (L2-L7). Scoring them Missing is itself a lying score: a well-run KB reads ~2.5/8 ("Not Ready") when it is in fact well-run, penalised for not testing code it doesn't contain.

The deterministic core (lib/archetype.py) classifies the repo and writes an archetype block to run-context.json:

  • Detection is a heuristic - the code-file ratio (code vs markdown) and the absence of a runtime surface (package.json, pyproject.toml, go.mod, Dockerfile, ...). A documentation-heavy application (lots of markdown but a real build) stays software because of the runtime-surface gate.
  • Override marker. An assess-archetype: knowledge-base (or software) marker in any instruction file (CLAUDE.md/AGENTS.md/...) forces or suppresses detection, so a maintainer is never trapped by a misfire. Write it as an HTML comment, e.g. <!-- assess-archetype: knowledge-base -->.
  • Scoring. For a detected knowledge base the write-side layers (2-7) are scored N/A (not Missing) and excluded from the denominator; the headline renormalises over the applicable layers (L0, L1, L8 → denominator 3) and the maturity label names the archetype and the applicable-layer count (e.g. Knowledge Base · Solid (3 applicable layers)). A software repo is unaffected - all 0-8 layers, denominator 8.
  • KB-maintenance signal. archetype.kb_maintenance flags whether the repo documents how the AI maintains the KB - the Karpathy LLM-wiki pattern (immutable raw sources, the schema file as the product, an ingest workflow, query-as-filing, periodic lint/consolidation). It is both a detection signal and a scored read-side (Layer 0) quality signal; the gist is cited in the report as the best-practice pointer whether or not the workflow is documented.

This is intentionally one archetype (knowledge base), structured as an extensible dispatch so more are cheap to add later - not a general archetype framework (YAGNI). The assess-layer-scorer agent reads the block (its Step 0) and the assess-findings skill renders N/A layers and the renormalised headline.

$ARGUMENTS

Step 1: Determine Repo Root and Output Directory

git rev-parse --show-toplevel   # from the arg path if given, else pwd

Set $REPO_ROOT to the result. All scanning happens from here.

Scoping a subtree (/assess <path>). When the argument is a directory under the repo root, scope the whole run to it - metrics, score, badge, wiki, and gate all computed for and labelled with the scope, artifacts under .assess/<scope-slug>/, no signal from a sibling. Pass --scope "$SCOPE" to complexity-treemap.py and assess_core.py and swap .assess/ for .assess/<slug>/ throughout. Full recipe: references/monorepo-scoping.md (relative to this skill dir). A no-path run is whole-repo, unchanged.

Decide the output directory (default: $REPO_ROOT/.assess/). Create it if needed:

mkdir -p "$REPO_ROOT/.assess"

Artefacts will land at:

  • $REPO_ROOT/.assess/complexity-heatmap.svg
  • $REPO_ROOT/.assess/complexity-stats.json
  • $REPO_ROOT/.assess/doc-graph.svg
  • $REPO_ROOT/.assess/assess-report.md

Write-protected repo root? /assess writes .assess/ into $REPO_ROOT, and the treemap/core run as uv subprocesses that write there too. If your workflow keeps the repo root pristine and read-only (e.g. a <repo>-main clone that teammates branch from, with a hook blocking direct edits), a guard on your writes won't stop the subprocess - it just makes the run write into the directory you meant to protect. Create a worktree first and run /assess from there.

Step 2: Generate the Code Heatmap + Doc Graph

This step produces two views of the codebase, both colour-blind-safe (OrRd ramp, no red-green):

  • Complexity heatmap (complexity-heatmap.svg) - a treemap of the code. Size = LOC, colour = cyclomatic complexity, saturation = recent churn. Vivid red = complex AND active = "hard to change safely".
  • Doc navigability graph (doc-graph.svg) - a node-graph of the docs. Structure shows connectivity (centre = entry point, rings = link-distance, rim = unreachable; orphans carry a dashed ring; solid edges are links, dotted edges references); colour shows staleness in the same grammar as the code heatmap (vivid red = a frozen doc beside churning code = a lying map); size = file length. It folds both Layer 0 doc signals - navigability and the decaying-map - into one artifact. Beyond static wikilinks and CommonMark links, it counts a backticked path to an existing doc as a reference edge (a cited .claude/ file included) and recognises Obsidian vault-native navigation - .base view hubs and dataview query blocks - as edges (resolved statically by folder / tag / frontmatter predicate), so a vault navigated by dynamic queries isn't mis-scored as orphaned. The SVG and the scored signal compute over the identical doc set: both honour the same excludes (.assess/config.toml).

Feed the complexity stats into the linter/complexity layer (Layer 3) and the doc_graph / doc_staleness blocks of run-context.json into Layer 0 (the graph SVG is the visual; the score reads the structured blocks).

The consent lifecycle (read references/consent-lifecycle.md)

Steps 2a/2b/2d and the assess-pr end-of-run offers share one consent model, specified in full in references/consent-lifecycle.md (relative to this skill dir) - load it before running the offers. Load-bearing hooks the steps rely on: decline markers carry provenance (write .no-<tool> JSON via the reference's write_decline_marker helper, never a bare touch; a mutation decline under an older plugin major sets reoffer_mutation: true so Step 2d re-asks once); three phases each a single batched question (Phase 1 tool installs 2a+2b, Phase 3 the separate mutation pass 2d, Phase 2 the assess-pr write-back offers); and the non-interactive contract - a headless/CI run makes no AskUserQuestion calls in any phase, Phase 1 as orchestration from your runtime context and Phases 2/3 from the core's run-context.json .interactive flag, which pre-records every skipped offer in .offers.

2a: Detect scc need (feeds Phase 1)

The bundled treemap uses lizard (Python, Go, JS, Java, C/C++, etc.) by default. Optional scc extends coverage to 200+ languages including markdown, JSON, YAML, SQL, and shell - useful when the repo's surface is more than just traditional source code.

Before scanning, check three signals:

# 1. Is scc already on PATH?
command -v scc >/dev/null 2>&1 && SCC_PRESENT=1 || SCC_PRESENT=0

# 2. Has the user previously declined for this repo?
[ -f "$REPO_ROOT/.assess/.no-scc" ] && SCC_DECLINED=1 || SCC_DECLINED=0

# 3. Is the repo mostly markdown/data/config (where lizard alone will be sparse)?
#    Cheap heuristic: count non-code files vs code files. The `.` argument is
#    the regex pattern (matches every path) and "$REPO_ROOT" is the search
#    path - without `.`, fd treats $REPO_ROOT as the pattern itself, matches
#    nothing, and silently returns 0.
CODE_FILES=$(fd -t f -e py -e js -e ts -e tsx -e jsx -e go -e java -e kt -e rs -e rb -e cs -e swift -e dart -e cpp -e c -e h -e php . "$REPO_ROOT" 2>/dev/null | wc -l | tr -d ' ')
NONCODE_FILES=$(fd -t f -e md -e json -e yaml -e yml -e toml -e sh -e sql . "$REPO_ROOT" 2>/dev/null | wc -l | tr -d ' ')

Add scc to the Phase 1 offer list only if all three are true: SCC_PRESENT=0, SCC_DECLINED=0, and the repo looks lizard-sparse (CODE_FILES < NONCODE_FILES or CODE_FILES < 10). Otherwise it contributes nothing to Phase 1. Do not ask here - scc is batched with the dead-code tools into the single Phase 1 question in Step 2b. Its trade-off phrasing, for when the batched question is presented:

"This repo has

The three options are the shared Phase 1 shape (Install / Skip for now / Skip permanently via write_decline_marker scc). If the user accepts, run the platform-appropriate command (do not auto-install - brew install is a system mutation):

# macOS (Homebrew)
[ "$(uname)" = "Darwin" ] && command -v brew >/dev/null && brew install scc

# Linux (try common package managers, fall back to go install or manual)
[ "$(uname)" = "Linux" ] && {
  command -v apt >/dev/null && sudo apt install -y scc \
    || command -v dnf >/dev/null && sudo dnf install -y scc \
    || command -v go >/dev/null && go install github.com/boyter/scc/v3@latest \
    || echo "Install scc manually: https://github.com/boyter/scc#installation"
}

If the install fails or the platform isn't covered, fall back to lizard-only and continue - don't block the assessment.

2b: Phase 1 - batched analysis-tool install offer (capability-driven, detect-or-propose)

/assess maps each Layer 1/Layer 3 analysis capability (liveness/dead-code, static module graph, linting, modernization) to a serving tool. Historically that map was a hardcoded per-language allowlist - vulture for Python, ts-prune/knip for TS/JS, staticcheck/deadcode for Go. The defect that allowlist created: when a repo's language isn't enumerated, every capability silently degraded to "unavailable" - the report read "this layer is absent here" rather than "a tool could serve this - install one?". A non-enumerated language was locked out with no resolution path inside the run.

The flow is now capability-driven detect-or-propose, in three moves per capability:

  1. Detect whether a serving tool already exists (on PATH, or configured in build/lint config). If it does, use it - and if it's configured in the build, credit it; never re-offer.
  2. Propose an ecosystem-appropriate candidate when none exists. For an enumerated language this is the table below; for a non-enumerated one you propose a fitting tool at runtime (reasoned latitude - you are not locked out because the language isn't in a hardcoded list). Ask the user with the same AskUserQuestion pattern.
  3. Honest-degrade anything you can detect-but-not-serve: name the capability and a candidate tool in the report. This is a deliverable state distinct from both "Present" and a silent "Missing" - never let a capability vanish without naming what would serve it.

The per-language dead-code offer below is the simplest instance (one capability, install-consent). When the tool is absent, the scan degrades to tool_absent and the user has no resolution path inside the skill - they'd have to know which tool fits the language, which package manager to use, and run the install themselves. The same install-offer pattern as Step 2a closes the loop without leaving them to figure it out.

Detect languages with cheap fd counts (mirroring Step 2a's heuristic - the treemap script's own classification isn't exposed in the stats sidecar, and shelling out is fine here):

PY_FILES=$(fd -t f -e py . "$REPO_ROOT" 2>/dev/null | wc -l | tr -d ' ')
TS_FILES=$(fd -t f -e ts -e tsx . "$REPO_ROOT" 2>/dev/null | wc -l | tr -d ' ')
GO_FILES=$(fd -t f -e go . "$REPO_ROOT" 2>/dev/null | wc -l | tr -d ' ')

# Per-language candidate tool. Prefer the read-only tool first - `ts-prune` over
# `knip` for TS, `staticcheck` over `deadcode` for Go - so the user isn't asked
# twice for the same job and the chosen tool doesn't need to build the project.
needs_offer() {
  # Args: tool, file count, min; 0 = ask. Braced: skill-arg substitution skips them.
  local tool="${1}" count="${2}" min="${3:-5}"
  [ "$count" -ge "$min" ] || return 1
  command -v "$tool" >/dev/null 2>&1 && return 1     # already installed
  [ -f "$REPO_ROOT/.assess/.no-$tool" ] && return 1  # user declined permanently
  return 0
}

OFFERS=()  # each entry: "language|tool|install_cmd"
# Seed with scc first when Step 2a flagged it (the treemap-coverage tool shares
# this one batched question with the per-language dead-code tools).
[ "${SCC_PRESENT:-1}" = 0 ] && [ "${SCC_DECLINED:-0}" = 0 ] \
  && { [ "${CODE_FILES:-0}" -lt "${NONCODE_FILES:-0}" ] || [ "${CODE_FILES:-0}" -lt 10 ]; } \
  && OFFERS+=("coverage|scc|brew install scc (or apt/dnf/go install - see Step 2a)")
needs_offer vulture "$PY_FILES"      && OFFERS+=("python|vulture|pip install vulture (or 'uv tool install vulture')")
needs_offer ts-prune "$TS_FILES" && [ -f "$REPO_ROOT/tsconfig.json" ] && OFFERS+=("typescript|ts-prune|npm install -g ts-prune")
needs_offer staticcheck "$GO_FILES"  && OFFERS+=("go|staticcheck|go install honnef.co/go/tools/cmd/staticcheck@latest (or 'brew install staticcheck')")

If OFFERS is empty (no language hits the threshold, or every tool is already installed/declined), skip straight to 2c. Non-interactive short-circuit: Phase 1 precedes the core, so in a headless/CI run make no AskUserQuestion call and install nothing - proceed to 2c with lizard-only plus whatever is already on PATH (the core records the skip in offers at 2c).

Otherwise, in an interactive run, batch all of Phase 1 into a single AskUserQuestion call - one question per entry in OFFERS (scc and each dead-code tool together), three options per question. This is the one tool-install decision surface; the user never faces scc and the linters as separate modals:

  • Install - run the cited install command and continue.
  • Skip for now - proceed without the tool. Don't write a marker; ask again next run.
  • Skip permanently for this repo - write_decline_marker <tool> so future runs don't ask. Recommended when the language only appears in scripts/configs that don't warrant symbol-level reachability.

Phrase each question so the gain is concrete, e.g.:

"This repo has 47 Go files. staticcheck -checks U1000 would let /assess flag unreachable Go funcs as Layer 1 candidates. Install? (go install honnef.co/go/tools/cmd/staticcheck@latest or brew install staticcheck)"

When the user picks Install , run the platform-appropriate command from the offer. Surface any install failure as a chat message and continue - dead-code tools are degrade-don't-block (same contract as scc); a missing tool reduces Layer 1's precision but never gates the assessment. When they pick Skip permanently, write_decline_marker <tool> (e.g. write_decline_marker staticcheck). The user answers this one batched question once and the run proceeds with whichever tools they accepted.

JVM / Maven capability offers (v1)

When the deterministic core detects a Maven or Gradle project (a build file plus at least one .java, .kt, .scala or .groovy file outside platform-wrapper android/ directories, Cordova's platforms/android/ included: a Flutter, React Native, Capacitor or Cordova shell is not a JVM codebase) it emits a capability_offers block in run-context.json - the first proof of the capability-driven flow on a non-enumerated ecosystem. Read it after Step 2c's core run, before scoring, and act on each capability's state:

jq '.capability_offers' "$REPO_ROOT/.assess/run-context.json"
  • liveness → state: "offer" - Maven was detected but mvn dependency:analyze (coarse module-level dead-dependency detection) has not run. The consent field names the shape: run (mvn is on PATH - offer to run it against the project; dependency:analyze needs a compiling build, so this is a run-consent, heavier than a static scan) or install (mvn absent - offer to install Maven first). Use AskUserQuestion exactly as Step 2b, phrasing the trade-off (a build that resolves dependencies and may hit the network) - but honour the non-interactive contract: when run-context.json .interactive is false, skip this offer and honest-degrade the capability instead of prompting. On accept and a run consent, run mvn dependency:analyze, capture its output, and re-run the core with the served result so the candidates feed Layer 1. On decline, the capability stays honestly named, not silently dropped.
  • linting / modernization → state: "credited" - an already-configured pom.xml plugin serves it (served_by lists which: Checkstyle, SpotBugs, PMD, error-prone, OpenRewrite, Modernizer). Credit it in the report; do not re-offer.
  • Any capability → state: "honest_degrade" - nothing serves it yet (module graph, linting/modernization without a configured plugin, and all capabilities under Gradle in v1). The block carries a candidate_tool and gloss. Name both in the report's Layer 1/Layer 3 prose ("module-graph analysis is unserved here; jdeps would provide it"). Honest-degrade is a deliverable - surfacing the candidate is the point.

Boundary (v1). Only Maven liveness is served. Module graph (jdeps), linting, and modernization honest-degrade; Gradle honest-degrades entirely. The candidate_tool values are deterministic defaults - you may propose a better-fitting ecosystem tool at runtime (the detect-or-propose latitude above); that choice is human-judged, not CI-tested. CI tests only signal consumption: given a tool's output, the scorecard feeds correctly.

2c: Run the treemap

Run the bundled treemap script alongside the deterministic core - see the chained block below.

The script prints a one-line summary (file count, lizard vs scc coverage, churn window chosen, top 5 biggest files). The stats sidecar contains percentiles (p50/p95/max for LOC, CCN, churn) and ranked lists of the top 10 files by hotspot score, raw CCN, and raw LOC. Both feed the report.

Dependencies: the script uses PEP 723 inline metadata (lizard, squarify, matplotlib, numpy). uv resolves them on first run.

Build artifacts, generated test reports and generated code are filtered by default (full list in complexity-treemap.py's EXCLUDE_DIRS, EXCLUDE_FILE_PATTERNS and EXCLUDE_NESTED_PATH_PATTERNS; pass --include-artifacts to score them, e.g. to visualise how much of the repo is generated). The script excludes three classes of files:

  • Build artifacts: main.dart.js, Flutter canvaskit/skwasm runtime bundles (canvaskit.js, skwasm*.js), *.min.js, *.bundle.js, *.chunk.js, *.map, sourcemaps, service workers, and files under node_modules/, dist/, build/, .next/, .nuxt/, .output/, coverage/, etc.
  • Generated test reports: html-report/, playwright-report/, lighthouse-report.html, lighthouse-results.json, zap-report.*, and *.jsonl under a fixtures/ directory below the top level. When the 5 largest files are all JSON, YAML or JSONL scored by scc with complexity 0, stderr hints at .assess/config.toml excludes.
  • Generated code: protobuf bindings (*.pb.go, *_grpc.pb.go, *.pb.gw.go, *.connect.go, *_pb.ts, *_pb.d.ts, *_pb2.py, *.pb.cc, *.pb.h), Go generators (*.gen.go, wire_gen.go, zz_generated_*.go, bindata.go), .NET source generators (*.designer.cs, *.g.cs), Dart/Flutter codegen (*.freezed.dart, *.g.dart, *.gr.dart), *.generated.*, *.gen.ts, database.types.ts, and any file with a comment in its first 5 lines carrying a generator marker (DO NOT EDIT, @generated, etc.; reason generated-header) or whose average line exceeds 1,000 characters (reason long-lines). Content-matched files are listed in excluded_generated (stats file and run-context.json), which the report and gate disclose.

Dominance warning. If a single file still holds >30% of total scoreable LOC after filtering (the threshold compiled bundles typically cross), the script prints a warning to stderr identifying the file. When you see this, the right next step depends on why the file is large:

  • Compiled bundle or committed build output (main.dart.js, a bundled JS file, etc.): surface in the report's "Hotspot snapshot" section as "<file> holds X% of LOC and is likely a build artifact - recommend adding to .gitignore and re-running." Add a Top 3 Action of the same shape.
  • Intentionally-tracked reference data (regulatory raw exports, vetted-context corpora, seed datasets, large CSV/JSON reference tables): the file is meant to be in git but isn't source code. The fix is --exclude, not .gitignore. Recommend the user persist the rule in .assess/config.toml so subsequent runs apply it automatically (see "Custom excludes" below). Do not push toward .gitignore in this case.
  • Either way, do NOT skip the rest of the assessment - the layered scan still produces useful signal.

Custom excludes for vetted-context / reference data. When the repo intentionally tracks large non-source files, two mechanisms extend the built-in defaults (the built-ins always apply; both layers are additive). The same excludes apply across every scan - the heatmap, the doc-navigability graph, the doc-staleness pass, and the liveness scan all honour the same list, so "this is reference data, not source" is a single statement, not a per-layer toggle:

  1. CLI flag --exclude PATTERN (repeatable, ad-hoc). A plain string is matched as a directory name; a glob is matched against the basename. The flag exists on the treemap script for one-off runs:

    <!-- chat-replace:treemap-exclude-example -->
    uv run "${CLAUDE_SKILL_DIR}/scripts/complexity-treemap.py" "$REPO_ROOT" --exclude regulatory-raw --exclude vetted-context --exclude '*.csv'
    
  2. Per-repo config .assess/config.toml (durable, version-controllable, applies to every scan via the orchestrator). Recommended for any exclude the user will want to apply every run:

    exclude_dirs = ["regulatory-raw", "vetted-context", "seed-data"]
    exclude_patterns = ["*.csv", "*.parquet"]
    

    No section header is needed - the file is already namespaced by living under .assess/. Missing or malformed files degrade silently to no extra excludes; the assessment never blocks on a broken config.

Provenance for generated docs (staleness measured against the source). A generated doc - a Jira note dump, an API reference, codegen output - is not stale because the file is old; it is stale when the source it was derived from has moved on. The mtime/age model gets this backwards: a freshly regenerated dump of 1,200 notes shares one recent mtime (looks fresh) even when its source changed afterwards, and an old-but-accurate generated doc reads as a lying map when it is not. Declare provenance and doc-staleness is computed as "is the source newer than the doc?" instead - a generated doc whose source is quiet is never flagged as a lying_map, regardless of how busy the surrounding code is. Two ways to declare it (frontmatter wins when both name a source for the same doc):

  1. Frontmatter on the generated doc - a source: key (a string or a list), resolved relative to the repo root first, then to the doc's own directory. An optional generated_by: records the generator for humans (it does not affect staleness):

    ---
    source: data/jira.tsv
    generated_by: scripts/dump-jira-notes.py
    ---
    
  2. Per-repo config .assess/config.toml [[generated]] array-of-tables, for bulk-generated trees that cannot each carry frontmatter. path is a folder relative to the repo root; every doc under it inherits the mapping. source is a string or list of strings relative to the repo root:

    [[generated]]
    path = "notes"
    source = "data/jira.tsv"
    

    When a generated doc's source is newer than the doc, the staleness verdict is a direct, high-confidence source-vs-doc comparison (git commit time, falling back to mtime) - so a stale generated doc over complex code still surfaces as a lying_map, while a fresh one never does.

The script's own output directory .assess/ is excluded automatically - prior runs' run-context.json and SVGs never feed the next run's heatmap, the doc graph, or the dead-code scan. Test fixtures under **/tests/fixtures/** are likewise excluded automatically - they are inputs that exercise the scanners (sample CLAUDE.md / monolithic-instruction files), not navigational docs or live code, so counting them would inflate the orphan rate and depress the Layer 0 navigability read.

Raw-source-tree exclusion. The read-side metrics (orphan rate, reachability, broken links) describe the curated wiki - the navigable layer an agent traverses. A repo can also track trees of raw, machine-extracted source documents (a disclosure / SAR export of hundreds of .msg/.pdf/.docx files converted to markdown). Those are immutable raw sources: they legitimately have no inbound wiki links and carry machine-extracted, non-navigational links (mailto:/tel:/footer URLs), so counting them as orphans / broken links inflates the figures and masks the curated signal. The doc graph auto-detects such subtrees - threshold-based: a large subtree that is almost entirely link-isolated and carries the machine-extraction fingerprint (lib/raw_source.py) - and excludes them from the headline metrics, reporting each excluded tree + its file count (doc_graph.excluded_raw_trees) and the raw layer's own figures separately (raw_source_doc_count / raw_source_orphan_rate / raw_source_broken_links). A second fingerprint excludes working-notes trees the same way: pattern-named notes (plans, session logs, tickets) mostly linked once from one or two index files, reported as excluded_working_notes_trees / working_notes_doc_count; in .assess/config.toml, directories relative to the repo root (a prefix, not a name matched anywhere like exclude_dirs): working_notes_dirs = ["journal"] excludes one from the headline and working_notes_ignore = ["docs/chapters"] keeps one counted. A repo with neither tree is unaffected. The detection reuses the link graph already built, so there is no second parse.

If the script fails (no uv, no scoreable files, etc.), record the error in the report under "Hotspot snapshot" as "could not be generated -

Run the full sequence - rotate the prior sidecar first, then the treemap, then the deterministic core:

# Rotate the prior stats sidecar so the diff has something to compare against next run
if [ -f "$REPO_ROOT/.assess/complexity-stats.json" ]; then
  cp "$REPO_ROOT/.assess/complexity-stats.json" "$REPO_ROOT/.assess/complexity-stats.prior.json" 2>/dev/null || true
fi


# Run the complexity treemap (produces fresh complexity-stats.json)
# (single line: the standalone transform replaces the marker + one following line)
<!-- chat-replace:uv-treemap -->
uv run "${CLAUDE_SKILL_DIR}/scripts/complexity-treemap.py" "$REPO_ROOT" -o "$REPO_ROOT/.assess/complexity-heatmap.svg" --stats "$REPO_ROOT/.assess/complexity-stats.json"

# Run the doc navigability graph (connectivity + staleness in one SVG; feeds Layer 0)
<!-- chat-replace:uv-doc-graph -->
uv run "${CLAUDE_SKILL_DIR}/scripts/doc-graph-svg.py" "$REPO_ROOT" -o "$REPO_ROOT/.assess/doc-graph.svg"

# Run the deterministic core (instruction grading, doc link-graph, doc staleness,
# liveness/dead-code, observability rungs, stats diff, wiki files, run-context.json)
# On a headless/CI run (as in Phase 1), append `--non-interactive` so every consent
# offer records as skipped; a normal interactive /assess omits the flag.
<!-- chat-replace:uv-core -->
uv run "${CLAUDE_SKILL_DIR}/scripts/assess_core.py" "$REPO_ROOT"

Either SVG is additive: if a script fails (no uv, no scoreable files, no docs), record "could not be generated -

Interactivity is an explicit signal, never a stdin probe: pass --non-interactive only on a headless/CI run, otherwise the run is interactive by default. See references/consent-lifecycle.md for why (isatty() misreads a subprocess).

Now $REPO_ROOT/.assess/run-context.json contains the structured data you need for the prose sections. Read it before writing the report.

The plugin_version field in run-context.json tells you which plugin version produced this run. Surface it at the top of the report (e.g., "Generated by /assess v1.8.0") so readers can spot it if a stale cached version of the plugin produced unexpected output.

2d: Phase 3 - the bounded mutation pass (opt-in, kept separate)

This is its own phase, asked on its own - never batched with the Phase 1 tool installs. The default core run is read-only - it never mutates or runs code, so test_pressure carries the cheap hollow-test heuristics and mutation-config detection but no survivor data. The decisive Layer 1 signal (would a test actually fail if the code were wrong?) needs this bounded pass, which modifies your source files and runs your test suite over the mutated code - a different risk class, so it gets a dedicated question with explicit code-modification framing rather than being waved through inside a bundle of install prompts, and honours the non-interactive contract (skip when run-context.json .interactive is false).

The test_focus block is the single source of focus targets - the risky files that most need test work, already cross-joined from hotspot risk, coverage, and the hollow-test heuristics. Read it; don't recompute it:

jq '.test_focus' "$REPO_ROOT/.assess/run-context.json"

Only when test_focus.entries holds at least one entry with test evidence (test_signal of covered_but_hollow or sibling_test_only, on a hot file that is not itself a test) is there anything to deepen - mutating a file with no test yields all survivors and measures the missing test, not an existing one, so unsupported / no_covering_test / unknown_no_coverage entries stay in the report table but out of the mutation scope. If no entry qualifies, skip straight to Step 3. When it has entries, follow the same detect-or-offer-install pattern as Steps 2a/2b: first detect a mutation tool, then ask the user whether to run the bounded pass.

Detect a mutation tool for the repo's language. Mirror the Step 2b heuristic - mutmut for Python, stryker for TS/JS - and check PATH plus the permanent-decline marker:

# Scope: entries with test evidence, ranked, minus test files (the core's mutation_scope;
# regex mirrors sibling_tests.IS_TEST_RE). Tool by dominant focus-file language
# (Python -> mutmut, TS/JS -> stryker); mutmut when mixed.
FOCUS_FILES=$(jq -r '.test_focus.entries[] | select(.test_signal == "covered_but_hollow" or .test_signal == "sibling_test_only") | .path | select(split("/") as $p | (($p[-1] | sub("\\.[^.]*$"; "") | test("(^test_|_test$|\\.test$|\\.spec$|_spec$|Tests?$)")) or any($p[:-1][]; . == "__tests__")) | not)' "$REPO_ROOT/.assess/run-context.json" | head -5)
case "$FOCUS_FILES" in
  *.ts|*.tsx|*.js|*.jsx) MUT_TOOL=stryker ;;
  *) MUT_TOOL=mutmut ;;
esac
command -v "$MUT_TOOL" >/dev/null 2>&1 && MUT_PRESENT=1 || MUT_PRESENT=0
[ -f "$REPO_ROOT/.assess/.no-$MUT_TOOL" ] && MUT_DECLINED=1 || MUT_DECLINED=0

# Re-offer once when the existing decline was made under an older plugin major
# (the mutation pass may have changed materially since). The core computes this.
REOFFER_MUT=$(jq -r '.reoffer_mutation // false' "$REPO_ROOT/.assess/run-context.json" 2>/dev/null)

Offer with AskUserQuestion as a standalone question (skip it entirely when run-context.json .interactive is false, or when MUT_DECLINED=1 unless REOFFER_MUT=true, in which case ask once more - the prior decline predates a major bump). Frame the code modification explicitly - not "run a deeper test analysis?" but "this will modify your source files (mutating up to 5 focus files) and run your test suite over the changes, time-boxed, then revert". When re-offering, say so: "You previously declined mutation testing under an older version; the pass has since changed - run it now?". Three options, same shape as Steps 2a/2b:

  • Run mutation analysis - run the bounded pass on the focus files (installing $MUT_TOOL first if MUT_PRESENT=0, exactly as Step 2b installs a dead-code tool: run the platform-appropriate install, surface any failure as a chat message, and fall back to no-mutation on failure - never block).
  • Skip for now - continue with the cheap read intact. Don't write a marker; ask again next run.
  • Skip permanently for this repo - write_decline_marker "$MUT_TOOL" so future runs don't ask. Re-declining restamps the marker at the current version, so the re-offer won't repeat within this major.

On accept (tool available): run the opt-in mutation pass, then regenerate the heatmap with the survivor overlay. The core re-run reads the test_focus targets with test evidence itself, runs scan_test_pressure(..., opt_in=True) scoped to them, and rewrites the test_pressure block in run-context.json in place:

# 1. Re-run the test-pressure scan with the bounded mutation pass enabled
<!-- chat-replace:uv-core-mutation -->
uv run "${CLAUDE_SKILL_DIR}/scripts/assess_core.py" "$REPO_ROOT" --opt-in-mutation

# 2. Regenerate the heatmap with the survivor overlay so covered-but-unpinned
#    files get hatched and stop reading as safe green
<!-- chat-replace:uv-treemap-overlay -->
uv run "${CLAUDE_SKILL_DIR}/scripts/complexity-treemap.py" "$REPO_ROOT" -o "$REPO_ROOT/.assess/complexity-heatmap.svg" --stats "$REPO_ROOT/.assess/complexity-stats.json" --test-pressure "$REPO_ROOT/.assess/run-context.json"

With no mutation data the --test-pressure flag is a silent no-op, so the overlay regeneration is harmless even if the pass produced nothing.

On decline or no tool available: continue with the cheap read intact - the assessment is complete without it. State in the report that the deep mutation pass was not run and why (declined, or no mutation tool for the language), so the Layer 1 read is honest about its depth rather than implying the focus files were proven well-tested.

Step 3: Score the Layers

The deterministic core has written the data bus (.assess/run-context.json). Assigning each layer Present / Partial / Missing is judgement-heavy work that benefits from a fresh context window applying the layer methodology - so it runs as a dedicated unit, not inline here.

Layer 6 (truth pressure) is capped at Partial when mutation testing did not run. Read mutation_not_run_cap from run-context.json: when applies is true (the default read-only pass leaves it true - mutation only runs on the opt-in Step 2d accept), Layer 6 cannot be scored Present. A Present verdict there claims the suite proves behaviour, which only a mutation run substantiates - absent it, the strongest honest verdict is Partial, annotated with mutation_not_run_cap.annotation (truth-pressure unproven (mutation not run)). This is enforced deterministically: assess_finalize.py refuses a finalize-input whose Layer 6 score exceeds Partial while mutation_run is false, so scoring it Present will fail the finalize step, not merely read wrong.

Spawn the assess-layer-scorer agent (subagent_type: "assess-layer-scorer"), passing REPO_ROOT. It reads .assess/run-context.json, scores every layer, and returns the 0-8 score, the per-layer verdicts with evidence, the maturity label, and the structured evidence list Step 4 re-checks. Hold that scorecard for Step 4.

Step 3.5: Read Cross-Run Context

Before the report is written, check what changed since the last run (the findings-writer renders this into the report's diff section):

jq '.diff, .diff_detail' "$REPO_ROOT/.assess/run-context.json"

If prior was None (first run), skip this section in the report.

Check diff_reliable first. The reliability check is schema- and version-aware, not a blunt exact-version match: a MINOR/PATCH plugin bump keeps diff_reliable: true and the trend armed, but the diff is voided (diff_reliable: false, with diff_version_note naming the cause) when the stats schema_version changed, a complexity backend moved (lizard/scc version delta - the note names the tool), the prior snapshot never stamped a version, or the plugin MAJOR version changed. A MAJOR bump also sets diff_trend_reset: true - the report renders an explicit "Trend baseline reset" disclosure so a suppressed diff isn't misread as an unchanged run. Suppress the "What Changed Since Last Run" section whenever diff_reliable is false and surface the diff_version_note (as the deterministic renderer already does) rather than the transition lists. Otherwise, populate the section:

  • Graduated (good): list paths from diff_detail.graduated - hotspots that left the top list
  • Regressed (bad): list paths from diff_detail.regressed with their ccn_delta / commits_delta
  • New (watch): list paths from diff_detail.new
  • Persistent (structural debt if N runs in a row): list paths from diff_detail.persistent

The wiki files at .assess/index.md and .assess/hotspots/*.md are already updated by assess_core.py - you don't need to write them. You only write the prose summary in assess-report.md.

Step 4: Write the Report

Verify the scorecard's evidence first - the only point where a false claim can still be kept out of the report. Write the scorer's evidence list to $REPO_ROOT/.assess/.cache/evidence.json (after mkdir -p "$REPO_ROOT/.assess/.cache"), then run the check: uv run "${CLAUDE_SKILL_DIR}/scripts/lib/evidence_check.py" "$REPO_ROOT" "$REPO_ROOT/.assess/.cache/evidence.json" --json "$REPO_ROOT/.assess/.cache/evidence-checked.json". It prints verified N, rejected M and exits 0 (all hold) or 1 (some rejected); without that line, or with no evidence-checked.json, the check did not run - fix it before writing, never read it as a pass. The output's evidence replaces the scorer's list in the scorecard handed on; entries under evidence_rejected (each with a reason) are handed on beside it as the record of refuted claims, never cited as fact. When every entry a layer cited was rejected, re-score that layer yourself from run-context.json and its remaining verified entries, not from the scorer's prose. Delete both files once read. Assembling .assess/assess-report.md - the scorecard, the snapshots, the verbatim cross-layer findings, the lying signals, and the mandatory Top 3 Actions - is a reusable, mostly-deterministic procedure. It runs as a sub-skill.

Use the assess-findings skill, handing it the scorecard the layer-scorer returned. It assembles .assess/assess-report.md from the data bus plus the scorecard: the verbatim findings section, the lying signals, and the Top 3 Actions (the attention list is mandatory). Then continue to Step 7.5.

Step 7.5: Finalize the wiki (required)

After writing assess-report.md, write finalize-input.json to the transient cache and invoke assess_finalize.py so the wiki files reflect the score and actions you chose.

The input file lives under .assess/.cache/ rather than directly in .assess/ because it is a one-off LLM-authored input consumed immediately - it has no future utility and only creates noisy diffs if committed. assess_finalize.py reads the cache path first (and falls back to the legacy in-tree location if a prior run wrote one there), then deletes it on success so it cannot leak into a commit either way.

mkdir -p "$REPO_ROOT/.assess/.cache"
cat > "$REPO_ROOT/.assess/.cache/finalize-input.json" <<'EOF'
{
  "run_id": "<copy run_id verbatim from run-context.json>",
  "score": 6.0,
  "maturity_label": "Solid",
  "denominator": 8,
  "layer_scores": {"0": 1.0, "1": 0.5, "2": 1.0, "3": 0.5, "4": 1.0, "5": 0.5, "6": 0.5, "7": 1.0, "8": 0.5}, "evidence": [{"layer": 0, "kind": "path_exists", "path": "CLAUDE.md"}],
  "top_action": "Add cyclop rule (threshold 15) to .golangci.yml",
  "hotspot_actions": {
    "src/foo.go": [
      "Split parseLine into smaller functions",
      "Add a test file at src/foo_test.go"
    ]
  },
  "actions": [
    {
      "rank": 1,
      "action": "Add cyclop rule (threshold 15) to .golangci.yml",
      "layer": 3,
      "effort": "small",
      "files": [".golangci.yml"],
      "first_step": "Add 'cyclop' with max-complexity: 15 under linters",
      "done_when": "golangci-lint run passes with the rule active; no new suppressions added",
      "scope_fence": "Only .golangci.yml; do not edit source files to chase pre-existing violations"
    }
  ]
}
EOF

<!-- chat-replace:uv-finalize -->
uv run "${CLAUDE_SKILL_DIR}/scripts/assess_finalize.py" "$REPO_ROOT"

This replaces:

  • The **AI Readiness:** 0.0 / 8 ((LLM fills in)) and **Top action:** Deterministic ranker not yet wired ... placeholders in this run's log.md entry (found by its assess:run_id stamp) with your score, maturity label and Top 1 action; the log chain is re-computed.
  • Each hotspots/<slug>.md's Suggested actions section with the actions you derived for that file.

The optional denominator field is 8 for a software repo (the default when omitted) or the applicable-layer count for a detected archetype (3 for a knowledge base - see "Repository archetype" above). assess_finalize.py renormalises the log.md AI-Readiness line over it, so a KB reads 2.5 / 3 rather than a misleading 2.5 / 8. If finalize exits 1 because an earlier same-date entry is still unfilled (a run on an older commit that was never finalized), run the --drop-entry <run_id> command its message prints (it leaves a one-line tombstone), then re-run finalize. Never delete a log entry by hand: it breaks the chain.

assess_finalize.py reconciles this input against run-context.json before writing anything, and refuses (writing nothing, exiting non-zero) on any violation. So the fields must be internally honest:

  • run_id - copy it verbatim from run-context.json. It proves the input was authored against this run; a mismatch is treated as a torn write and rejected.
  • denominator must equal archetype.denominator in run-context.json.
  • score must not exceed denominator, and maturity_label must name the tier the score earns (≥0.875 AI-Native, ≥0.625 Solid, ≥0.375 Basic, else Not Ready over the denominator) - a label that overstates the score is rejected.
  • Every key in hotspot_actions must be a real top hotspot from stats_summary.top_hotspots - a fabricated path is rejected, naming the path.
  • layer_scores maps each layer id to its band (Missing 0.0 / Partial 0.5 / Present 1.0). Layer 6 must not exceed 0.5 when mutation_not_run_cap.applies is true (see Step 3). Include it so the cap is enforced; a legacy input omitting it skips only the Layer 6 check. Set evidence to the verified list the pre-report evidence_check run kept (flat {layer, kind, path[, needle]} entries), never the scorer's raw list; finalize re-checks it against the repository: a layer whose entries are all rejected is refused, naming each entry by kind, path and needle; a layer with at least one verified entry finalises, and each rejected entry prints a finalize: warning: line on stderr, which you relay to the user. An input without evidence skips the check.

The live README badge (.assess/badge.json, shields.io endpoint schema) is deterministic: assess_core.py writes the findings-count form on every run and links it to assess-report.md. Your LLM-derived score is not written to the badge - it appears inside the report the badge links to, so the badge only ever claims what a deterministic run can reproduce. When offering the PR (assess-pr), include the embed snippet if the repo's README has no badge yet:

![AI-readiness](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2F<owner>%2F<repo>%2F<default-branch>%2F.assess%2Fbadge.json)

The actions array mirrors the report's Top 3 Actions table one-to-one and must carry every table row - rank, action, done_when, and scope_fence are required per entry (layer, effort, files, first_step, finding recommended). Set finding to the finding type the action addresses (e.g. hidden_coupling, lying_map, unexplained_complexity, untrusted_hotspot, self_referential_tests, refactor_boundary) so assess_finalize.py can stamp the deterministic execution mode an executor should take; an action with no finding defaults to the conservative characterize_first mode. assess_finalize.py writes it to .assess/actions.json, the durable machine-readable contract (schema 2): unlike this input file (consumed and deleted), actions.json persists so an executing agent - including a smaller, cheaper model - can pick up the work with its exit criteria, fences, execution mode, and lifecycle status intact, without parsing the report's markdown. Re-running /assess preserves each action's status/claimed_by/completed_sha, so a completed action stays done. See references/actions-schema.md for the full schema.

Without this step, the log.md placeholders above carry forward forever. Hotspot pages you don't supply actions for keep a neutral pointer (This file is flagged but outside this run's Top 3. See the report's Top 3 Actions, or run a focused /assess pass for file-specific guidance.) rather than an unfinished-work placeholder - a flagged-but-not-Top-3 page reads as intentional.

The hotspot_actions dict should include at minimum the files mentioned in your Top 3 Actions. You can include more if you have specific suggestions for them; any file you omit keeps the neutral pointer.

Step 8: End-of-Run Offers

With the report written and the wiki finalized, run the end-of-run offers - open a PR, track the Top 3 Actions, freeze the assessment into a CI gate, the tool-feedback prompt, and the uninstall escape hatch (remove everything this run wrote, per run-context.json .uninstall_instructions_path). These are batched into Phase 2's single question and honour the non-interactive contract. This is a reusable procedure (akin to pr-review-merge), so it runs as a sub-skill.

Use the assess-pr skill. It runs the write-back offers (PR, issue tracking, freeze-into-CI) plus the tool-feedback prompt and the uninstall offer, reading the written .assess/assess-report.md artifact - notably mutating the Top 3 Actions table's Issue column in place when the user creates tracking items.

Files (ai-native-toolkit)
  • references
    • actions-schema.md 5 KB
      # `actions.json` schema
      
      `.assess/actions.json` is the durable, machine-readable Top 3 Actions contract. `assess_finalize.py` writes it from the LLM-authored `actions` array in `finalize-input.json`. Unlike the report's markdown table, it is meant to be parsed: an executor agent - often a smaller, cheaper model than the one that ran the assessment - reads it to know what to do, how to verify it, where to stop, and whether the work is still open.
      
      ## Top-level shape
      
      ```json
      {
        "schema": 2,
        "run_id": "20260707T101500Z-ab12cd",
        "actions": [ /* one entry per action, sorted by rank */ ]
      }
      ```
      
      | Field | Type | Meaning |
      |-------|------|---------|
      | `schema` | int | Schema version. Currently `2`. |
      | `run_id` | string \| null | The run that produced this contract, copied from `run-context.json`. `null` when the run carried no id (legacy). Ties the actions back to the assessment that raised them. |
      | `actions` | array | The Top 3 Actions, one object per row, sorted ascending by `rank`. |
      
      ## Per-action fields
      
      ```json
      {
        "rank": 1,
        "action": "Investigate the src/foo.go <-> src/bar.go seam",
        "done_when": "The coupling is documented in a contract, or the shared state is extracted",
        "scope_fence": "Only src/foo.go and src/bar.go; do not touch callers",
        "status": "pending",
        "claimed_by": null,
        "completed_sha": null,
        "mode": "characterize_first",
        "finding": "hidden_coupling"
      }
      ```
      
      ### Required (written every time)
      
      | Field | Type | Meaning |
      |-------|------|---------|
      | `rank` | int | Priority order (1 = do first). |
      | `action` | string | The directive. Also the **stable identity** used to carry status across re-runs (rank reshuffles between runs; the directive text does not). |
      | `done_when` | string | The exit criterion. Without it a weak executor doesn't know when to stop. |
      | `scope_fence` | string | What NOT to touch. Without it a weak executor over-extends. |
      | `status` | enum | Lifecycle: `pending` \| `claimed` \| `done` \| `reopened`. See below. |
      | `claimed_by` | string \| null | Identifier of the executor that claimed the action; `null` while unclaimed. |
      | `completed_sha` | string \| null | The commit SHA that satisfied `done_when`; `null` until done. |
      | `mode` | enum | Deterministic execution posture, derived from `finding`: `characterize_first` \| `verify_then_retire` \| `refactor_safe`. See below. |
      
      ### Recommended (passed through when the LLM supplies them)
      
      `layer`, `effort`, `files`, `first_step`, and `finding` are carried through verbatim if present. `finding` is the finding type the action addresses; it drives `mode` derivation and is worth supplying for that reason.
      
      ## `status` lifecycle
      
      | Status | Meaning |
      |--------|---------|
      | `pending` | Open, unclaimed. The initial state of every newly written action. |
      | `claimed` | An executor has taken the action but not finished it. |
      | `done` | Completed; `completed_sha` records the commit that satisfied `done_when`. |
      | `reopened` | A later run re-flagged work a prior run had marked done. |
      
      **Carry-forward across runs.** Each `/assess` run recomputes `rank`, `mode`, `done_when`, and `scope_fence` from the freshest findings, but preserves `status`, `claimed_by`, and `completed_sha` for any action whose `action` directive matches an entry in the existing `actions.json`. A done action therefore stays done, with its completed SHA and claimant intact, when the assessment is re-run. An action that no longer appears in the new Top 3 simply drops out of the contract.
      
      ## `mode` derivation
      
      `mode` is derived deterministically by the finalize step from the action's `finding` type (via `FINDING_MODES` in `lib/keyhole_signals.py`) - it is never guessed by the LLM. Each mode traces to one of the write-side tendencies the toolkit guards against:
      
      | Mode | Posture | Finding types |
      |------|---------|---------------|
      | `characterize_first` | Understand/contract the code before changing it - the risk is acting blind on an unpinned seam or complexity. | `hidden_coupling`, `unexplained_complexity`, `untrusted_hotspot`, `orphaned_understanding`, `override_contradicts_signals` |
      | `verify_then_retire` | A self-description that may be lying - verify whether it is still true, then delete / ticket / escalate. Never trust it as-is. | `lying_map`, `self_referential_tests`, `unactioned_intent`, `candidate_dead_weight` |
      | `refactor_safe` | A bounded island safe to restructure in isolation. | `refactor_boundary`, `accretion_ratchet` |
      
      An action whose `finding` is absent or unrecognised defaults to `characterize_first` - the conservative "understand before you touch it" posture.
      
      ## Versioning
      
      - **v1** (`schema: 1`): `{schema, actions:[{rank, action, done_when, scope_fence, ...}]}`. No lifecycle fields, no `mode`, no top-level `run_id`.
      - **v2** (`schema: 2`): adds per-action `status` / `claimed_by` / `completed_sha` / `mode` and top-level `run_id`.
      
      The finalize step reads a v1 `actions.json` for carry-forward without error: a v1 entry contributes no lifecycle fields, so a re-run over a v1 contract initialises every action to `pending`.
      
    • consent-lifecycle.md 6.2 KB
      # Consent lifecycle: decline markers, three phases, non-interactive contract
      
      Reference for `/assess`'s consent flow. SKILL.md Steps 2a/2b/2d and the assess-pr skill point here. Two concerns: how a permanent decline is recorded (decline markers with provenance), and how the offers are grouped so a user is never asked 8-10 serial questions (three phases) and a headless run never blocks (the non-interactive contract).
      
      ## Decline markers carry provenance
      
      A user can permanently decline any optional tool (`scc`, a dead-code linter, the mutation pass) by writing `$REPO_ROOT/.assess/.no-<tool>`. A decline is a durable, silencing choice, so the marker records **who** declined **what**, **when**, and under **which plugin version** - not a provenance-free empty file. Always write markers with this helper so the disclosure and re-offer logic downstream has the data it needs:
      
      ```bash
      # Resolve the plugin version for provenance stamping (degrades to "unknown").
      # The deterministic core records the running version in run-context.json.
      assess_plugin_version() {
        if [ -f "$REPO_ROOT/.assess/run-context.json" ]; then
          jq -r '.plugin_version // "unknown"' "$REPO_ROOT/.assess/run-context.json" 2>/dev/null || echo unknown
        else
          echo unknown
        fi
      }
      
      # Write a JSON decline marker with provenance. $1 = tool; $2 = optional reason.
      write_decline_marker() {
        local tool="$1" reason="${2:-}"
        mkdir -p "$REPO_ROOT/.assess"
        local who when ver
        who="$(git -C "$REPO_ROOT" config user.name 2>/dev/null || echo "${USER:-unknown}")"
        when="$(date +%Y-%m-%d)"
        ver="$(assess_plugin_version)"
        jq -n --arg by "$who" --arg at "$when" --arg ver "$ver" --arg reason "$reason" \
          '{declined_by: $by, declined_at: $at, plugin_version: $ver}
             + (if $reason == "" then {} else {reason: $reason} end)' \
          > "$REPO_ROOT/.assess/.no-$tool"
      }
      ```
      
      The marker JSON shape: `{declined_by, declined_at, plugin_version, reason?}` (`reason` optional). The deterministic core reads every `.no-<tool>` back into `run-context.json` as `decline_markers` (with a `decline_disclosures` line per marker and a `reoffer_mutation` flag). Two downstream effects, both automatic once you write markers this way:
      
      - **Disclosure.** The report surfaces each active marker verbatim from `decline_disclosures`, e.g. _"Mutation testing permanently declined by ben on 2026-07-07"_ - a silenced capability is never invisible.
      - **Re-offer once per major.** When a mutation marker was written under an older *major* plugin version, the core sets `reoffer_mutation: true`; Step 2d re-asks once. Declining again permanently restamps the marker at the current version, so its major now matches and the re-offer does not repeat within the same major.
      
      **Pre-versioning markers** (empty or non-JSON files from before this convention) are still honoured as a decline; they read as "declined by an unknown user on an unknown date" and are never auto-re-offered (no major to compare). The `[ -f ... ]` presence checks in SKILL.md work identically for JSON and legacy markers.
      
      ## Three phases
      
      `/assess` asks for consent at several points. Left un-batched, that is 8-10 serial questions - a tax the user pays one modal at a time. The flow is grouped into **three phases**, each a single decision surface:
      
      - **Phase 1 - tool installs** (SKILL.md Steps 2a + 2b): **one** batched AskUserQuestion covering every optional analysis tool (`scc` plus each per-language dead-code linter). These are read-only system installs; batching them is safe because none modifies the repo.
      - **Phase 3 - mutation pass** (SKILL.md Step 2d): kept **separate** and asked on its own. Unlike Phase 1, the mutation pass *modifies source and runs code*, so it carries a different risk class and must not be bundled into an install question where a user might wave it through. Frame it explicitly as code modification.
      - **Phase 2 - write-back** (assess-pr Steps 5-7): **one** batched AskUserQuestion covering the four write-back offers (open a PR, track findings, freeze a CI gate, file feedback).
      
      The phases are numbered by their risk grouping, not their run order: Phase 1 and 3 happen during Step 2; Phase 2 happens at the end.
      
      ## Non-interactive contract (headless / CI)
      
      When no human can answer - a headless run, a CI job, any non-tty stdin - **every offer is treated as declined**. The run must complete with **zero interactive prompts**; never emit an AskUserQuestion that would block a pipeline forever. This holds in every phase.
      
      Two enforcement surfaces, because the phases straddle the core run (Step 2c, which writes `run-context.json`):
      
      - **Phase 1 (Steps 2a/2b) precedes the core** - `run-context.json` does not exist yet. Here the contract is **orchestration**: if this is a headless/CI run (no human to answer), make no Phase 1 AskUserQuestion calls, install nothing, write no markers. You determine this from your own runtime context.
      - **Phases 3 and 2 (Steps 2d and the assess-pr write-back) follow the core**, so they read the authoritative `interactive` flag from `run-context.json`. That flag is set from the **explicit signal you pass the core**, not a stdin probe: pass `--non-interactive` to `assess_core.py` (or set `ASSESS_NON_INTERACTIVE=1` / `CI`) **only** on the same headless/CI run for which you skipped Phase 1. `sys.stdin.isatty()` is never consulted - the core runs as a subprocess with no controlling terminal, so it would misread a normal interactive `/assess` as headless and silently suppress every offer. With no flag and no CI env, the run is interactive by default.
      
        ```bash
        jq '{interactive, offers}' "$REPO_ROOT/.assess/run-context.json"
        ```
      
        When `interactive` is `false`, **make no AskUserQuestion calls** - every offer (`tool_install`, `mutation`, `pr`, `issue_tracking`, `ci_gate`, `feedback`, `uninstall`) is already recorded as `{type, status: "skipped", reason: "non-interactive"}` in `offers`, the run's audit trail. When `true`, `offers` is empty and you drive the phases live.
      
      Passing the same signal to Phase 1 (orchestration) and to the core (the `--non-interactive` flag) keeps all three phases reading one decision, not two. The core cannot make AskUserQuestion calls for you, so *you* enforce "no prompts when non-interactive"; the `offers` array is the record that you did.
      
    • monorepo-scoping.md 4.1 KB
      # Monorepo scoping: `/assess <path>`
      
      When the user runs `/assess <path>` (a directory under the repo root), scope the
      whole assessment to that subtree. The metrics, score, badge, wiki, and gate are
      all computed for - and labelled with - the scope, and every artifact lands under
      `.assess/<scope-slug>/` instead of `.assess/`. A root-level run (no path) is
      unchanged.
      
      This exists because a monorepo holds several services in one git repo. A
      whole-repo score averages them into a number that describes none of them; a
      scoped run answers "how ready is *this* service" with no signal bleeding in from
      a sibling directory.
      
      ## Deriving the scope
      
      `$SCOPE` is the path argument, resolved to a directory under `$REPO_ROOT`. If it
      does not exist or is not under the repo root, the scripts exit non-zero with an
      `error:` message - report that to the user rather than pressing on.
      
      ```bash
      # $SCOPE is repo-relative, e.g. services/api
      SCOPE_SLUG=$(printf '%s' "$SCOPE" | tr '/\\' '-')   # services/api -> services-api
      ASSESS_DIR="$REPO_ROOT/.assess/$SCOPE_SLUG"
      mkdir -p "$ASSESS_DIR"
      ```
      
      Every `.assess/...` path in the orchestrator's Step 1-7.5 becomes
      `$ASSESS_DIR/...` for a scoped run (the run-context, the SVGs, the stats
      sidecars, the wiki, the badge, `finalize-input.json`). A whole-repo run keeps
      `ASSESS_DIR="$REPO_ROOT/.assess"` so its output is byte-identical to before.
      
      ## Threading the scope into the scans
      
      Each deterministic step takes the scope so it scores only the subtree while still
      rooting at the repo (so `.assess/config.toml` excludes and the git churn window
      are resolved once, repo-wide, and stay comparable across scopes):
      
      <!-- chat-skip:start -->
      ```bash
      # <assess-skill-dir> is the directory SKILL.md's own `uv run` lines name. Do not
      # assume a reference file gets the harness's path substitution.
      # Heatmap - scores only files under the scope; title + default SVG name carry it
      uv run "<assess-skill-dir>/scripts/complexity-treemap.py" "$REPO_ROOT" \
        --scope "$SCOPE" -o "$ASSESS_DIR/complexity-heatmap.svg" \
        --stats "$ASSESS_DIR/complexity-stats.json"
      
      # Deterministic core - reads the scoped stats sidecar it just wrote, confines the
      # doc graph, doc staleness, dead-code, promissory-marker and change-coupling
      # scans to the subtree, routes every artifact under .assess/<slug>/, and records
      # `scope` / `scope_slug` in run-context.json for the report and badge.
      uv run "<assess-skill-dir>/scripts/assess_core.py" "$REPO_ROOT" --scope "$SCOPE"
      ```
      <!-- chat-skip:end -->
      
      The deterministic core confines the doc graph, doc staleness, dead-code,
      promissory-marker and change-coupling scans to the subtree, routes every
      artifact under `.assess/<slug>/`, and records `scope` / `scope_slug` in
      `run-context.json` for the report and badge.
      
      The doc-graph SVG has no `--scope` flag of its own; render it for the subtree by
      pointing it at the scope directory as its root
      (`doc-graph-svg.py "$REPO_ROOT/$SCOPE" -o "$ASSESS_DIR/doc-graph.svg"`), or skip
      it for a code-only service.
      
      The opt-in mutation re-run must be given the same scope so it finds the scoped
      run-context: `assess_core.py "$REPO_ROOT" --opt-in-mutation --scope "$SCOPE"`.
      
      ## What "scoped" means per signal
      
      - **Complexity / hotspots** - only files under the scope are scored; the
        dominance warning is per-scope.
      - **Doc graph + staleness** - only docs (and `.base` hubs) under the scope.
      - **Git churn** - the treemap saturation axis and the behaviour co-change pairs
        count only commits that touched the subtree.
      - **Dead code, promissory markers, change coupling** - candidates/markers/pairs
        outside the scope are dropped, so a sibling's TODO or unused function never
        appears.
      - **Observability** stays repo-level - telemetry is a whole-repo property, not a
        per-subtree one.
      
      ## Labelling
      
      The badge label reads `AI-readiness (services/api)` and the wiki `index.md`
      carries a `_Scope: \`services/api\`_` line, so a committed artifact is never
      mistaken for a whole-repo score. The report should name the scope in its
      heading too. `run-context.json` carries `scope` (repo-relative) and `scope_slug`
      for any consumer; both are `null` on a whole-repo run.
      
    • uninstall.md 4.3 KB
      # Uninstall `/assess` from a repo
      
      Everything `/assess` leaves in a **target** repo, and how to remove it cleanly. Offered at the end of a run (see the assess-pr end-of-run offers); also runnable on demand. `/assess` writes only inside the target repo - it installs no global state - so removal is a bounded set of files plus a few in-file edits. Nothing here touches the `ai-native-toolkit` plugin itself; this removes the *artifacts a run produced*, not the tool.
      
      Work from the target repo root (`$REPO_ROOT`). Do each step only if that artifact exists - a repo that never opted into the CI gate has no workflow to delete, and so on. None of these is destructive beyond the assessment: no source file, test, or history is touched.
      
      ## 1. Delete the `.assess/` directory
      
      The assessment wiki and all transient artifacts live here: `assess-report.md`, `complexity-heatmap.svg`, `doc-graph.svg`, `run-context.json`, `complexity-stats.json` (+ `.prior.json`), `badge.json`, `actions.json`, `index.md`, `log.md`, `hotspots/`, `config.toml`, the `.cache/` scratch dir, and any decline markers (see step 4).
      
      ```bash
      rm -rf "$REPO_ROOT/.assess"
      ```
      
      If `.assess/` was committed, stage the deletion (`git rm -r --cached .assess` then commit) so it leaves the tree.
      
      ## 2. Remove the badge line from the README
      
      The PR offer may have added a shields.io AI-readiness badge that points at `.assess/badge.json`. With `.assess/` gone the badge would 404, so remove the embed line. It looks like:
      
      ```markdown
      ![AI-readiness](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2F<owner>%2F<repo>%2F<default-branch>%2F.assess%2Fbadge.json)
      ```
      
      Find and delete it (check `README.md`, and any other README the repo uses):
      
      ```bash
      grep -rn "assess%2Fbadge.json\|.assess/badge.json" "$REPO_ROOT"/README* 2>/dev/null
      ```
      
      Delete the matching line(s) with an editor - do not blind-`sed` a README, since surrounding prose may reference the badge.
      
      ## 3. Delete the CI gate workflow
      
      If the freeze-into-CI offer was accepted, a gating workflow was written:
      
      ```bash
      rm -f "$REPO_ROOT/.github/workflows/assess-gate.yml"
      ```
      
      That is the only workflow `/assess` emits. Removing it stops the gate from running on future PRs. No other CI file is touched.
      
      ## 4. Remove decline markers
      
      Permanent-decline markers (`.assess/.no-mutmut`, `.assess/.no-scc`, `.assess/.no-<tool>`) live inside `.assess/`, so step 1 already removed them. Only if the user chose to keep `.assess/` (e.g. to preserve the report) remove the markers explicitly so a future re-install starts from a clean slate:
      
      ```bash
      rm -f "$REPO_ROOT"/.assess/.no-*
      ```
      
      ## 5. Remove archetype override markers from instruction files
      
      `/assess` never writes these - a user adds an `assess-archetype: <value>` marker by hand to force the knowledge-base or software archetype. If one was added for `/assess`, remove it from the instruction file so it does not linger as a dangling directive. Scan the known instruction files:
      
      ```bash
      grep -rn "assess-archetype" \
        "$REPO_ROOT/CLAUDE.md" "$REPO_ROOT/AGENTS.md" "$REPO_ROOT/GEMINI.md" \
        "$REPO_ROOT/.cursorrules" "$REPO_ROOT/.github/copilot-instructions.md" 2>/dev/null
      ```
      
      Delete any matching line (typically `<!-- assess-archetype: knowledge-base -->`). Leave the rest of the instruction file untouched.
      
      ## 6. Optional: `.gitignore` hint and tracked findings
      
      - If the PR offer's gitignore hint was followed, a `.gitignore` line was added for `.assess/complexity-stats.prior.json`. With `.assess/` gone it is inert; remove the line only if you want a spotless `.gitignore`.
      - **Tracked findings are not removed.** Issues created by the "track the Top 3 Actions" offer (labelled `assess-finding`) are real work items in the user's tracker. Uninstalling the tool does not close them - the close decision is the user's. Mention any open `assess-finding` items so the user can triage them separately:
      
        ```bash
        gh issue list --label assess-finding --state open 2>/dev/null
        ```
      
      ## Verify
      
      After the steps above, no assessment artifact should remain:
      
      ```bash
      ls "$REPO_ROOT/.assess" 2>/dev/null            # should be absent
      grep -rn "assess%2Fbadge.json" "$REPO_ROOT"/README* 2>/dev/null   # no output
      ls "$REPO_ROOT/.github/workflows/assess-gate.yml" 2>/dev/null     # should be absent
      ```
      
      All three silent means the repo is back to its pre-`/assess` state (bar any `assess-finding` tracker items the user chose to keep).
      
  • scripts
    • lib
      • test_pressure
        • aggregate.py 3.9 KB
          """Aggregation + the public ``scan_test_pressure`` facade.
          
          Merges the mutation tier and the cheap always-on heuristics into one
          ``test_pressure`` block ready to drop into run-context.json.
          """
          from __future__ import annotations
          
          from dataclasses import dataclass, field
          from pathlib import Path
          
          from .heuristics import compute_cheap_heuristics
          from .mutation import (
              compute_gap_signal,
              compute_survivor_density,
              detect_mutation_config,
              identify_survivor_clusters,
              run_bounded_mutation,
          )
          
          
          @dataclass
          class TestPressureResult:
              mutation_config_present: bool = False
              mutation_tools_detected: list = field(default_factory=list)
              ci_integrated: bool = False
              mutation_run: bool = False
              mutation_scope: list = field(default_factory=list)
              per_file: list = field(default_factory=list)
              survivor_density: dict = field(default_factory=dict)
              survivor_clusters: list = field(default_factory=list)
              gap_signal: str = "not assessed"
              cheap_heuristics: dict = field(default_factory=dict)
          
              def as_dict(self) -> dict:
                  return {
                      "mutation_config_present": self.mutation_config_present,
                      "mutation_tools_detected": self.mutation_tools_detected,
                      "ci_integrated": self.ci_integrated,
                      "mutation_run": self.mutation_run,
                      "mutation_scope": self.mutation_scope,
                      "per_file": self.per_file,
                      "survivor_density": self.survivor_density,
                      "survivor_clusters": self.survivor_clusters,
                      "gap_signal": self.gap_signal,
                      "cheap_heuristics": self.cheap_heuristics,
                  }
          
          
          def _overall_coverage(coverage_data) -> float | None:
              """Reduce coverage_data to a single line-coverage ratio, if supplied.
              We only have covered lines, not total lines, so we cannot compute a true
              ratio here - return None unless an explicit ``{"_overall": ratio}`` is
              present. Kept separate so the wiring teammate can pass a real ratio."""
              if isinstance(coverage_data, dict):
                  overall = coverage_data.get("_overall")
                  if isinstance(overall, (int, float)):
                      return float(overall)
              return None
          
          
          def scan_test_pressure(repo_root: Path, hot_files: list | None = None,
                                 opt_in: bool = False, coverage_data=None) -> dict:
              """Top-level Layer-1 write-side scan. Merges the mutation tier and the cheap
              always-on heuristics into one ``test_pressure`` block ready to drop into
              run-context.json. Never raises.
          
              ``opt_in`` gates the (mutating, code-running) bounded mutation pass; the
              cheap heuristics and config detection always run. ``coverage_data``
              (optional) feeds both the boundary heuristic and the mutation gap signal -
              absent, both report "not assessed" rather than guessing.
              """
              repo_root = Path(repo_root)
          
              config = detect_mutation_config(repo_root)
              mutation = run_bounded_mutation(repo_root, hot_files, opt_in=opt_in)
              per_file = mutation.get("per_file", [])
              density = compute_survivor_density(per_file)
              clusters = identify_survivor_clusters(per_file)
          
              coverage = _overall_coverage(coverage_data)
              mutation_score = (1.0 - density["overall"]) if density["overall"] is not None else None
              gap = compute_gap_signal(coverage, mutation_score)
          
              cheap = compute_cheap_heuristics(repo_root, coverage_data)
          
              result = TestPressureResult(
                  mutation_config_present=config["present"],
                  mutation_tools_detected=config["tools"],
                  ci_integrated=config["ci_integrated"],
                  mutation_run=mutation.get("mutation_run", False),
                  mutation_scope=mutation.get("scope", []),
                  per_file=per_file,
                  survivor_density=density,
                  survivor_clusters=clusters,
                  gap_signal=gap,
                  cheap_heuristics=cheap,
              )
              block = result.as_dict()
              # Surface why a mutation run didn't happen, for the report prose.
              if "reason" in mutation:
                  block["mutation_note"] = mutation["reason"]
              return block
          
        • common.py 1.8 KB
          """Shared file walking for the test-pressure detectors.
          
          Every helper here is best-effort: a broken symlink, an unreadable file, or an
          EXCLUDE_DIRS hit degrades to "skip this path", never an exception that aborts
          the whole scan.
          """
          from __future__ import annotations
          
          import re
          from pathlib import Path
          
          from lib.doc_graph import EXCLUDE_DIRS
          
          # Per-heuristic cap so a pathological repo can't bloat the run-context block.
          MAX_FINDINGS = 50
          
          _PY_TEST_RE = re.compile(r"(^test_.*\.py$|.*_test\.py$)")
          _TS_TEST_RE = re.compile(r".*\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$")
          _GO_TEST_RE = re.compile(r".*_test\.go$")
          
          
          def _read(path: Path) -> str:
              try:
                  return path.read_text(encoding="utf-8", errors="ignore")
              except OSError:
                  return ""
          
          
          def _iter_files(repo_root: Path, exts: set[str] | None = None) -> list[Path]:
              """Files under repo_root with EXCLUDE_DIRS pruned. Best-effort, never raises."""
              out: list[Path] = []
              try:
                  walker = repo_root.rglob("*")
              except OSError:  # pragma: no cover - defensive
                  return out
              for path in walker:
                  try:
                      if not path.is_file():
                          continue
                      rel = path.relative_to(repo_root)
                  except OSError:  # pragma: no cover - broken symlink etc.
                      continue
                  if any(part in EXCLUDE_DIRS for part in rel.parts):
                      continue
                  if exts is not None and path.suffix.lower() not in exts:
                      continue
                  out.append(path)
              return out
          
          
          def _is_test_file(path: Path) -> bool:
              name = path.name
              return bool(
                  _PY_TEST_RE.match(name) or _TS_TEST_RE.match(name) or _GO_TEST_RE.match(name)
              )
          
          
          def _rel(repo_root: Path, path: Path) -> str:
              try:
                  return str(path.relative_to(repo_root))
              except ValueError:  # pragma: no cover
                  return str(path)
          
        • heuristics.py 18 KB
          """Cheap, always-on hollow-test heuristics (candidate signals only).
          
          Three syntactic fingerprints of hollow tests, each tuned for few false
          positives because they are *candidates for human judgement, never verdicts*:
          
            1. **Assertion on internals** - a test that asserts on a private/internal
               field (`_x`, a Go unexported field, `#private`) with no assertion on any
               public side-effect. The "meridian resume-guard" fingerprint.
            2. **Untested boundaries** - `<=`/`<`/`>=`/`>`/`+1`/`-1` comparisons that are
               covered but, as far as we can tell, exercised on only one side.
            3. **Duplicate truth** - a *field* (class/instance attribute or module-level
               name) only ever assigned from another field. Two names for one fact.
          
          Every signal here is a candidate. None is a verdict.
          """
          from __future__ import annotations
          
          import ast
          import re
          from pathlib import Path
          
          from .common import MAX_FINDINGS, _is_test_file, _iter_files, _read, _rel
          
          CHEAP_HEURISTIC_NOTE = (
              "candidate signals for human judgement, never verdicts"
          )
          
          
          # ── heuristic 1: assertion on internal state ──────────────────────────────────
          
          def _root_name(node: ast.AST) -> str:
              """Leftmost Name in an attribute chain (``a.b.c`` -> ``a``)."""
              while isinstance(node, ast.Attribute):
                  node = node.value
              return node.id if isinstance(node, ast.Name) else "?"
          
          
          def _asserted_expressions(func: ast.AST) -> list[ast.AST]:
              """Expressions actually asserted on inside a test function. For unittest-style
              ``self.assertEqual(a, b)`` we take the args, not the assert* method name
              itself; for pytest ``assert expr`` we take the tested expression."""
              exprs: list[ast.AST] = []
              for node in ast.walk(func):
                  if isinstance(node, ast.Assert):
                      exprs.append(node.test)
                  elif (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
                        and node.func.attr.startswith("assert")):
                      exprs.extend(node.args)
              return exprs
          
          
          def _classify_assertion(expr: ast.AST) -> tuple[list[tuple[str, str]], bool]:
              """Split an asserted expression into (private-field reads, saw_public_effect).
          
              Attributes in call-position (``x.method(...)``) are invocations, not field
              reads. A *private* such attribute is the subject under test (``mod._helper(
              ...)``), so it is neither an internal read nor a public effect; a *public*
              method call still counts as observable behaviour.
              """
              called_attr_ids = {
                  id(sub.func) for sub in ast.walk(expr)
                  if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute)
              }
              internal: list[tuple[str, str]] = []  # (subject, field)
              has_public = False
              for sub in ast.walk(expr):
                  if not isinstance(sub, ast.Attribute):
                      continue
                  name = sub.attr
                  is_private = name.startswith("_") and not name.startswith("__")
                  if is_private:
                      if id(sub) in called_attr_ids:
                          continue  # private helper invoked as the subject under test
                      internal.append((_root_name(sub.value), name))
                  elif not name.startswith("__"):
                      has_public = True
              return internal, has_public
          
          
          def _py_assertion_internal(path: Path, rel: str) -> list[dict]:
              """Python (AST): test functions that assert on a private ``_field`` but on no
              public attribute or method. Conservative - both conditions must hold.
          
              A private attribute used as the *callee* of a call (``mod._helper(...)``) is
              the subject under test, not internal state being read: testing a private
              helper directly is a legitimate, common pattern and is excluded. Only a
              private attribute *read as a value* counts as an internal-state assertion.
              """
              try:
                  tree = ast.parse(_read(path))
              except (SyntaxError, ValueError):
                  return []
              findings: list[dict] = []
              for func in ast.walk(tree):
                  if not isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)):
                      continue
                  if not func.name.startswith("test"):
                      continue
                  internal: list[tuple[str, str]] = []
                  has_public = False
                  for expr in _asserted_expressions(func):
                      expr_internal, expr_public = _classify_assertion(expr)
                      internal.extend(expr_internal)
                      has_public = has_public or expr_public
                  if internal and not has_public:
                      seen: set[str] = set()
                      for subject, fieldname in internal:
                          if fieldname in seen:
                              continue
                          seen.add(fieldname)
                          findings.append({
                              "test_file": rel, "subject_function": f"{func.name}:{subject}",
                              "internal_field": fieldname, "confidence": "medium",
                          })
              return findings
          
          
          _TS_EXPECT_INTERNAL_RE = re.compile(
              r"expect\s*\(\s*[\w.$\[\]'\"]*?[.#](_\w+|#\w+|\w+)")
          _GO_ASSERT_INTERNAL_RE = re.compile(
              r"(?:assert|require)\.\w+\([^)]*?\b\w+\.([a-z]\w*)")
          
          
          def _regex_assertion_internal(path: Path, rel: str, lang: str) -> list[dict]:
              """TS/JS and Go: conservative regex for assertions reading a private field.
              Lower confidence than the Python AST path - no per-test public-side-effect
              check, just the presence of an internal-field assertion."""
              text = _read(path)
              findings: list[dict] = []
              seen: set[str] = set()
              if lang == "ts":
                  # Only flag genuinely private accessors: _underscore or #private.
                  rx = re.compile(r"expect\s*\(\s*[\w.$\[\]'\"]*?[.#](_\w+|#\w+)")
                  for m in rx.finditer(text):
                      fld = m.group(1)
                      if fld in seen:
                          continue
                      seen.add(fld)
                      findings.append({"test_file": rel, "subject_function": "(file)",
                                       "internal_field": fld, "confidence": "low"})
              elif lang == "go":
                  for m in _GO_ASSERT_INTERNAL_RE.finditer(text):
                      fld = m.group(1)
                      if fld in seen:
                          continue
                      seen.add(fld)
                      findings.append({"test_file": rel, "subject_function": "(file)",
                                       "internal_field": fld, "confidence": "low"})
              return findings
          
          
          def detect_assertion_on_internal(repo_root: Path,
                                           test_files: list | None = None) -> list[dict]:
              """Tests that pin private/internal state instead of public behaviour.
          
              The meridian resume-guard fingerprint. Returns
              ``[{test_file, subject_function, internal_field, confidence}]``. Degrades
              per-file: a parse failure on one test file skips it, never the whole scan.
              """
              repo_root = Path(repo_root)
              if test_files is None:
                  files = [p for p in _iter_files(
                      repo_root, {".py", ".ts", ".tsx", ".js", ".jsx", ".go"})
                      if _is_test_file(p)]
              else:
                  files = [Path(f) for f in test_files]
              findings: list[dict] = []
              for path in files:
                  rel = _rel(repo_root, path)
                  try:
                      if path.suffix == ".py":
                          findings.extend(_py_assertion_internal(path, rel))
                      elif path.suffix in {".ts", ".tsx", ".js", ".jsx"}:
                          findings.extend(_regex_assertion_internal(path, rel, "ts"))
                      elif path.suffix == ".go":
                          findings.extend(_regex_assertion_internal(path, rel, "go"))
                  except Exception:  # pragma: no cover - never crash the assessment
                      continue
                  if len(findings) >= MAX_FINDINGS:
                      break
              return findings[:MAX_FINDINGS]
          
          
          # ── heuristic 2: untested boundaries ──────────────────────────────────────────
          
          _CMP_SYMBOL = {ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">="}
          _BOUNDARY_RE = re.compile(r"(<=|>=|<|>|\+\s*1\b|-\s*1\b)")
          
          
          def _normalise_coverage(coverage_data) -> set[tuple[str, int]] | None:
              """Accept {relpath: [lines]} or {relpath: {line: hits}} -> {(relpath, line)}.
              None stays None (we cannot assess boundaries without it). A reserved
              ``_overall`` key (the line-coverage ratio) is ignored here."""
              if coverage_data is None:
                  return None
              covered: set[tuple[str, int]] = set()
              try:
                  for fname, lines in coverage_data.items():
                      if fname == "_overall":
                          continue
                      if isinstance(lines, dict):
                          iterable = (ln for ln, hits in lines.items() if hits)
                      else:
                          iterable = lines
                      for ln in iterable:
                          covered.add((str(fname), int(ln)))
              except (AttributeError, TypeError, ValueError):  # pragma: no cover - defensive
                  return set()
              return covered
          
          
          def _py_boundaries(path: Path, rel: str,
                             covered: set[tuple[str, int]]) -> list[dict]:
              try:
                  tree = ast.parse(_read(path))
              except (SyntaxError, ValueError):
                  return []
              out: list[dict] = []
              for node in ast.walk(tree):
                  op_symbol = None
                  if isinstance(node, ast.Compare):
                      for op in node.ops:
                          if type(op) in _CMP_SYMBOL:
                              op_symbol = _CMP_SYMBOL[type(op)]
                              break
                  elif (isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub))
                        and isinstance(node.right, ast.Constant) and node.right.value == 1):
                      op_symbol = "+1" if isinstance(node.op, ast.Add) else "-1"
                  if op_symbol is None:
                      continue
                  line = getattr(node, "lineno", None)
                  if line is None or (rel, line) not in covered:
                      continue
                  out.append({"file": rel, "line": line, "operator": op_symbol,
                              "covered": True, "boundary_tested": False})
              return out
          
          
          def _regex_boundaries(path: Path, rel: str,
                                covered: set[tuple[str, int]]) -> list[dict]:
              out: list[dict] = []
              for i, line in enumerate(_read(path).splitlines(), start=1):
                  if (rel, i) not in covered:
                      continue
                  m = _BOUNDARY_RE.search(line)
                  if m:
                      op = re.sub(r"\s+", "", m.group(1))
                      out.append({"file": rel, "line": i, "operator": op,
                                  "covered": True, "boundary_tested": False})
              return out
          
          
          def detect_untested_boundaries(repo_root: Path, coverage_data=None) -> list[dict]:
              """Boundary comparisons that are covered but, as far as we can tell, exercised
              on only one side. Off-by-one territory.
          
              Requires ``coverage_data`` (``{relpath: [lines]}`` or ``{relpath: {line:
              hits}}``) - without it we cannot say a boundary was reached, so the result is
              empty. ``boundary_tested`` is always False here: line coverage cannot prove
              both sides of a comparison were exercised, so every hit is a *candidate*.
              Returns ``[{file, line, operator, covered, boundary_tested}]``.
              """
              repo_root = Path(repo_root)
              covered = _normalise_coverage(coverage_data)
              if not covered:
                  return []
              findings: list[dict] = []
              for path in _iter_files(repo_root, {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs"}):
                  if _is_test_file(path):
                      continue
                  rel = _rel(repo_root, path)
                  try:
                      if path.suffix == ".py":
                          findings.extend(_py_boundaries(path, rel, covered))
                      else:
                          findings.extend(_regex_boundaries(path, rel, covered))
                  except Exception:  # pragma: no cover - never crash
                      continue
                  if len(findings) >= MAX_FINDINGS:
                      break
              return findings[:MAX_FINDINGS]
          
          
          # ── heuristic 3: duplicate truth ──────────────────────────────────────────────
          
          def _classify_rhs(node: ast.AST) -> tuple[bool, str | None]:
              """Is RHS a derivation of another single field? Returns (is_derived, source).
              Derived := a bare Name/Attribute, or ``<Name/Attribute> +/- <constant>``."""
              if isinstance(node, ast.Name):
                  return True, node.id
              if isinstance(node, ast.Attribute):
                  return True, node.attr
              if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)):
                  left_derived, src = _classify_rhs(node.left)
                  if left_derived and isinstance(node.right, ast.Constant):
                      return True, src
              return False, None
          
          
          def _target_field(node: ast.AST, in_function: bool) -> str | None:
              """Field name for an assignment target, or None if the target is not a
              *field*.
          
              A field is an instance/class attribute (``self.x`` -> ``x``) at any depth,
              or a module/class-level bare name (``x`` -> ``x``). A bare name assigned
              *inside a function body* is a transient local variable - ordinary aliasing
              like ``raw = result.stdout`` - and is explicitly NOT a duplicate-truth field.
              """
              if isinstance(node, ast.Attribute):
                  return node.attr
              if isinstance(node, ast.Name) and not in_function:
                  return node.id
              return None
          
          
          def _collect_field_assignments(
              tree: ast.AST,
          ) -> dict[str, list[tuple[bool, str | None]]]:
              """Walk the tree tracking lexical function scope so transient locals are
              excluded. Returns ``{field_name: [(is_derived, source), ...]}``."""
              assignments: dict[str, list[tuple[bool, str | None]]] = {}
          
              def visit(node: ast.AST, in_function: bool) -> None:
                  for child in ast.iter_child_nodes(node):
                      if isinstance(child, ast.Assign):
                          derived, src = _classify_rhs(child.value)
                          for tgt in child.targets:
                              fieldname = _target_field(tgt, in_function)
                              if fieldname is not None:
                                  assignments.setdefault(fieldname, []).append((derived, src))
                      child_in_function = in_function or isinstance(
                          child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda))
                      visit(child, child_in_function)
          
              visit(tree, False)
              return assignments
          
          
          def _py_duplicate_truth(path: Path, rel: str) -> list[dict]:
              try:
                  tree = ast.parse(_read(path))
              except (SyntaxError, ValueError):
                  return []
              assignments = _collect_field_assignments(tree)
              findings: list[dict] = []
              for fieldname, recs in assignments.items():
                  if not recs:
                      continue
                  # All assignments must be derivations from a single consistent source
                  # that is a different field. Any independent assignment disqualifies it.
                  if not all(derived for derived, _ in recs):
                      continue
                  sources = {src for _, src in recs if src is not None}
                  if len(sources) != 1:
                      continue
                  source = sources.pop()
                  if source == fieldname:
                      continue
                  findings.append({"file": rel, "field_name": fieldname,
                                   "derives_from": source, "confidence": "medium"})
              return findings
          
          
          _TS_DERIVE_RE = re.compile(r"this\.(\w+)\s*=\s*this\.(\w+)\s*(?:[+-]\s*\d+\s*)?;")
          _TS_ANY_ASSIGN_RE = re.compile(r"this\.(\w+)\s*=")
          
          
          def _ts_duplicate_truth(path: Path, rel: str) -> list[dict]:
              """TS/JS regex: ``this.x = this.y (+ k)`` where *every* assignment of ``x``
              matches the derive pattern. Conservative - any non-derive assignment of the
              same field disqualifies it. ``this.x`` is already an instance attribute, so
              no scope filtering is needed here."""
              text = _read(path)
              derive_sources: dict[str, set[str]] = {}
              derive_count: dict[str, int] = {}
              for m in _TS_DERIVE_RE.finditer(text):
                  fieldname = m.group(1)
                  derive_sources.setdefault(fieldname, set()).add(m.group(2))
                  derive_count[fieldname] = derive_count.get(fieldname, 0) + 1
              total_assign: dict[str, int] = {}
              for m in _TS_ANY_ASSIGN_RE.finditer(text):
                  total_assign[m.group(1)] = total_assign.get(m.group(1), 0) + 1
              findings: list[dict] = []
              for fieldname, sources in derive_sources.items():
                  # Every assignment of the field must be a derivation.
                  if total_assign.get(fieldname, 0) != derive_count.get(fieldname, 0):
                      continue
                  if len(sources) != 1:
                      continue
                  source = next(iter(sources))
                  if source == fieldname:
                      continue
                  findings.append({"file": rel, "field_name": fieldname,
                                   "derives_from": source, "confidence": "low"})
              return findings
          
          
          def detect_duplicate_truth(repo_root: Path) -> list[dict]:
              """Fields only ever assigned from another field, never independently computed
              - two names for one fact. A test asserting on one says nothing about the
              other; a refactor that decouples them silently breaks the invariant.
          
              Scoped to *fields*: instance/class attributes (``self.x = self.y``) and
              module/class-level names. Function-local bare-name aliasing (``raw =
              result.stdout``, ``current = node.parent``) is a transient binding, not a
              duplicate source of truth, and is excluded.
          
              Returns ``[{file, field_name, derives_from, confidence}]``. Degrades per-file.
              """
              repo_root = Path(repo_root)
              findings: list[dict] = []
              for path in _iter_files(repo_root, {".py", ".ts", ".tsx", ".js", ".jsx"}):
                  if _is_test_file(path):
                      continue
                  rel = _rel(repo_root, path)
                  try:
                      if path.suffix == ".py":
                          findings.extend(_py_duplicate_truth(path, rel))
                      else:
                          findings.extend(_ts_duplicate_truth(path, rel))
                  except Exception:  # pragma: no cover - never crash
                      continue
                  if len(findings) >= MAX_FINDINGS:
                      break
              return findings[:MAX_FINDINGS]
          
          
          def compute_cheap_heuristics(repo_root: Path, coverage_data=None) -> dict:
              """Aggregate the three always-on hollow-test heuristics. Never raises - each
              detector degrades independently, so a failure in one still returns the
              others. Every finding is a *candidate*, flagged by ``confidence_note``.
              """
              repo_root = Path(repo_root)
              return {
                  "assertion_on_internal": detect_assertion_on_internal(repo_root),
                  "untested_boundaries": detect_untested_boundaries(repo_root, coverage_data),
                  "duplicate_truth": detect_duplicate_truth(repo_root),
                  "confidence_note": CHEAP_HEURISTIC_NOTE,
              }
          
        • mutation.py 19.1 KB
          """Mutation tier: config detection, bounded opt-in runs, survivor aggregation.
          
          The only direct evidence that a test would catch a regression is to introduce
          one and watch a test go red. We detect mutation-testing *configuration* as a
          standing signal - a repo that runs mutation testing in CI has already answered
          the question - and, only when explicitly opted in, run a time-boxed mutation
          pass over the hottest files and report survivor density and clusters. Mutation
          mutates and *runs* code, so it is never part of a default read-only assessment.
          """
          from __future__ import annotations
          
          import json
          import os
          import re
          import shutil
          import subprocess
          import tempfile
          import time
          import xml.etree.ElementTree as ET
          from pathlib import Path
          
          from .common import _iter_files, _read
          
          # ── tuning constants ────────────────────────────────────────────────────────
          
          MUTATION_TIMEOUT = 300          # seconds; a mutation run is bounded or it degrades
          MAX_FILES_TO_MUTATE = 5         # only the hottest files - mutation is O(mutants)
          CLUSTER_MIN_SURVIVORS = 3       # files at/above this survivor count are a "cluster"
          HIGH_COVERAGE = 0.80            # gap-signal threshold: line coverage this high ...
          LOW_MUTATION_SCORE = 0.50       # ... paired with a mutation score this low = a gap
          MAX_FINDINGS = 50               # per-heuristic cap so a pathological repo can't bloat
          
          # ── mutation config detection ────────────────────────────────────────────────
          
          # Exact config filenames -> tool. Presence of any of these is itself a signal
          # that the project takes test strength seriously, independent of whether we run
          # anything.
          _MUTATION_CONFIG_FILES: dict[str, str] = {
              "stryker.conf.js": "stryker", "stryker.conf.json": "stryker",
              "stryker.conf.mjs": "stryker", "stryker.config.js": "stryker",
              "stryker.config.json": "stryker", "stryker.config.mjs": "stryker",
              "mutmut.toml": "mutmut", ".mutmut.toml": "mutmut",
              "cosmic-ray.toml": "cosmic-ray",
          }
          
          # Tokens that, when found in a CI file, indicate a mutation tool is invoked.
          _MUTATION_CI_TOKENS: dict[str, str] = {
              "stryker": "stryker", "mutmut": "mutmut", "cosmic-ray": "cosmic-ray",
              "gremlins": "gremlins", "go-mutesting": "go-mutesting",
              "cargo-mutants": "cargo-mutants", "cargo mutants": "cargo-mutants",
          }
          
          _CI_FILE_NAMES = (".gitlab-ci.yml", ".gitlab-ci.yaml", "Jenkinsfile")
          
          
          def detect_mutation_config(repo_root: Path) -> dict:
              """Detect mutation-testing configuration and CI integration. Never raises.
          
              Returns ``{present, tools, ci_integrated}``. ``present`` is true if any
              config file *or* CI invocation is found - presence of mutation testing is
              itself the signal, so a repo that merely configures it scores differently
              from one that has nothing. ``tools`` is the sorted union of tools seen in
              config files and CI; ``ci_integrated`` is true only when a CI file invokes
              one of them.
              """
              repo_root = Path(repo_root)
              config_tools: set[str] = set()
              ci_tools: set[str] = set()
          
              # 1. Config files at any depth.
              for path in _iter_files(repo_root):
                  name = path.name
                  if name in _MUTATION_CONFIG_FILES:
                      config_tools.add(_MUTATION_CONFIG_FILES[name])
                  elif name == "Cargo.toml":
                      text = _read(path).lower()
                      if "cargo-mutants" in text or "metadata.mutants" in text:
                          config_tools.add("cargo-mutants")
                  elif name == "pyproject.toml":
                      text = _read(path).lower()
                      if "[tool.mutmut]" in text:
                          config_tools.add("mutmut")
                      if "[tool.cosmic-ray]" in text or "[tool.cosmic_ray]" in text:
                          config_tools.add("cosmic-ray")
                  elif name == "setup.cfg":
                      if "[mutmut]" in _read(path).lower():
                          config_tools.add("mutmut")
          
              # 2. CI invocations.
              for ci_file in _ci_files(repo_root):
                  text = _read(ci_file).lower()
                  for token, tool in _MUTATION_CI_TOKENS.items():
                      if token in text:
                          ci_tools.add(tool)
          
              tools = sorted(config_tools | ci_tools)
              return {
                  "present": bool(tools),
                  "tools": tools,
                  "ci_integrated": bool(ci_tools),
              }
          
          
          def _ci_files(repo_root: Path) -> list[Path]:
              out: list[Path] = []
              wf_dir = repo_root / ".github" / "workflows"
              if wf_dir.is_dir():
                  for p in wf_dir.rglob("*"):
                      if p.is_file() and p.suffix.lower() in {".yml", ".yaml"}:
                          out.append(p)
              for name in _CI_FILE_NAMES:
                  p = repo_root / name
                  if p.is_file():
                      out.append(p)
              return out
          
          
          # ── mutation output parsers ──────────────────────────────────────────────────
          
          def _parse_stryker_json(stdout: str) -> list[dict]:
              """Stryker JSON report (mutation-testing-elements schema):
              ``{"files": {"<path>": {"mutants": [{"status": "Killed"|"Survived"|...}]}}}``."""
              try:
                  data = json.loads(stdout)
              except (json.JSONDecodeError, ValueError):
                  return []
              out: list[dict] = []
              for path, info in (data.get("files") or {}).items():
                  killed = survived = 0
                  for m in info.get("mutants", []):
                      status = str(m.get("status", "")).lower()
                      if status in {"killed", "timeout"}:
                          killed += 1
                      elif status in {"survived", "nocoverage", "no coverage"}:
                          survived += 1
                  total = killed + survived
                  if total:
                      out.append({"file": path, "killed": killed,
                                  "survived": survived, "total": total})
              return out
          
          
          def _parse_mutmut(stdout: str) -> list[dict]:
              """mutmut survivor listing: lines of ``<path>:<line>``. We can only see
              survivors, so killed and total are unknown (None) - density treats them as
              missing rather than guessing."""
              out: dict[str, int] = {}
              rx = re.compile(r"^([\w./\\-]+\.py):(\d+)")
              for line in stdout.splitlines():
                  m = rx.match(line.strip())
                  if m:
                      out[m.group(1)] = out.get(m.group(1), 0) + 1
              return [{"file": f, "killed": None, "survived": n, "total": None}
                      for f, n in out.items()]
          
          
          def _testcase_file(testcase: ET.Element) -> str | None:
              """Derive the source file path for a mutmut junitxml ``<testcase>``.
          
              mutmut 2.x emits ``<testcase ... file="src/foo.py">`` - the file attribute
              is the source path directly. Some versions/tools instead encode it in
              ``classname`` as a dotted module path (``mutmut.src.foo`` -> ``src/foo.py``),
              so we fall back to that. Returns None when neither yields a path."""
              file_attr = testcase.get("file")
              if file_attr:
                  return file_attr
              classname = testcase.get("classname") or ""
              if classname.startswith("mutmut."):
                  dotted = classname[len("mutmut."):]
                  if dotted:
                      return dotted.replace(".", "/") + ".py"
              return None
          
          
          def _parse_mutmut_junitxml(xml_path: Path) -> list[dict]:
              """Parse ``mutmut junitxml`` output into per-file killed/survived/total.
          
              Unlike the survivor-only stdout listing, junitxml reports *every* mutant -
              one ``<testcase>`` each - so we recover real totals. A testcase with a
              ``<failure>`` child is a survivor (the suite did not catch the mutation);
              otherwise it was killed. Returns ``list[{file, killed, survived, total}]``
              with ``total = killed + survived``. Never raises - returns ``[]`` on any
              error (missing file, malformed XML, unexpected shape) so the run degrades
              gracefully to the stdout fallback."""
              try:
                  root = ET.parse(xml_path).getroot()
              except (ET.ParseError, OSError, ValueError):
                  return []
              per_file: dict[str, dict[str, int]] = {}
              try:
                  for testcase in root.iter("testcase"):
                      fname = _testcase_file(testcase)
                      if not fname:
                          continue
                      survived = testcase.find("failure") is not None
                      agg = per_file.setdefault(fname, {"killed": 0, "survived": 0})
                      agg["survived" if survived else "killed"] += 1
              except Exception:  # pragma: no cover - defensive: never crash the run
                  return []
              return [{"file": f, "killed": d["killed"], "survived": d["survived"],
                       "total": d["killed"] + d["survived"]}
                      for f, d in per_file.items()]
          
          
          def _parse_gremlins(stdout: str) -> list[dict]:
              """gremlins per-mutant lines: ``KILLED|LIVED|TIMED OUT|NOT COVERED ... <file>:<line>``.
              LIVED / NOT COVERED == survived."""
              killed: dict[str, int] = {}
              survived: dict[str, int] = {}
              rx = re.compile(r"^(KILLED|LIVED|TIMED OUT|NOT COVERED)\s+.*?([\w./\\-]+\.go):\d+")
              for line in stdout.splitlines():
                  m = rx.match(line.strip())
                  if not m:
                      continue
                  status, fname = m.group(1), m.group(2)
                  if status in {"LIVED", "NOT COVERED"}:
                      survived[fname] = survived.get(fname, 0) + 1
                  elif status == "KILLED":
                      killed[fname] = killed.get(fname, 0) + 1
              return _merge_killed_survived(killed, survived)
          
          
          def _parse_cargo_mutants(stdout: str) -> list[dict]:
              """cargo-mutants text outcomes: ``MISSED|CAUGHT|TIMEOUT|UNVIABLE ... <file>:<line>``.
              MISSED == survived; CAUGHT == killed; UNVIABLE/TIMEOUT ignored."""
              killed: dict[str, int] = {}
              survived: dict[str, int] = {}
              rx = re.compile(r"^(MISSED|CAUGHT|TIMEOUT|UNVIABLE)\s+.*?([\w./\\-]+\.rs):\d+")
              for line in stdout.splitlines():
                  m = rx.match(line.strip())
                  if not m:
                      continue
                  status, fname = m.group(1), m.group(2)
                  if status == "MISSED":
                      survived[fname] = survived.get(fname, 0) + 1
                  elif status == "CAUGHT":
                      killed[fname] = killed.get(fname, 0) + 1
              return _merge_killed_survived(killed, survived)
          
          
          def _merge_killed_survived(killed: dict[str, int],
                                     survived: dict[str, int]) -> list[dict]:
              out: list[dict] = []
              for fname in sorted(set(killed) | set(survived)):
                  k = killed.get(fname, 0)
                  s = survived.get(fname, 0)
                  out.append({"file": fname, "killed": k, "survived": s, "total": k + s})
              return out
          
          
          # ── bounded mutation run (opt-in only) ───────────────────────────────────────
          
          # Per-tool run spec. `cmd` builds the argv given (repo_root, scoped_files);
          # `parser` maps stdout -> list[{file, killed, survived, total}]. Tried in order;
          # the first whose language is present and which is on PATH wins (config-detected
          # tools are preferred via _select_mutation_tool).
          _MUTATION_TOOLS: list[dict] = [
              {"language": "typescript", "tool": "stryker", "exts": {".ts", ".tsx", ".js", ".jsx"},
               "cmd": lambda root, files: ["stryker", "run", "--reporters", "json"],
               "parser": _parse_stryker_json},
              {"language": "python", "tool": "mutmut", "exts": {".py"},
               "cmd": lambda root, files: ["mutmut", "run"],
               "parser": _parse_mutmut},
              {"language": "go", "tool": "gremlins", "exts": {".go"},
               "cmd": lambda root, files: ["gremlins", "unleash"],
               "parser": _parse_gremlins},
              {"language": "rust", "tool": "cargo-mutants", "exts": {".rs"},
               "cmd": lambda root, files: ["cargo", "mutants", "--no-shuffle"],
               "parser": _parse_cargo_mutants},
          ]
          
          
          def _has_ext(repo_root: Path, exts: set[str]) -> bool:
              for _ in _iter_files(repo_root, exts):
                  return True
              return False
          
          
          def _select_mutation_tool(repo_root: Path, detected_tools: list[str]) -> dict | None:
              """Pick a tool: prefer one whose config we detected, else any whose language
              is present. Must be on PATH (first argv token). Returns the spec or None."""
              by_pref = sorted(
                  _MUTATION_TOOLS,
                  key=lambda s: 0 if s["tool"] in detected_tools else 1,
              )
              for spec in by_pref:
                  if not _has_ext(repo_root, spec["exts"]):
                      continue
                  if shutil.which(spec["cmd"](repo_root, [])[0]) is None:
                      continue
                  return spec
              return None
          
          
          def run_bounded_mutation(repo_root: Path, hot_files: list | None = None,
                                   opt_in: bool = False) -> dict:
              """Time-boxed, opt-in mutation pass over the hottest files. Never raises.
          
              Mutation testing mutates source and *runs* the suite, so it is never part of
              a default read-only assessment - ``opt_in`` must be explicitly true. When
              run, it is bounded by ``MUTATION_TIMEOUT`` and ``MAX_FILES_TO_MUTATE``.
              Degrades gracefully: no tool on PATH -> ``{mutation_run: False, available:
              False}``; a timeout or crash -> the same with a ``reason``. ``mutation_run``
              is True only when the parser recovered mutant records: a tool that runs but
              yields no parsed mutants returns ``mutation_run: False`` with a ``reason``.
          
              Returns ``{mutation_run, available, tool, scope, per_file, reason}`` (keys
              present as relevant).
              """
              repo_root = Path(repo_root)
              if not opt_in:
                  return {"mutation_run": False, "available": False,
                          "reason": "opt-in required: mutation testing mutates and runs code"}
          
              detected = detect_mutation_config(repo_root)["tools"]
              spec = _select_mutation_tool(repo_root, detected)
              if spec is None:
                  return {"mutation_run": False, "available": False,
                          "reason": "no supported mutation tool on PATH for languages present"}
          
              scope = [str(f) for f in (hot_files or [])][:MAX_FILES_TO_MUTATE]
              started = time.monotonic()
              try:
                  proc = subprocess.run(
                      spec["cmd"](repo_root, scope), cwd=str(repo_root),
                      capture_output=True, text=True, timeout=MUTATION_TIMEOUT, check=False,
                  )
              except subprocess.TimeoutExpired:
                  return {"mutation_run": False, "available": True, "tool": spec["tool"],
                          "scope": scope, "per_file": [],
                          "reason": f"exceeded {MUTATION_TIMEOUT}s timeout"}
              except (OSError, FileNotFoundError) as e:  # pragma: no cover - defensive
                  return {"mutation_run": False, "available": True, "tool": spec["tool"],
                          "scope": scope, "per_file": [], "reason": str(e)}
          
              try:
                  per_file = spec["parser"](proc.stdout)
              except Exception:  # pragma: no cover - parser must never crash the run
                  per_file = []
          
              # mutmut's stdout only lists survivors (no totals), so density can't be
              # derived from it. A second `mutmut junitxml` call reports every mutant -
              # giving real killed/survived/total per file. The two-step run shares the
              # MUTATION_TIMEOUT budget; junitxml is best-effort and falls back to the
              # survivor-only stdout parse on any failure or empty result.
              if spec["tool"] == "mutmut":
                  remaining = MUTATION_TIMEOUT - (time.monotonic() - started)
                  if remaining > 0:
                      xml_per_file = _run_mutmut_junitxml(repo_root, remaining)
                      if xml_per_file:
                          per_file = xml_per_file
          
              # A returned subprocess is not a run: the tool can exit (cleanly or not)
              # without the parser recovering a single mutant - a stryker spec on a repo
              # whose runner is not wired, a JSON reporter writing to a file instead of
              # stdout. Claiming a run off an empty parse would lift the Layer 6 cap with
              # no evidence behind it, so only parsed mutant data counts.
              if not per_file:
                  return {"mutation_run": False, "available": True, "tool": spec["tool"],
                          "scope": scope, "per_file": [],
                          "reason": (f"no mutant records recovered from {spec['tool']} "
                                     f"output (exit code {proc.returncode})")}
          
              return {"mutation_run": True, "available": True, "tool": spec["tool"],
                      "scope": scope, "per_file": per_file}
          
          
          def _run_mutmut_junitxml(repo_root: Path, timeout: float) -> list[dict]:
              """Run ``mutmut junitxml`` (after a completed ``mutmut run``) and parse it
              into per-file totals. mutmut writes the XML report to stdout, so we capture
              it to a temp file and hand that to ``_parse_mutmut_junitxml``. Best-effort:
              returns ``[]`` on any failure (older mutmut without the subcommand, timeout,
              crash, malformed output) so the caller falls back to the stdout parse."""
              try:
                  proc = subprocess.run(
                      ["mutmut", "junitxml"], cwd=str(repo_root),
                      capture_output=True, text=True, timeout=max(1.0, timeout), check=False,
                  )
              except (subprocess.TimeoutExpired, OSError):  # pragma: no cover - defensive
                  return []
              if proc.returncode != 0 or not proc.stdout.strip():
                  return []
              tmp_path: str | None = None
              try:
                  with tempfile.NamedTemporaryFile(
                      mode="w", suffix=".xml", delete=False, encoding="utf-8") as fh:
                      fh.write(proc.stdout)
                      tmp_path = fh.name
                  return _parse_mutmut_junitxml(Path(tmp_path))
              except OSError:  # pragma: no cover - defensive
                  return []
              finally:
                  if tmp_path:
                      try:
                          os.unlink(tmp_path)
                      except OSError:  # pragma: no cover - defensive
                          pass
          
          
          # ── aggregation over per-file mutation results ────────────────────────────────
          
          def compute_survivor_density(per_file: list[dict]) -> dict:
              """Survivors normalised by total mutants. Returns ``{overall, total_survived,
              total_mutants, by_file}``. ``overall`` is None when no totals are known
              (e.g. mutmut, which only lists survivors)."""
              total_survived = 0
              total_mutants = 0
              have_totals = False
              by_file: dict[str, int] = {}
              for f in per_file or []:
                  survived = f.get("survived") or 0
                  by_file[f.get("file", "?")] = survived
                  total_survived += survived
                  total = f.get("total")
                  if total is not None:
                      total_mutants += total
                      have_totals = True
              overall = (total_survived / total_mutants) if (have_totals and total_mutants) else None
              return {
                  "overall": overall,
                  "total_survived": total_survived,
                  "total_mutants": total_mutants if have_totals else None,
                  "by_file": by_file,
              }
          
          
          def identify_survivor_clusters(per_file: list[dict]) -> list[dict]:
              """Files whose survivor count clears ``CLUSTER_MIN_SURVIVORS`` - a cluster of
              survivors in one file points at a specific under-tested unit. Sorted by
              survivor count, descending."""
              clusters = [
                  {"file": f.get("file", "?"), "survived": f.get("survived") or 0}
                  for f in per_file or []
                  if (f.get("survived") or 0) >= CLUSTER_MIN_SURVIVORS
              ]
              clusters.sort(key=lambda c: c["survived"], reverse=True)
              return clusters[:MAX_FINDINGS]
          
          
          def compute_gap_signal(coverage: float | None,
                                 mutation_score: float | None) -> str:
              """The decisive cross-signal: high line coverage paired with a low mutation
              score means the suite *runs* the code but doesn't *test* it. Returns
              ``"high coverage + low mutation score"`` | ``"no gap"`` | ``"not assessed"``."""
              if coverage is None or mutation_score is None:
                  return "not assessed"
              if coverage >= HIGH_COVERAGE and mutation_score <= LOW_MUTATION_SCORE:
                  return "high coverage + low mutation score"
              return "no gap"
          
        • __init__.py 2.7 KB
          """Write-side truth pressure: does the test suite actually pin behaviour down?
          
          Coverage says a line *ran*; it never says an assertion would *fail* if that line
          were wrong. This package gathers the signals that distinguish a suite which
          holds the code to account from one that merely visits it. Three tiers, cheapest
          first, each degrading to "not assessed" rather than ever blocking the
          assessment:
          
          **Mutation tier (decisive, expensive, opt-in)** - ``mutation.py``. Config
          detection plus a bounded, opt-in mutation run and survivor aggregation.
          
          **Cheap-heuristic tier (always-on, candidate signals only)** - ``heuristics.py``.
          Three syntactic fingerprints of hollow tests: assertion-on-internal,
          untested-boundaries, duplicate-truth.
          
          **Aggregation + facade** - ``aggregate.py``. ``scan_test_pressure`` merges both
          tiers into the ``test_pressure`` block consumed by run-context.json.
          
          This ``__init__`` is the stable public facade: it re-exports the same names the
          former single-module ``lib.test_pressure`` exposed, so importers and tests need
          no change. ``shutil`` and ``subprocess`` are re-exported so existing tests can
          monkeypatch ``lib.test_pressure.shutil`` / ``.subprocess`` (the same singleton
          module objects the mutation tier calls through).
          
          Boundary: every signal here is a *candidate*. None is a verdict. The output is
          grist for human (or LLM) judgement, scoped and labelled as such.
          """
          from __future__ import annotations
          
          # Re-exported so tests can monkeypatch lib.test_pressure.shutil/.subprocess.
          import shutil
          import subprocess
          
          from .aggregate import (
              TestPressureResult,
              _overall_coverage,
              scan_test_pressure,
          )
          from .common import MAX_FINDINGS
          from .heuristics import (
              CHEAP_HEURISTIC_NOTE,
              compute_cheap_heuristics,
              detect_assertion_on_internal,
              detect_duplicate_truth,
              detect_untested_boundaries,
          )
          from .mutation import (
              CLUSTER_MIN_SURVIVORS,
              HIGH_COVERAGE,
              LOW_MUTATION_SCORE,
              MAX_FILES_TO_MUTATE,
              MUTATION_TIMEOUT,
              _parse_cargo_mutants,
              _parse_gremlins,
              _parse_mutmut,
              _parse_mutmut_junitxml,
              _parse_stryker_json,
              compute_gap_signal,
              compute_survivor_density,
              detect_mutation_config,
              identify_survivor_clusters,
              run_bounded_mutation,
          )
          
          __all__ = [
              "scan_test_pressure",
              "TestPressureResult",
              "detect_mutation_config",
              "run_bounded_mutation",
              "compute_survivor_density",
              "identify_survivor_clusters",
              "compute_gap_signal",
              "compute_cheap_heuristics",
              "detect_assertion_on_internal",
              "detect_untested_boundaries",
              "detect_duplicate_truth",
              "CHEAP_HEURISTIC_NOTE",
              "MUTATION_TIMEOUT",
              "MAX_FILES_TO_MUTATE",
              "MAX_FINDINGS",
              "CLUSTER_MIN_SURVIVORS",
              "HIGH_COVERAGE",
              "LOW_MUTATION_SCORE",
          ]
          
      • accretion_ratchet.py 15.3 KB
        """Accretion-ratchet scan: files that only ever grow.
        
        The first of the three write-side tendencies named in this repo's north star.
        An agent does what is asked, and what is asked is feature after feature -
        nothing in that loop ever asks for a refactor, so files only grow. Absent a
        consciously requested restructuring, size and complexity ratchet monotonically
        upward. This module turns that tendency into a deterministic signal: the file
        whose accumulated line count never meaningfully comes back down.
        
        The instrument walks a file's full numstat history in *author-time order* and
        accumulates net delta (additions - deletions). A file is flagged when two
        conditions hold together:
        
          - **Monotonic growth.** The running net-delta is non-decreasing across the
            history - the file gained lines and effectively never gave them back. A
            single late refactor that cuts the file is enough to clear the flag.
          - **Low deletion fraction.** Across its whole history, deletions are a small
            share of total churn (``deletions / (additions + deletions)`` below a
            threshold). A file that churns by rewriting - deleting as much as it adds -
            is being maintained, not merely accreted, even if its net size still drifts
            up.
        
        Both are required: net growth alone is normal for any developing file; it is
        growth *with almost no deletion pressure* that fingerprints pure accretion.
        
        Determinism is non-negotiable - same repo, byte-identical output. The history
        is parsed once and sorted by author time (``%at``) with the commit SHA as a
        tie-breaker, so the accumulation order never depends on git's traversal
        heuristics or clone-to-clone ref ordering. ``--no-renames`` keeps a file under
        its current name only (a rename is not accretion), and ``--no-merges`` keeps
        merge double-counts out of the totals.
        
        Pure subprocess (git) + stdlib. No LLM calls, no heavy dependencies, every git
        call capped by ``GIT_TIMEOUT_SECONDS``. Degrades to ``available: False`` on any
        git failure and to ``reliable: False`` on degenerate history (shallow clone,
        squashed import - same verdict as every other churn consumer), never raises out
        of ``scan_accretion_ratchet``.
        
        CLI (standalone use)::
        
            uv run accretion_ratchet.py <repo_root> [--deletion-threshold 0.15] [--json OUT]
        """
        
        from __future__ import annotations
        
        import json
        import subprocess
        import sys
        from collections import defaultdict
        from dataclasses import dataclass, field
        from pathlib import Path
        from typing import Any
        
        try:
            from lib.git_churn import GIT_TIMEOUT_SECONDS, churn_is_degenerate
        except ImportError:  # standalone CLI: script dir is lib/, put scripts/ on path
            sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
            from lib.git_churn import GIT_TIMEOUT_SECONDS, churn_is_degenerate
        
        # A file is flagged only when deletions are below this share of its total churn:
        # deletions / (additions + deletions). 0.15 means "fewer than ~1 deleted line
        # for every 6 lines of churn" - the fingerprint of a file that is appended to
        # rather than reworked. A file that churns by rewriting clears the flag.
        DELETION_FRACTION_THRESHOLD = 0.15
        
        # Renames register as a single all-additions commit under the new name, which
        # would read as perfect accretion. Requiring at least this many commits filters
        # those single-touch artifacts: accretion is a property of repeated growth.
        MIN_COMMITS_FOR_ACCRETION = 3
        
        # Documentation is never accretion. A plan or changelog that grows by appending
        # is the normal life of a document and carries no change risk, yet scc scores
        # markdown, so a long document lands in the top size band on LOC alone. Matched
        # case-insensitively on the final suffix. Filtered by extension rather than by the
        # stats row's ``source``: scc-only languages (Dart) have no lizard row either.
        # ``.txt`` is deliberately absent: it covers CMakeLists.txt (build logic) and
        # requirements.txt (a manifest whose growth is a real accretion signal).
        DOC_SUFFIXES = frozenset({".md", ".markdown", ".mdx", ".rst", ".adoc"})
        
        # Average days per month for the time-span readout (Gregorian mean).
        DAYS_PER_MONTH = 30.44
        
        # Cap the files carried into run-context.json so a pathological repo can't bloat
        # the bus; the scan still measures every file, only the report list is capped.
        MAX_TOP_OFFENDERS = 10
        
        
        @dataclass(frozen=True)
        class AccretionFile:
            """A file whose line count only ever ratcheted upward.
        
            ``net_additions`` is the final accumulated additions - deletions (always
            positive for a flagged file). ``deletion_fraction`` is deletions over total
            churn across the whole history. ``time_span_months`` is the span from the
            file's first to last commit, a context cue for how long the ratchet ran.
            """
        
            path: str
            net_additions: int
            commit_count: int
            deletion_fraction: float
            time_span_months: float
        
            def to_dict(self) -> dict[str, Any]:
                return {
                    "path": self.path,
                    "net_additions": self.net_additions,
                    "commit_count": self.commit_count,
                    "deletion_fraction": round(self.deletion_fraction, 4),
                    "time_span_months": round(self.time_span_months, 1),
                }
        
        
        @dataclass
        class AccretionScan:
            """Result of an accretion-ratchet scan.
        
            ``available`` is False when the directory is not a git repo or git is
            unreachable. ``reliable`` is False on degenerate history (shallow clone,
            squashed import) where the per-file commit distribution carries no signal -
            same verdict definition as every other churn consumer. ``files`` holds the
            flagged ``AccretionFile`` records, sorted by net additions descending.
            """
        
            available: bool
            reason: str = ""
            reliable: bool = True
            deletion_fraction_threshold: float = DELETION_FRACTION_THRESHOLD
            files: list[AccretionFile] = field(default_factory=list)
        
            def summary(self) -> dict[str, Any]:
                top = self.files[:MAX_TOP_OFFENDERS]
                return {
                    "available": self.available,
                    "reason": self.reason,
                    "reliable": self.reliable,
                    "deletion_fraction_threshold": self.deletion_fraction_threshold,
                    "total_accreting": len(self.files),
                    "top_offenders": [f.to_dict() for f in top],
                }
        
        
        @dataclass
        class _FileHistory:
            """Per-file accumulation state, built up while walking the commit history."""
        
            additions: int = 0
            deletions: int = 0
            commit_count: int = 0
            first_time: int = 0
            last_time: int = 0
            # Running net delta after each commit, in author-time order. Monotonicity is
            # judged off this sequence, not the endpoints, so a file that grew, was cut
            # back, then grew again is not mistaken for a pure ratchet.
            net_sequence: list[int] = field(default_factory=list)
        
        
        def _repo_top(repo_root: Path) -> str | None:
            """Absolute repo top-level for ``repo_root``, or None if not in a git repo."""
            try:
                return subprocess.run(
                    ["git", "-C", str(repo_root), "rev-parse", "--show-toplevel"],
                    capture_output=True, text=True, check=True, timeout=GIT_TIMEOUT_SECONDS,
                ).stdout.strip()
            except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
                return None
        
        
        def _accumulate_history(repo_top: str) -> dict[str, _FileHistory]:
            """Walk the full numstat history and accumulate per-file growth.
        
            Returns ``{repo-relative-path: _FileHistory}``. Each commit contributes its
            author time (``%at``) plus the numstat rows that follow it. The history is
            sorted by (author_time, sha) before accumulation so the running net-delta
            sequence is built in a clone-independent order - the load-bearing
            reproducibility guarantee. Binary files (numstat ``-`` for added/removed)
            are skipped: they carry no line-count signal.
            """
            # \x1e (RS) opens each commit record; the header is "<author_time> <sha>",
            # then the numstat rows ("added\tremoved\tpath") on their own lines.
            cmd = [
                "git", "-C", repo_top, "log",
                "--no-merges", "--no-renames", "--numstat",
                "--pretty=format:\x1e%at %H",
            ]
            try:
                raw = subprocess.run(
                    cmd, capture_output=True, text=True, check=True, errors="replace",
                    timeout=GIT_TIMEOUT_SECONDS,
                ).stdout
            except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
                return {}
        
            # Parse into (author_time, sha, [(added, removed, path), ...]) per commit,
            # then sort. We do NOT trust git's emission order: --reverse / traversal
            # order can differ across clones, so the ordering pin is an explicit sort
            # on (author_time, sha) rather than the order git happened to print.
            commits: list[tuple[int, str, list[tuple[int, int, str]]]] = []
            for chunk in raw.split("\x1e"):
                if not chunk.strip():
                    continue
                lines = chunk.splitlines()
                header = lines[0].split(" ", 1)
                if len(header) != 2:
                    continue
                try:
                    author_time = int(header[0])
                except ValueError:
                    continue
                sha = header[1]
                rows: list[tuple[int, int, str]] = []
                for row in lines[1:]:
                    parts = row.split("\t")
                    if len(parts) < 3:
                        continue
                    added_s, removed_s, path = parts[0], parts[1], parts[2]
                    # Binary files numstat as "-\t-\t<path>": no line-count signal.
                    if not added_s.isdigit() or not removed_s.isdigit():
                        continue
                    rows.append((int(added_s), int(removed_s), path))
                commits.append((author_time, sha, rows))
        
            commits.sort(key=lambda c: (c[0], c[1]))
        
            histories: dict[str, _FileHistory] = defaultdict(_FileHistory)
            for author_time, _sha, rows in commits:
                for added, removed, path in rows:
                    hist = histories[path]
                    hist.additions += added
                    hist.deletions += removed
                    hist.commit_count += 1
                    if hist.first_time == 0:
                        hist.first_time = author_time
                    hist.last_time = author_time
                    hist.net_sequence.append(hist.additions - hist.deletions)
            return histories
        
        
        def _is_monotonic_nondecreasing(sequence: list[int]) -> bool:
            """True when the running net-delta never falls below an earlier high.
        
            A single step down (a commit that deleted more than it added, net) breaks
            the ratchet - that is the deletion pressure the signal looks for. An empty
            or single-point sequence is vacuously monotonic.
            """
            return all(b >= a for a, b in zip(sequence, sequence[1:]))
        
        
        def _build_accretion_file(
            path: str, hist: _FileHistory, deletion_threshold: float
        ) -> AccretionFile | None:
            """Promote one file's history to an AccretionFile, or None if it isn't accreting.
        
            Applies the multi-commit gate, the monotonic-growth test, and the
            deletion-fraction threshold. A file passes only when it grew across multiple
            commits, its running net-delta never came back down, and its deletions stayed
            below ``deletion_threshold`` share of total churn. The threshold is the
            caller's value (see :func:`scan_accretion_ratchet`) so the cut applied is the
            one reported, with no second filter downstream. Documentation files
            (:data:`DOC_SUFFIXES`) are never promoted.
            """
            if Path(path).suffix.lower() in DOC_SUFFIXES:
                return None
            if hist.commit_count < MIN_COMMITS_FOR_ACCRETION:
                return None
        
            total_churn = hist.additions + hist.deletions
            if total_churn == 0:
                return None
            deletion_fraction = hist.deletions / total_churn
            if deletion_fraction >= deletion_threshold:
                return None
        
            net = hist.additions - hist.deletions
            # A net-zero or net-negative file is not accreting even if its history reads
            # as non-decreasing (e.g. a file that was only ever deleted from).
            if net <= 0:
                return None
            if not _is_monotonic_nondecreasing(hist.net_sequence):
                return None
        
            span_days = max(0, (hist.last_time - hist.first_time)) / 86400
            time_span_months = span_days / DAYS_PER_MONTH
            return AccretionFile(
                path=path,
                net_additions=net,
                commit_count=hist.commit_count,
                deletion_fraction=deletion_fraction,
                time_span_months=time_span_months,
            )
        
        
        def scan_accretion_ratchet(
            repo_root: Path,
            deletion_threshold: float = DELETION_FRACTION_THRESHOLD,
        ) -> AccretionScan:
            """Full pipeline: parse numstat history -> accumulate -> flag pure-growth files.
        
            Returns an :class:`AccretionScan`. ``available`` is False when ``repo_root``
            is not inside a git repo or git is unreachable; ``reliable`` is False on
            degenerate history. Flagged files are sorted by net additions descending,
            then by path for a stable tie-break.
            """
            repo_top = _repo_top(repo_root)
            if repo_top is None:
                return AccretionScan(available=False, reason="not a git repository")
        
            try:
                histories = _accumulate_history(repo_top)
                if not histories:
                    return AccretionScan(available=False, reason="no commit history")
        
                # Degenerate history (every file ~1 commit: shallow clone, squashed
                # import) means the accumulation sequence carries no signal. Report what
                # we found but mark it unreliable so nothing downstream reads it as a
                # clean bill of health. Same verdict definition as git_churn.
                commit_counts = [h.commit_count for h in histories.values()]
                reliable = not churn_is_degenerate(commit_counts)
        
                files: list[AccretionFile] = []
                for path, hist in histories.items():
                    accreting = _build_accretion_file(path, hist, deletion_threshold)
                    if accreting is not None:
                        files.append(accreting)
        
                files.sort(key=lambda f: (-f.net_additions, f.path))
                return AccretionScan(
                    available=True,
                    reliable=reliable,
                    deletion_fraction_threshold=deletion_threshold,
                    files=files,
                )
            except Exception as exc:  # noqa: BLE001 - degrade, never crash the core
                return AccretionScan(
                    available=False, reason=f"{type(exc).__name__}: {exc}"
                )
        
        
        def main() -> int:
            import argparse
            import time
        
            ap = argparse.ArgumentParser(description=__doc__)
            ap.add_argument("repo_root", type=Path)
            ap.add_argument(
                "--deletion-threshold", type=float, default=DELETION_FRACTION_THRESHOLD,
                help="flag files whose deletion fraction is below this (default 0.15)",
            )
            ap.add_argument("--json", type=Path, help="write full summary JSON here")
            args = ap.parse_args()
        
            t0 = time.monotonic()
            scan = scan_accretion_ratchet(args.repo_root.resolve(), args.deletion_threshold)
            elapsed = time.monotonic() - t0
            s = scan.summary()
            s["elapsed_seconds"] = round(elapsed, 2)
        
            if args.json:
                args.json.write_text(json.dumps(s, indent=2))
        
            if not scan.available:
                print(f"unavailable: {scan.reason}")
                return 1
            reliability = "reliable" if scan.reliable else "UNRELIABLE (degenerate history)"
            print(
                f"scanned in {elapsed:.2f}s  threshold={scan.deletion_fraction_threshold} "
                f"deletion-fraction  {reliability}"
            )
            print(f"accreting files: {s['total_accreting']}")
            print("\ntop offenders (net additions, never meaningfully cut back):")
            for f in s["top_offenders"]:
                print(
                    f"  +{f['net_additions']:>7}  {f['commit_count']:>3} commits  "
                    f"del {f['deletion_fraction']:.2f}  over {f['time_span_months']:.1f}mo  "
                    f"{f['path']}"
                )
            return 0
        
        
        if __name__ == "__main__":
            sys.exit(main())
        
      • agent_instructions_grader.py 17.3 KB
        """Heuristic agent-instructions grader.
        
        Filename-agnostic. Scores any agent instruction file - CLAUDE.md, AGENTS.md,
        GEMINI.md, .cursorrules, .github/copilot-instructions.md - on signals that
        correlate with usefulness to an LLM contributor:
        
            positive_directives: "Use X", "Prefer Y", "Default to Z" (positive framing)
            tradeoff_phrases:    "because", "over X", "instead of", "rather than"
            path_references:     file paths like src/foo/bar.py, tests/..., etc.
            verifiable_outcomes: "Working if", "verify:", "success criteria", or a
                                 runnable verification command (mvn/gradle/rg)
            freshness:           days since last git modification
        
        No LLM calls. Pure regex + arithmetic. Deterministic.
        """
        from __future__ import annotations
        
        import re
        from dataclasses import dataclass, field
        from pathlib import Path
        
        
        POSITIVE_DIRECTIVE_PATTERNS = [
            r"\bUse\b",
            r"\bPrefer\b",
            r"\bChoose\b",
            r"\bDefault to\b",
            r"\bMatch\b",
            r"\bAdd\b",
            r"\bRun\b",
        ]
        
        TRADEOFF_PATTERNS = [
            r"\bbecause\b",
            r"\bover\s+(?!the\b|a\b|an\b|time\b|all\b|here\b|there\b|to\b|in\b|on\b|with\b)\w+",
            r"\binstead of\b",
            r"\brather than\b",
            r"\btradeoff\b",
        ]
        
        PATH_PATTERN = re.compile(
            r"`[^`]*[/\\][^`\s]+`"          # backtick-wrapped paths
            r"|(?:^|\s)[\w./-]+/[\w./-]+"   # bare paths with at least one slash
        )
        
        VERIFIABLE_PATTERNS = [
            # Phrase-based outcomes (JS/Python/general prose idioms).
            r"\bworking if\b",
            r"\bverify:\b",
            r"\bsuccess criteria\b",
            r"\bacceptance criteria\b",
            # Runnable verification commands (issue #116). A CLAUDE.md that hands the
            # agent an exact command to confirm an outcome is just as "verifiable" as
            # one that spells out "success criteria" in prose - the original idiom set
            # only credited JS/Python phrasing and scored Maven/Gradle/JVM files zero.
            # Kept high-precision (a tool name plus a real subcommand/flag) so prose
            # that merely mentions Maven or Gradle is not credited.
            r"\bmvn\s+(?:-\S+\s+)*(?:clean\s+)?(?:test|verify|install|integration-test)\b",  # mvn test / verify
            r"-Dtest=\S",                                                                     # mvn test -Dtest=Class#method
            r"(?:^|\s)\.?/?gradlew?\s+(?:-\S+\s+)*(?:test|build|check|clean|assemble)\b",     # gradle / ./gradlew test|build|check
            r"--tests\s+\S",                                                                  # gradle test --tests Foo
            # ripgrep verification recipes - require a flag or a quoted query so that
            # bare prose mentions ("use rg to find things", "rg or grep") are not
            # credited, only an actual runnable search command.
            r"\brg\s+(?:(?:-{1,2}[\w-]+\s+)+\S|(?:-{1,2}[\w-]+\s+)*['\"]\S)",
        ]
        
        # Size/bloat metrics with CONSERVATIVE thresholds.
        # Conservative threshold rationale: small/legitimate instruction files must
        # never be penalized. 500 lines / 3000 words covers the common case of a
        # well-structured CLAUDE.md with sections, examples, and patterns - only
        # genuinely bloated files cross this threshold.
        SIZE_THRESHOLD_LINES = 500
        SIZE_THRESHOLD_WORDS = 3000
        
        # Skills delegation detection - text patterns that indicate progressive
        # disclosure (guidance factored into on-demand skills rather than inlined).
        SKILL_DELEGATION_PATTERNS = [
            r"\.claude/skills/",
            r"skills/\w+/SKILL\.md",
            r"skill\s+\(.*?loaded\s+on\s+demand",
            r"via\s+the\s+`?\w+-?\w*`?\s+skill",
            r"load(?:s|ed)?\s+on\s+demand",
            r"progressive\s+disclosure",
        ]
        
        # --- Sensitive-content scan (issue #56) -----------------------------------
        # Before /assess recommends committing ANY instruction file - especially to a
        # public repo - it must scan the candidate text for content that should not be
        # published: infrastructure recon (IPs, SSH/host details), credentials, and
        # home-directory / PII paths. Conservative by design: high-precision signals so
        # a legitimate instruction file is not flagged. Every finding's evidence is
        # REDACTED before it leaves this module - the scan must not itself copy the
        # secret it is warning about into run-context.json (which ships in the wiki).
        
        # Placeholder values that mean "fill this in", not a real secret. A credential
        # assignment whose value matches one of these is not flagged.
        _CREDENTIAL_PLACEHOLDERS = re.compile(
            r"^(?:x{2,}|\*{2,}|\.{3,}|-{2,}|_+|"
            r"your[_-]?\w*|my[_-]?\w*|some[_-]?\w*|example\w*|placeholder\w*|"
            r"change[_-]?me|todo|tbd|none|null|env|secret|password|token|"
            r"\$\{?\w+\}?|<[^>]+>|\{\{[^}]+\}\})$",
            re.IGNORECASE,
        )
        
        
        def _redact(token: str, *, keep: int = 0) -> str:
            """Mask the bulk of a token so the warning never republishes the secret."""
            token = token.strip()
            if keep <= 0 or len(token) <= keep:
                return "***"
            return f"{token[:keep]}***"
        
        
        def _scan_ip_addresses(text: str) -> list[str]:
            findings: list[str] = []
            for m in re.finditer(r"(?<![\w.])(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?![\w.])", text):
                octets = [int(g) for g in m.groups()]
                if any(o > 255 for o in octets):
                    continue  # not a valid IPv4 - likely a version string
                # Loopback / unspecified are harmless and noisy; skip them.
                if octets[0] == 127 or octets == [0, 0, 0, 0]:
                    continue
                findings.append(f"{octets[0]}.x.x.x")
            return findings
        
        
        def scan_sensitive_content(text: str) -> list[dict]:
            """Scan an instruction file for content unsafe to commit (issue #56).
        
            Returns a list of ``{"category": str, "evidence": str}`` findings with the
            evidence REDACTED. Categories:
        
                private_key   - an embedded PEM private key block
                cloud_key     - an AWS-style access key id
                credential    - a ``password=``/``token=``/``api_key=`` assignment with
                                a concrete (non-placeholder) value
                ssh_or_host   - root@host / ssh user@host login details
                ip_address    - a routable/private IPv4 literal (loopback excluded)
                home_path     - a personal home-directory path (/Users/<name>/, ...)
        
            Conservative: high-precision signals only. An empty list means "nothing
            obviously sensitive found" - not a guarantee, so the prose still advises a
            human glance before committing to a public repo.
            """
            findings: list[dict] = []
            seen: set[tuple[str, str]] = set()
        
            def add(category: str, evidence: str) -> None:
                key = (category, evidence)
                if key not in seen:
                    seen.add(key)
                    findings.append({"category": category, "evidence": evidence})
        
            if re.search(r"-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----", text):
                add("private_key", "-----BEGIN PRIVATE KEY-----")
        
            for m in re.finditer(r"\b(AKIA[0-9A-Z]{16})\b", text):
                add("cloud_key", _redact(m.group(1), keep=4))
        
            cred = re.compile(
                r"\b(password|passwd|secret|api[_-]?key|access[_-]?key|auth[_-]?token|token)\b"
                r"\s*[:=]\s*(\"[^\"]+\"|'[^']+'|\S+)",
                re.IGNORECASE,
            )
            for m in cred.finditer(text):
                value = m.group(2).strip("\"'")
                if not value or _CREDENTIAL_PLACEHOLDERS.match(value):
                    continue
                add("credential", f"{m.group(1).lower()}=***")
        
            for m in re.finditer(r"\broot@[A-Za-z0-9._-]+", text):
                add("ssh_or_host", "root@***")
            for m in re.finditer(r"\bssh\s+[A-Za-z0-9._-]+@[A-Za-z0-9._-]+", text):
                add("ssh_or_host", "ssh ***@***")
        
            for ip in _scan_ip_addresses(text):
                add("ip_address", ip)
        
            for m in re.finditer(r"(?:/Users/|/home/)([A-Za-z0-9._-]+)/", text):
                name = m.group(1)
                if name.lower() in {"user", "username", "you", "name", "shared", "public"}:
                    continue  # generic placeholder, not a person's home dir
                add("home_path", "/Users/***/" if "/Users/" in m.group(0) else "/home/***/")
            for m in re.finditer(r"[A-Za-z]:\\Users\\([^\\/]+)\\", text):
                if m.group(1).lower() not in {"user", "username", "public", "default"}:
                    add("home_path", "C:\\Users\\***\\")
        
            return findings
        
        
        # --- Alias detection (issue #57) ------------------------------------------
        # Claude Code reads a single canonical CLAUDE.md. A repo that also wants an
        # AGENTS.md (for Codex) or GEMINI.md (for Gemini CLI) should point it AT the
        # canonical file - a thin stub or symlink - not maintain a second standalone
        # document. Detect that thin-stub shape so the grader can treat it as an alias
        # (inheriting the canonical grade) rather than a low-scoring standalone doc.
        
        # Canonical instruction filenames an alias might point at.
        _CANONICAL_BASENAMES = ("CLAUDE.md", "AGENTS.md", "GEMINI.md")
        
        # A stub is "thin" when it carries essentially no instruction content of its own.
        ALIAS_MAX_NONBLANK_LINES = 12
        ALIAS_MAX_WORDS = 80
        
        
        def detect_alias(text: str) -> dict:
            """Detect a thin alias/stub that points at a canonical instruction file.
        
            A thin alias is a short file whose only real content is a reference to a
            canonical instruction file (e.g. an ``AGENTS.md`` that says "see CLAUDE.md").
            Treating it as an alias avoids grading it as a bespoke standalone doc and
            avoids recommending it be rewritten into a duplicate routing document.
        
            Returns ``{"is_alias": bool, "alias_target": str | None}`` where
            ``alias_target`` is the referenced canonical basename (e.g. ``CLAUDE.md``).
            """
            stripped = text.strip()
            nonblank = [ln for ln in stripped.splitlines() if ln.strip()]
            words = len(stripped.split())
            if not nonblank or len(nonblank) > ALIAS_MAX_NONBLANK_LINES or words > ALIAS_MAX_WORDS:
                return {"is_alias": False, "alias_target": None}
        
            for basename in _CANONICAL_BASENAMES:
                if re.search(rf"\b{re.escape(basename)}\b", stripped):
                    return {"is_alias": True, "alias_target": basename}
            return {"is_alias": False, "alias_target": None}
        
        
        @dataclass(frozen=True)
        class Grade:
            score: int
            grade: str
            subscores: dict[str, int] = field(default_factory=dict)
        
        
        def _count(text: str, patterns: list[str]) -> int:
            total = 0
            for p in patterns:
                total += len(re.findall(p, text, re.IGNORECASE))
            return total
        
        
        def count_positive_directives(text: str) -> int:
            return _count(text, POSITIVE_DIRECTIVE_PATTERNS)
        
        
        def count_tradeoff_phrases(text: str) -> int:
            return _count(text, TRADEOFF_PATTERNS)
        
        
        def count_path_references(text: str) -> int:
            return len(PATH_PATTERN.findall(text))
        
        
        def count_verifiable_outcomes(text: str) -> int:
            return _count(text, VERIFIABLE_PATTERNS)
        
        
        def compute_size_metrics(text: str) -> dict:
            """Return line_count, word_count, and threshold-exceeded flags."""
            lines = text.splitlines()
            words = len(text.split())
            return {
                "line_count": len(lines),
                "word_count": words,
                "exceeds_line_threshold": len(lines) > SIZE_THRESHOLD_LINES,
                "exceeds_word_threshold": words > SIZE_THRESHOLD_WORDS,
            }
        
        
        def detect_skills_delegation(text: str) -> dict:
            """Detect if an instruction file delegates to skills (progressive-disclosure
            pointers). Presence means the repo factors guidance into on-demand skills
            rather than inlining everything into one monolithic file."""
            matches: list[str] = []
            for p in SKILL_DELEGATION_PATTERNS:
                found = re.findall(p, text, re.IGNORECASE)
                matches.extend(found)
            return {
                "delegates_to_skills": len(matches) > 0,
                "delegation_pointers": len(matches),
                "delegation_samples": matches[:5],  # first 5 for evidence
            }
        
        
        def detect_skills_dir(repo_root: Path) -> dict:
            """Check for the presence of skills directories in the repo.
        
            Looks for `.claude/skills/` and `skills/` and counts the `*/SKILL.md`
            files within. A repo with skills is using progressive disclosure, so a
            large instruction file is not necessarily bloat.
            """
            skills_paths = [
                repo_root / ".claude" / "skills",
                repo_root / "skills",
            ]
            found_dirs: list[str] = []
            skill_files: list[str] = []
            for sp in skills_paths:
                if sp.is_dir():
                    found_dirs.append(str(sp.relative_to(repo_root)))
                    for skill_md in sp.glob("*/SKILL.md"):
                        skill_files.append(str(skill_md.relative_to(repo_root)))
            return {
                "skills_dirs_present": len(found_dirs) > 0,
                "skills_dirs": found_dirs,
                "skills_count": len(skill_files),
                "skill_files": skill_files,
            }
        
        
        def compute_bloat_penalty(
            size_metrics: dict,
            skills_present: bool,
            delegates_to_skills: bool,
        ) -> tuple[int, str | None]:
            """Compute the point penalty for an oversized monolithic instruction file.
        
            Returns: (penalty_points, remediation_message)
        
            Asymmetric scoring:
            - Lean file (not oversized) -> no penalty
            - Oversized file + skills factoring (dir present or delegation pointers)
              -> no penalty; the repo uses progressive disclosure
            - Oversized file + NO skills -> PENALTY scaled by overage
        
            Penalty scale (conservative - only clear bloat is penalized):
            - 500-750 lines: -5, 750-1000: -10, 1000+: -15
            - Word count applies the same tiers at 3000/4500/6000 words
            - Take the higher penalty of the two metrics
            """
            is_oversized = (
                size_metrics["exceeds_line_threshold"]
                or size_metrics["exceeds_word_threshold"]
            )
        
            if not is_oversized:
                return 0, None
        
            if skills_present or delegates_to_skills:
                # Repo uses progressive disclosure - no penalty even if the
                # instruction file is large (it may be a hub that points to skills).
                return 0, None
        
            lines = size_metrics["line_count"]
            line_penalty = 0
            if lines > 1000:
                line_penalty = 15
            elif lines > 750:
                line_penalty = 10
            elif lines > SIZE_THRESHOLD_LINES:
                line_penalty = 5
        
            words = size_metrics["word_count"]
            word_penalty = 0
            if words > 6000:
                word_penalty = 15
            elif words > 4500:
                word_penalty = 10
            elif words > SIZE_THRESHOLD_WORDS:
                word_penalty = 5
        
            penalty = max(line_penalty, word_penalty)
        
            remediation = (
                f"Instruction file exceeds size threshold ({lines} lines, {words} words) "
                "without factoring guidance into on-demand skills. Remediation: factor "
                "guidance into on-demand skills - extract topic-specific guidance into "
                "`.claude/skills/*/SKILL.md` files loaded when relevant, keeping the root "
                "instruction file lean."
            )
        
            return penalty, remediation
        
        
        def _letter_grade(score: int) -> str:
            if score >= 80:
                return "A"
            if score >= 70:
                return "A-"
            if score >= 60:
                return "B+"
            if score >= 50:
                return "B"
            if score >= 40:
                return "C"
            if score >= 25:
                return "D"
            return "F"
        
        
        def grade_instructions(
            text: str,
            freshness_days: int,
            *,
            skills_present: bool = False,
            delegates_to_skills: bool | None = None,
        ) -> Grade:
            """Score an agent instruction file (CLAUDE.md / AGENTS.md / GEMINI.md / etc.) and return a Grade.
        
            Scoring weights (max 100):
                positive_directives:  3 points each, capped at 30
                tradeoff_phrases:     5 points each, capped at 25
                path_references:      3 points each, capped at 20
                verifiable_outcomes:  10 points each, capped at 15
                freshness penalty:    -10 if > 365 days, -5 if > 180, 0 otherwise
                                      +10 baseline if file has any content
                bloat penalty:        -5/-10/-15 for an oversized monolithic file that
                                      does NOT factor guidance into on-demand skills
        
            Args:
                skills_present: whether the repo has a skills directory (auto-detected
                    by the caller via ``detect_skills_dir``).
                delegates_to_skills: whether the text itself contains progressive-
                    disclosure pointers. ``None`` (the default) auto-detects from text.
        
            The bloat penalty makes an oversized monolith score STRICTLY BELOW an
            equivalent lean-file-plus-skills repo - the monolith is penalized, not
            merely annotated. Conservative thresholds (500 lines / 3000 words) ensure
            small/legitimate instruction files are never penalized.
            """
            if not text.strip():
                return Grade(score=0, grade="F", subscores={})
        
            # Auto-detect delegation from text when not explicitly provided.
            if delegates_to_skills is None:
                delegates_to_skills = detect_skills_delegation(text)["delegates_to_skills"]
        
            sub = {
                "positive_directives": count_positive_directives(text),
                "tradeoff_phrases": count_tradeoff_phrases(text),
                "path_references": count_path_references(text),
                "verifiable_outcomes": count_verifiable_outcomes(text),
            }
        
            size_metrics = compute_size_metrics(text)
            sub["line_count"] = size_metrics["line_count"]
            sub["word_count"] = size_metrics["word_count"]
        
            score = 10  # baseline for non-empty content
            score += min(sub["positive_directives"] * 3, 30)
            score += min(sub["tradeoff_phrases"] * 5, 25)
            score += min(sub["path_references"] * 3, 20)
            score += min(sub["verifiable_outcomes"] * 10, 15)
        
            if freshness_days > 365:
                score -= 10
            elif freshness_days > 180:
                score -= 5
        
            # Bloat penalty - the core change. Oversized monolithic files with no
            # skills factoring lose points, scoring strictly below lean-file-plus-skills.
            bloat_penalty, _bloat_remediation = compute_bloat_penalty(
                size_metrics, skills_present, delegates_to_skills
            )
            score -= bloat_penalty
            sub["bloat_penalty"] = bloat_penalty
        
            score = max(0, min(score, 100))
            return Grade(score=score, grade=_letter_grade(score), subscores=sub)
        
      • agent_ops.py 5.6 KB
        """Scan encoded agent-operations guardrails (Layer 8 workflow-maturity evidence).
        
        A team that runs agents in parallel or autonomously needs the operational
        guardrails encoded in the repo, not in one operator's head: pre-approved
        permission allowlists, hooks that intercept tool calls, sandbox/deny rules, and
        routine/loop definitions that make repeated agent work a committed artifact.
        This module scans the repo-observable subset of those guardrails:
        
        - ``.claude/settings.json`` / ``.claude/settings.local.json`` - permission
          ``allow`` / ``deny`` / ``ask`` entry counts, hook events, sandbox config.
        - ``.claude/hooks/`` - hook scripts on disk.
        - ``.claude/workflows/`` and ``.claude/routines/`` - routine/loop definitions
          (evidence of repeated, encoded agent work cycles).
        
        Only **git-tracked** artifacts count toward the summary booleans, mirroring the
        Layer 0 rule: a settings file present on disk but uncommitted reaches no clone,
        so it is reported (``tracked: false``) but never credited. ``.claude/agents/``
        and ``.claude/skills/`` are deliberately NOT scanned here - they already feed
        Layer 0; this block feeds Layer 8 only, so the two never double-count.
        
        Pure stdlib. ``scan_agent_ops`` never raises (callers wrap in ``_safe`` anyway).
        """
        from __future__ import annotations
        
        import json
        from pathlib import Path
        from typing import Any
        
        from lib.git_churn import tracked_files
        
        # Candidate settings files, repo-root-relative. Order is the report order.
        _SETTINGS_PATHS = (
            ".claude/settings.json",
            ".claude/settings.local.json",
        )
        
        # Directories whose committed contents evidence encoded routine/loop work.
        _ROUTINE_DIRS = (
            ".claude/workflows",
            ".claude/routines",
        )
        
        _HOOKS_DIR = ".claude/hooks"
        
        
        def _is_tracked(path: Path, tracked: frozenset[Path] | None) -> bool:
            """Whether ``path`` is git-tracked; untracked when tracking is unknowable."""
            if tracked is None:
                return False
            try:
                return path.resolve() in tracked
            except OSError:
                return False
        
        
        def _count_list(value: Any) -> int:
            return len(value) if isinstance(value, list) else 0
        
        
        def _scan_settings_file(
            repo_root: Path, rel: str, tracked: frozenset[Path] | None
        ) -> dict[str, Any] | None:
            """Parse one settings file into its guardrail counts, or None when absent."""
            path = repo_root / rel
            if not path.is_file():
                return None
            entry: dict[str, Any] = {
                "path": rel,
                "tracked": _is_tracked(path, tracked),
                "parse_ok": False,
                "allow_count": 0,
                "deny_count": 0,
                "ask_count": 0,
                "hook_events": 0,
                "sandbox_configured": False,
            }
            try:
                data = json.loads(path.read_text(encoding="utf-8"))
            except (OSError, ValueError):
                return entry
            if not isinstance(data, dict):
                return entry
            entry["parse_ok"] = True
            permissions = data.get("permissions")
            if isinstance(permissions, dict):
                entry["allow_count"] = _count_list(permissions.get("allow"))
                entry["deny_count"] = _count_list(permissions.get("deny"))
                entry["ask_count"] = _count_list(permissions.get("ask"))
            hooks = data.get("hooks")
            if isinstance(hooks, dict):
                entry["hook_events"] = len(hooks)
            entry["sandbox_configured"] = "sandbox" in data
            return entry
        
        
        def _scan_dir(
            repo_root: Path, rel: str, tracked: frozenset[Path] | None
        ) -> dict[str, Any]:
            """Count files (and how many are tracked) directly under ``rel``."""
            root = repo_root / rel
            file_count = 0
            tracked_count = 0
            if root.is_dir():
                try:
                    for p in sorted(root.rglob("*")):
                        if not p.is_file() or p.name.startswith("."):
                            continue
                        file_count += 1
                        if _is_tracked(p, tracked):
                            tracked_count += 1
                except OSError:
                    pass
            return {
                "path": rel,
                "present": root.is_dir(),
                "file_count": file_count,
                "tracked_count": tracked_count,
            }
        
        
        def scan_agent_ops(repo_root: Path) -> dict[str, Any]:
            """Build the run-context ``agent_ops`` block.
        
            Returns ``settings`` (one entry per settings file found), ``hooks_dir``,
            ``routine_dirs``, and a ``summary`` of three booleans - each True only on
            **tracked** evidence:
        
            - ``permissions_encoded``: a tracked settings file carries at least one
              permission ``allow`` / ``deny`` / ``ask`` entry.
            - ``hooks_present``: a tracked settings file configures hook events, or
              ``.claude/hooks/`` holds at least one tracked script.
            - ``routines_present``: a routine dir holds at least one tracked file.
            """
            repo_root = repo_root.resolve()
            tracked = tracked_files(repo_root)
        
            settings = [
                entry
                for rel in _SETTINGS_PATHS
                if (entry := _scan_settings_file(repo_root, rel, tracked)) is not None
            ]
            hooks_dir = _scan_dir(repo_root, _HOOKS_DIR, tracked)
            routine_dirs = [_scan_dir(repo_root, rel, tracked) for rel in _ROUTINE_DIRS]
        
            tracked_settings = [s for s in settings if s["tracked"]]
            permissions_encoded = any(
                s["allow_count"] + s["deny_count"] + s["ask_count"] > 0
                for s in tracked_settings
            )
            hooks_present = (
                any(s["hook_events"] > 0 for s in tracked_settings)
                or hooks_dir["tracked_count"] > 0
            )
            routines_present = any(d["tracked_count"] > 0 for d in routine_dirs)
        
            return {
                "available": True,
                "settings": settings,
                "hooks_dir": hooks_dir,
                "routine_dirs": routine_dirs,
                "summary": {
                    "permissions_encoded": permissions_encoded,
                    "hooks_present": hooks_present,
                    "routines_present": routines_present,
                },
            }
        
      • anomaly_detector.py 2.9 KB
        """Detect anomalies in /assess run output.
        
        Pure inspection of a run-context dict. No LLM, no file IO. Deterministic.
        
        The detail strings on each Anomaly are SAFE-TO-SHARE: counts and grades only,
        never paths or code. They form the body of self-feedback issues filed against
        the toolkit repo.
        """
        from __future__ import annotations
        
        from dataclasses import dataclass
        
        
        @dataclass(frozen=True)
        class Anomaly:
            code: str
            description: str
            detail: str  # sanitized - no paths, no code
        
        
        def detect_anomalies(context: dict) -> list[Anomaly]:
            """Inspect a run-context dict and return any anomalies found."""
            found: list[Anomaly] = []
            stats = context.get("stats_summary", {})
            instruction_files = context.get("instruction_files", {})
            diff = context.get("diff", {})
        
            files_scored = stats.get("files_scored", 0)
            ccn = stats.get("ccn", {})
            hotspots = stats.get("top_hotspots", [])
        
            if files_scored == 0:
                found.append(Anomaly(
                    code="ZERO_FILES_SCORED",
                    description="Treemap reported 0 files scored.",
                    detail="files_scored=0",
                ))
        
            if files_scored > 5 and ccn.get("p95", 0) == 0 and ccn.get("max", 0) == 0:
                found.append(Anomaly(
                    code="ZERO_COMPLEXITY",
                    description="All complexity metrics are zero despite files being scored.",
                    detail=f"files_scored={files_scored}, ccn_p95=0, ccn_max=0",
                ))
        
            if files_scored > 200 and len(hotspots) == 0:
                found.append(Anomaly(
                    code="EMPTY_HOTSPOTS",
                    description="Large repo but no hotspots emerged.",
                    detail=f"files_scored={files_scored}, hotspots_count=0",
                ))
        
            # Iterate over all present instruction files - the same check applies to each.
            for filename, file_info in instruction_files.items():
                if file_info.get("grade") == "F" and file_info.get("line_count", 0) > 200:
                    # Use the file's basename for the detail so we don't leak any
                    # repo-relative directory structure (e.g. .github/copilot-instructions.md).
                    kind = filename.rsplit("/", 1)[-1]
                    found.append(Anomaly(
                        code="INSTRUCTION_FILE_GRADE_MISMATCH",
                        description=f"{kind} is substantial but graded F.",
                        detail=f"file={kind}, line_count={file_info.get('line_count')}, grade=F",
                    ))
        
            hotspot_count = len(hotspots)
            new_count = diff.get("new", 0)
            persistent_count = diff.get("persistent", 0)
            prior_exists = context.get("prior_stats_exists", False)
            if prior_exists and hotspot_count > 5 and new_count == hotspot_count and persistent_count == 0:
                found.append(Anomaly(
                    code="ALL_NEW_HOTSPOTS",
                    description="All hotspots are new (none persisted). Stats rotation may have failed.",
                    detail=f"hotspot_count={hotspot_count}, new_count={new_count}, persistent_count=0",
                ))
        
            return found
        
      • archetype.py 16.1 KB
        """Repository archetype detection for /assess.
        
        The 0-8 layered model assumes a software repo: read-side foundation (L0-L1),
        write-side enforcement (L2-L7), and a meta capstone (L8). For a **knowledge /
        document base** - markdown sources, an LLM-maintained wiki, a ``CLAUDE.md``
        schema, and no application code or runtime - the write-side layers have no code
        surface to enforce. They are *not applicable*, not *failing*. Leaving them in
        the denominator makes a well-run KB score ~2.5/8 and read as "Not Ready", which
        is a lying score: it penalises the repo for not having tests on code it doesn't
        contain.
        
        This module turns "what kind of repo is this?" into a deterministic signal so
        the scorer can mark the inapplicable layers **N/A** (excluded from the
        denominator) rather than **Missing** (a real gap). The headline then
        renormalises over the layers that actually apply.
        
        Detection is **dispatch-friendly**: ``classify_archetype`` evaluates one
        archetype today (knowledge-base) and falls through to ``software``. Adding a
        further archetype later means adding a branch, not rebuilding the scaffolding -
        but we deliberately ship one archetype now (YAGNI) rather than a general
        framework.
        
        Two override paths keep the heuristic honest:
        
        - an explicit ``<!-- assess-archetype: knowledge-base -->`` (or ``software``)
          marker in any instruction file **forces or suppresses** detection, so a
          maintainer is never trapped by a misfire.
        - the heuristic itself gates on the code-file ratio **and** the absence of a
          runtime surface (``package.json``, ``pyproject.toml``, ``go.mod``,
          ``Dockerfile``, ...), so a documentation-heavy *application* (lots of
          markdown but a real build) is never mistaken for a KB.
        
        A documented **AI maintenance workflow** - the Karpathy LLM-wiki pattern
        (immutable raw sources, the schema file as the product, an ingest workflow,
        query-as-filing, periodic lint/consolidation) - is both a detection signal and
        a scored read-side (Layer 0) quality signal. Best-practice pointer:
        https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f
        """
        from __future__ import annotations
        
        import re
        from pathlib import Path
        from typing import Any
        
        # Imported from doc_graph (a lib module - the inward-only layering allows it).
        from lib.doc_graph import CODE_EXTENSIONS, DOC_EXTENSIONS, EXCLUDE_DIRS
        from lib.git_churn import tracked_files
        
        # The Karpathy LLM-wiki best-practice pointer. Surfaced in the report so a KB
        # maintainer has the canonical reference for the pattern being scored.
        KARPATHY_GIST_URL = (
            "https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f"
        )
        
        # Layer bands of the 0-8 model. The write-side band has no code surface in a
        # knowledge base, so those layers go N/A there.
        ALL_LAYERS: list[int] = list(range(0, 9))  # 0..8
        WRITE_SIDE_LAYERS: list[int] = [2, 3, 4, 5, 6, 7]
        KB_APPLICABLE_LAYERS: list[int] = [0, 1, 8]  # read-side foundation + meta
        
        # The software display denominator caps at 8 (nine layers, ceiling 8 - see
        # assess-findings score derivation). A knowledge base renormalises over only
        # its applicable layers.
        SOFTWARE_DENOMINATOR = 8
        
        # Heuristic thresholds. Conservative by design: a repo is only called a
        # knowledge base when code is a *tiny* fraction AND there is no runtime surface.
        # A typical software repo with a docs/ tree stays well above the ratio; a
        # documentation-heavy app is caught by the runtime-surface gate.
        KB_CODE_RATIO_MAX = 0.10  # code / (code + docs) must be at or below this
        KB_MIN_DOC_FILES = 3  # need a real doc base, not an empty repo
        
        # Marker filenames whose presence at the repo root signals an application
        # runtime / build surface (so the repo is software even if doc-heavy). Globs
        # are matched against the basename; plain names are matched exactly.
        RUNTIME_SURFACE_MARKERS: tuple[str, ...] = (
            "package.json", "pyproject.toml", "setup.py", "setup.cfg", "go.mod",
            "Cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts", "Gemfile",
            "composer.json", "Dockerfile", "requirements.txt", "tsconfig.json",
            "pubspec.yaml", "*.csproj", "*.sln", "*.cabal", "mix.exs",
        )
        
        # Instruction files an override marker / maintenance text may live in.
        _INSTRUCTION_FILES: tuple[str, ...] = (
            "CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules",
            ".github/copilot-instructions.md",
        )
        
        # An override marker in any instruction file. Tolerant of surrounding HTML
        # comment syntax and whitespace: `<!-- assess-archetype: knowledge-base -->`,
        # `assess-archetype: software`, etc.
        _OVERRIDE_RE = re.compile(
            r"assess-archetype\s*[:=]\s*([a-z][a-z0-9-]*)", re.IGNORECASE
        )
        
        # Normalisation of override values to the two archetypes we support.
        _OVERRIDE_ALIASES = {
            "knowledge-base": "knowledge-base",
            "knowledgebase": "knowledge-base",
            "kb": "knowledge-base",
            "wiki": "knowledge-base",
            "software": "software",
            "code": "software",
            "app": "software",
        }
        
        # Karpathy LLM-wiki maintenance signals. Each key is a named facet of the
        # pattern; the value is a list of regexes any of which marks the facet present.
        # Kept reasonably specific so a stray word doesn't trip a facet.
        _KB_MAINTENANCE_SIGNALS: dict[str, list[str]] = {
            "immutable-sources": [
                r"immutable\s+(raw\s+)?sources?",
                r"raw\s+sources?\s+are\s+(immutable|append-only|never\s+edit)",
                r"append-only",
                r"never\s+(edit|modify)\s+(the\s+)?(raw\s+)?sources?",
            ],
            "schema-as-product": [
                r"schema\s+(file\s+)?is\s+the\s+product",
                r"the\s+product\s+is\s+the\s+schema",
                r"schema\s+file\s+as\s+the\s+product",
                r"schema-as-product",
            ],
            "ingest-workflow": [
                r"ingest(ion)?\s+(workflow|pipeline|process|step)",
                r"intake\s+(workflow|process)",
                r"how\s+(the\s+)?(ai|agent|llm)\s+(ingests|maintains|updates)",
            ],
            "query-as-filing": [
                r"query[- ]as[- ]filing",
                r"file\s+(it|the\s+answer)\s+(back\s+)?into",
                r"queries?\s+(get|are)\s+filed",
            ],
            "periodic-consolidation": [
                r"periodic\s+(lint|review|consolidat|compaction)",
                r"consolidat(e|ion)\s+(pass|step|workflow)",
                r"lint\s+(pass|the\s+wiki|the\s+knowledge)",
                r"compaction|garbage[- ]collect",
            ],
        }
        
        
        def _scan_override(repo_root: Path) -> tuple[str | None, str | None]:
            """Return ``(normalised_archetype, source_rel_path)`` for the first marker.
        
            Scans the known instruction files for ``assess-archetype: <value>`` and
            normalises the value to ``knowledge-base`` or ``software``, also carrying the
            repo-relative path of the file the marker was found in so a contradiction
            finding can point at it. Both are ``None`` when no recognised marker exists.
            """
            for rel in _INSTRUCTION_FILES:
                path = repo_root / rel
                try:
                    text = path.read_text(encoding="utf-8", errors="replace")
                except OSError:
                    continue
                m = _OVERRIDE_RE.search(text)
                if not m:
                    continue
                normalised = _OVERRIDE_ALIASES.get(m.group(1).strip().lower())
                if normalised:
                    return normalised, rel
            return None, None
        
        
        def read_archetype_override(repo_root: Path) -> str | None:
            """Return the forced archetype from an instruction-file marker, or None.
        
            Scans the known instruction files for ``assess-archetype: <value>`` and
            normalises the value to ``knowledge-base`` or ``software``. A
            ``knowledge-base`` marker *forces* the KB archetype; a ``software`` marker
            *suppresses* it. An unrecognised value is ignored (returns None) so a typo
            falls back to the heuristic rather than silently mis-scoring.
            """
            return _scan_override(repo_root)[0]
        
        
        def detect_kb_maintenance(text: str) -> dict[str, Any]:
            """Detect a documented AI KB-maintenance workflow (Karpathy LLM-wiki).
        
            Scans combined instruction / doc text for the named facets of the pattern.
            A facet counts once. ``documented`` is True when the gist is cited directly
            or at least two distinct facets appear - two independent facets are a real
            description of a maintenance loop, a single keyword is not.
        
            Returns the gist pointer unconditionally so the report can cite the
            best-practice reference whether or not the repo documents the pattern.
            """
            lowered = text.lower()
            signals_found: list[str] = []
            for facet, patterns in _KB_MAINTENANCE_SIGNALS.items():
                if any(re.search(p, lowered) for p in patterns):
                    signals_found.append(facet)
        
            gist_cited = (
                "karpathy/442a6bf555914893e9891c11519de94f" in lowered
                or bool(re.search(r"karpathy.{0,40}(llm\s+wiki|wiki)", lowered))
            )
            documented = gist_cited or len(signals_found) >= 2
            return {
                "documented": documented,
                "signals_found": signals_found,
                "gist_cited": gist_cited,
                "gist": KARPATHY_GIST_URL,
            }
        
        
        def _matches_marker(basename: str, marker: str) -> bool:
            if "*" in marker or "?" in marker:
                from fnmatch import fnmatch
        
                return fnmatch(basename, marker)
            return basename == marker
        
        
        def _has_runtime_surface(repo_root: Path) -> bool:
            """True when a root-level marker indicates an application build/runtime.
        
            Only the repo root is inspected: a build manifest nested deep in a docs
            example is not the repo's own runtime surface.
            """
            try:
                entries = list(repo_root.iterdir())
            except OSError:
                return False
            for entry in entries:
                if not entry.is_file():
                    continue
                if any(_matches_marker(entry.name, m) for m in RUNTIME_SURFACE_MARKERS):
                    return True
            return False
        
        
        def _classify_files(repo_root: Path) -> tuple[int, int, int]:
            """Count (code, doc, other) files, honouring git tracking + excludes.
        
            Prefers git-tracked files (the precise "files in the repo"); falls back to
            a filesystem walk when the root isn't a git repo. Excluded directories
            (``.git``, ``node_modules``, ``.assess``, ...) never count.
            """
            tracked = tracked_files(repo_root)
            if tracked is not None:
                paths = [p for p in tracked if _under_repo(p, repo_root)]
            else:
                paths = [p for p in repo_root.rglob("*") if p.is_file()]
        
            code = doc = other = 0
            for path in paths:
                if _is_excluded(path, repo_root):
                    continue
                suffix = path.suffix.lower()
                if suffix in CODE_EXTENSIONS:
                    code += 1
                elif suffix in DOC_EXTENSIONS:
                    doc += 1
                else:
                    other += 1
            return code, doc, other
        
        
        def _under_repo(path: Path, repo_root: Path) -> bool:
            try:
                path.resolve().relative_to(repo_root.resolve())
                return True
            except ValueError:
                return False
        
        
        def _is_excluded(path: Path, repo_root: Path) -> bool:
            try:
                rel = path.resolve().relative_to(repo_root.resolve())
            except ValueError:
                return True
            return any(part in EXCLUDE_DIRS for part in rel.parts)
        
        
        def classify_archetype(
            *,
            code_file_count: int,
            doc_file_count: int,
            other_file_count: int,
            has_runtime_surface: bool,
            override: str | None,
            kb_maintenance: dict[str, Any],
        ) -> dict[str, Any]:
            """Pure archetype classification from already-gathered signals.
        
            The IO-free core: ``analyze_archetype`` gathers the inputs and delegates
            here, so this is the unit the tests pin. Returns the ``archetype`` block
            shape written to ``run-context.json``.
            """
            content_total = code_file_count + doc_file_count
            code_ratio = (code_file_count / content_total) if content_total else 0.0
        
            # What the deterministic heuristic would have concluded on its own signals -
            # computed unconditionally so an override can be checked against it and any
            # contradiction surfaced (the override still wins, but never silently).
            heuristic_is_kb = (
                doc_file_count >= KB_MIN_DOC_FILES
                and code_ratio <= KB_CODE_RATIO_MAX
                and not has_runtime_surface
            )
            heuristic_archetype = "knowledge-base" if heuristic_is_kb else "software"
        
            if override == "knowledge-base":
                archetype, detected_via = "knowledge-base", "override"
                reason = "forced by an `assess-archetype: knowledge-base` marker"
            elif override == "software":
                archetype, detected_via = "software", "override"
                reason = "forced by an `assess-archetype: software` marker"
            else:
                detected_via = "heuristic"
                is_kb = heuristic_is_kb
                archetype = heuristic_archetype
                if is_kb:
                    reason = (
                        f"code-file ratio {code_ratio:.2f} "
                        f"({code_file_count} code / {doc_file_count} docs) at or below "
                        f"{KB_CODE_RATIO_MAX:.2f} and no runtime surface detected"
                    )
                elif has_runtime_surface:
                    reason = (
                        f"a runtime surface is present (code-file ratio {code_ratio:.2f}); "
                        "scored as a software repo"
                    )
                else:
                    reason = (
                        f"code-file ratio {code_ratio:.2f} "
                        f"({code_file_count} code / {doc_file_count} docs) exceeds the "
                        f"knowledge-base threshold {KB_CODE_RATIO_MAX:.2f}"
                    )
        
            # An override wins the classification (denominator, na_layers, reason all
            # follow the forced archetype above), but when the deterministic signals
            # would have concluded differently the disagreement is emitted as a visible
            # finding. This is the guardrail-erosion tendency turned into a signal: a
            # marker that quietly overrides what the code actually looks like.
            override_contradicts_signals = (
                detected_via == "override" and archetype != heuristic_archetype
            )
            contradiction_details: str | None = None
            if override_contradicts_signals:
                contradiction_details = (
                    f"Override forces {archetype}, but signals suggest "
                    f"{heuristic_archetype}: code_ratio={code_ratio:.2f}, "
                    f"has_runtime={has_runtime_surface}, doc_files={doc_file_count}, "
                    f"code_files={code_file_count}"
                )
        
            if archetype == "knowledge-base":
                applicable = list(KB_APPLICABLE_LAYERS)
                na_layers = list(WRITE_SIDE_LAYERS)
                denominator = len(applicable)
            else:
                applicable = list(ALL_LAYERS)
                na_layers = []
                denominator = SOFTWARE_DENOMINATOR
        
            return {
                "available": True,
                "archetype": archetype,
                "detected_via": detected_via,
                "override": override,
                "override_contradicts_signals": override_contradicts_signals,
                "contradiction_details": contradiction_details,
                "reason": reason,
                "signals": {
                    "code_file_count": code_file_count,
                    "doc_file_count": doc_file_count,
                    "other_file_count": other_file_count,
                    "code_file_ratio": round(code_ratio, 3),
                    "has_runtime_surface": has_runtime_surface,
                },
                "applicable_layers": applicable,
                "na_layers": na_layers,
                "denominator": denominator,
                "kb_maintenance": kb_maintenance,
            }
        
        
        def _gather_instruction_text(repo_root: Path) -> str:
            parts: list[str] = []
            for rel in _INSTRUCTION_FILES:
                path = repo_root / rel
                try:
                    parts.append(path.read_text(encoding="utf-8", errors="replace"))
                except OSError:
                    continue
            return "\n".join(parts)
        
        
        def analyze_archetype(repo_root: Path) -> dict[str, Any]:
            """Detect the repository archetype and assemble the run-context block.
        
            Side-effect-free with respect to the repo (read-only). Gathers the file
            counts, runtime-surface signal, override marker, and KB-maintenance signal,
            then delegates the verdict to ``classify_archetype``.
            """
            code, doc, other = _classify_files(repo_root)
            override, override_source = _scan_override(repo_root)
            kb_maintenance = detect_kb_maintenance(_gather_instruction_text(repo_root))
            has_runtime = _has_runtime_surface(repo_root)
            block = classify_archetype(
                code_file_count=code,
                doc_file_count=doc,
                other_file_count=other,
                has_runtime_surface=has_runtime,
                override=override,
                kb_maintenance=kb_maintenance,
            )
            # The marker source lets a contradiction finding point at the file the
            # override lives in; recorded whenever a recognised marker was found.
            if override_source is not None:
                block["override_source"] = override_source
            return block
        
      • assess_config.py 15.7 KB
        """Per-repo `.assess/config.toml` reader.
        
        A single, optional config file lets a repo persist `/assess` preferences that
        would otherwise have to be re-supplied as CLI flags on every run. Today this
        covers the user-supplied exclude lists (issue #50: repos that intentionally
        track vetted-context / reference data need a durable escape hatch that
        applies *across every scan* - the heatmap, the doc-navigability graph, the
        doc-staleness association, and the dead-code/liveness scan).
        
        Schema (top-level, no section - the file is already namespaced by living
        under `.assess/`):
        
        ```toml
        exclude_dirs = ["regulatory-raw", "vetted-context"]
        exclude_patterns = ["*.csv", "*.parquet"]
        
        # Working-notes overrides (issue #367): force or suppress the doc graph's
        # working-notes classification for a directory, relative to the repo root.
        working_notes_dirs = ["journal"]
        working_notes_ignore = ["docs/chapters"]
        
        # Provenance for generated-doc trees (issue #178): doc-staleness for docs
        # under `path` is measured against `source` (newer source = stale) instead of
        # the doc's own file age. `source` may be a string or a list. Paths are
        # relative to the repo root.
        [[generated]]
        path = "notes"
        source = "data/jira.tsv"
        ```
        
        The exclude lists feed every scan. There is no per-scan override knob -
        if the user excludes `regulatory-raw/`, they mean "this is reference data,
        not source," and that statement applies to every layer's view of the
        codebase. Consistency is the point.
        
        Design choices:
        
        - **Optional and additive.** A missing config is the default state, not an
          error. The built-in defaults baked into each scan always apply; the config
          only *extends* them. CLI flags layer on top of both.
        - **Degrade silently on malformed input.** A broken TOML file should never
          block an assessment - the loader returns empty excludes and prints a
          one-line warning to stderr. Scans keep running on defaults.
        - **No new dependencies.** `tomllib` is in the stdlib since Python 3.11
          (which the existing scripts already require).
        """
        from __future__ import annotations
        
        import fnmatch
        import sys
        import tomllib
        from pathlib import Path
        from typing import NamedTuple
        
        
        CONFIG_FILE = "config.toml"
        
        
        def is_user_excluded(rel: Path, extra_dirs: set[str],
                             extra_patterns: list[str]) -> bool:
            """True if `rel` matches a user-supplied exclude.
        
            `extra_dirs` is matched exactly against any component of the relative
            path. `extra_patterns` is matched as a basename glob via `fnmatch`.
            Either match is sufficient - the two lists are independent. Empty
            inputs always return False so callers can call unconditionally.
        
            Reused by every scan so the semantics of `--exclude` / `config.toml`
            are identical across the heatmap, the doc-navigability graph, the
            doc-staleness pass, and the liveness scan.
            """
            if extra_dirs and any(part in extra_dirs for part in rel.parts):
                return True
            if extra_patterns and any(
                fnmatch.fnmatch(rel.name, pat) for pat in extra_patterns
            ):
                return True
            return False
        
        
        def load_config(repo_root: Path) -> dict:
            """Read `<repo_root>/.assess/config.toml` and return the parsed dict.
        
            Returns `{}` when the file does not exist, isn't readable, or fails to
            parse. Malformed files print a one-line warning to stderr; missing files
            are silent (the common case).
            """
            config_path = (repo_root / ".assess" / CONFIG_FILE).resolve()
            if not config_path.is_file():
                return {}
            try:
                return tomllib.loads(config_path.read_text(encoding="utf-8"))
            except (tomllib.TOMLDecodeError, OSError) as e:
                print(
                    f"warning: could not read {config_path} ({e}); "
                    "continuing with defaults",
                    file=sys.stderr,
                )
                return {}
        
        
        def _string_list(config: dict, key: str) -> list[str]:
            """Return `config[key]` filtered to strings only.
        
            Honours the "degrade silently" contract on three failure modes:
        
            - **Key missing**: returns `[]` (the common case).
            - **Value is not a list** (e.g. `exclude_dirs = "regulatory-raw"` or
              `exclude_dirs = 5`): returns `[]`. Iterating a string would produce
              single-character "dir names" that match unexpectedly; iterating an
              int would raise `TypeError` and propagate up through `load_excludes`
              into the orchestrator, blocking the assessment.
            - **List with non-string entries** (e.g. `exclude_dirs = ["foo", 42]`):
              drops the bad entry, keeps the rest. One malformed value doesn't
              poison the rest of the config.
            """
            value = config.get(key, [])
            if not isinstance(value, list):
                return []
            return [str(x) for x in value if isinstance(x, str)]
        
        
        # Default comprehension-footprint budget (A1). A unit whose footprint --
        # size + the public surface of its direct deps + its own exposed surface --
        # exceeds this is one no agent can change completely from inside a single
        # context window. The number is a tunable proxy for "a fraction of a reference
        # window"; repos calibrate it via `.assess/config.toml` `[structure]`.
        DEFAULT_KEYHOLE_BUDGET = 2000
        
        
        def load_structure_config(repo_root: Path) -> dict:
            """Return the `[structure]` settings from `.assess/config.toml`.
        
            Currently a single key, `keyhole_budget` (the A1 comprehension-footprint
            budget), defaulting to `DEFAULT_KEYHOLE_BUDGET`. Honours the same
            "degrade silently" contract as the exclude loaders: a missing file,
            missing section, or malformed value falls back to the default rather
            than blocking the assessment.
            """
            cfg = load_config(repo_root)
            section = cfg.get("structure", {})
            budget = DEFAULT_KEYHOLE_BUDGET
            if isinstance(section, dict):
                value = section.get("keyhole_budget")
                # bool is an int subclass; reject it so `keyhole_budget = true` doesn't
                # silently become a budget of 1. Only a positive int is a valid budget.
                if isinstance(value, int) and not isinstance(value, bool) and value > 0:
                    budget = value
            return {"keyhole_budget": budget}
        
        
        # The findings that represent a concern (everything except the one positive
        # finding, ``refactor_boundary``). The canonical order + the positive finding
        # live in ``keyhole_signals.FINDING_ORDER``; the gate only needs the concern set
        # and must not import back up into a sibling lib module, so the list is repeated
        # here deliberately. ``test_gate_concerns_match_keyhole_signals`` pins the two in
        # sync so a new finding can't silently escape the default warn set.
        GATE_CONCERN_FINDINGS = [
            "hidden_coupling",
            "lying_map",
            "unexplained_complexity",
            "untrusted_hotspot",
            "self_referential_tests",
            "unactioned_intent",
            "accretion_ratchet",
            "orphaned_understanding",
            "candidate_dead_weight",
            "override_contradicts_signals",
        ]
        
        
        def _positive_number(section: dict, key: str) -> float | None:
            """Return ``section[key]`` as a float when it is a positive real, else None.
        
            A threshold of zero or below is meaningless (every run would trip it), and a
            non-numeric value is malformed config - both degrade to "no threshold" rather
            than blocking the gate. ``bool`` is rejected (it is an ``int`` subclass) so
            ``ccn_p95_max = true`` doesn't silently become a threshold of 1.
            """
            value = section.get(key)
            if isinstance(value, bool) or not isinstance(value, (int, float)):
                return None
            return float(value) if value > 0 else None
        
        
        def _parse_gate_section(cfg: dict) -> dict:
            """Build the gate settings from an already-parsed config dict.
        
            Split from ``load_gate_config`` so the same normalization serves both the
            convention path (``.assess/config.toml``) and an explicit ``--config`` file.
            """
            gate = cfg.get("gate", {})
            if not isinstance(gate, dict):
                gate = {}
            # Distinguish "missing" (use the default warn set) from an explicit empty
            # list (warn on nothing) - ``_string_list`` alone can't tell them apart.
            warn_on = (
                _string_list(gate, "warn_on")
                if "warn_on" in gate
                else list(GATE_CONCERN_FINDINGS)
            )
            enabled = gate.get("enabled", True)
            fail_on_regression = gate.get("fail_on_regression", False)
            return {
                "enabled": enabled if isinstance(enabled, bool) else True,
                "fail_on": _string_list(gate, "fail_on"),
                "warn_on": warn_on,
                "ccn_p95_max": _positive_number(gate, "ccn_p95_max"),
                "containment_min": _positive_number(gate, "containment_min"),
                "fail_on_regression": (
                    fail_on_regression if isinstance(fail_on_regression, bool) else False
                ),
            }
        
        
        def load_gate_config(repo_root: Path) -> dict:
            """Return the ``[gate]`` settings from ``.assess/config.toml``.
        
            The gate is the CI check run by ``assess_gate.py``. Its defaults are
            deliberately **warn-only**: a repo that adopts the emitted workflow without
            writing any config never has a pipeline blocked by surprise. Every way to
            fail is strictly opt-in.
        
            Returned keys:
        
            - ``enabled`` (bool, default ``True``): master switch. ``false`` makes the
              gate always pass while still reporting, so a repo can mute it without
              deleting the workflow.
            - ``fail_on`` (list[str], default ``[]``): finding names whose presence (a
              non-empty ``paths`` list) fails the gate - an AI-readiness *floor*. Empty
              means warn-only.
            - ``warn_on`` (list[str], default = every concern finding): finding names
              reported but non-blocking. An explicit empty list silences warnings.
            - ``ccn_p95_max`` (float | None): floor - fail when the p95 file CCN exceeds.
            - ``containment_min`` (float | None): floor - fail when the safe-zone
              containment ratio drops below this (0-1).
            - ``fail_on_regression`` (bool, default ``False``): true *regression* check -
              fail when the cross-run diff (computed by ``assess_core`` against the prior
              committed snapshot) reports hotspots whose complexity/churn increased.
        
            Honours the same "degrade silently" contract as the other loaders: a missing
            file, missing section, or malformed value falls back to the default rather
            than blocking the assessment.
            """
            return _parse_gate_section(load_config(repo_root))
        
        
        def load_gate_config_file(config_path: Path) -> dict:
            """Return the ``[gate]`` settings from an explicit TOML file path.
        
            Used to honour ``assess_gate.py --config <path>`` when the gate config lives
            somewhere other than the conventional ``.assess/config.toml``. Degrades the
            same way as ``load_config``: a missing or malformed file yields defaults.
            """
            config_path = config_path.resolve()
            if not config_path.is_file():
                return _parse_gate_section({})
            try:
                cfg = tomllib.loads(config_path.read_text(encoding="utf-8"))
            except (tomllib.TOMLDecodeError, OSError) as e:
                print(
                    f"warning: could not read {config_path} ({e}); continuing with defaults",
                    file=sys.stderr,
                )
                cfg = {}
            return _parse_gate_section(cfg)
        
        
        def load_generated_sources(repo_root: Path) -> list[tuple[str, list[str]]]:
            """Return the ``[[generated]]`` folder->source provenance mappings.
        
            Lets a repo declare that a generated-doc tree derives from a source file or
            command, so doc-staleness for those docs is measured against the source
            rather than the doc's own age (issue #178). Schema::
        
                [[generated]]
                path = "notes"
                source = "data/jira.tsv"
        
                [[generated]]
                path = "docs/api"
                source = ["openapi.yaml", "schema.proto"]
        
            ``path`` is a folder relative to the repo root; every doc under it inherits
            the mapping. ``source`` is a string or list of strings relative to the repo
            root. Returns a list of ``(path, [sources])`` tuples, in declaration order so
            the first matching prefix wins deterministically. Honours the same "degrade
            silently" contract as the other loaders: a missing file, missing/!list
            section, or malformed entry is skipped rather than blocking the assessment.
            """
            cfg = load_config(repo_root)
            raw = cfg.get("generated")
            if not isinstance(raw, list):
                return []
            out: list[tuple[str, list[str]]] = []
            for entry in raw:
                if not isinstance(entry, dict):
                    continue
                path = entry.get("path")
                if not isinstance(path, str) or not path.strip():
                    continue
                source = entry.get("source")
                if isinstance(source, str):
                    sources = [source]
                elif isinstance(source, list):
                    sources = [s for s in source if isinstance(s, str)]
                else:
                    sources = []
                if not sources:
                    continue
                out.append((path.strip(), sources))
            return out
        
        
        def load_excludes(repo_root: Path) -> tuple[set[str], list[str]]:
            """Return `(extra_exclude_dirs, extra_exclude_patterns)` from the config.
        
            The same two lists feed every `/assess` scan. Returns `(set(), [])`
            when the config is missing or doesn't define the keys - callers should
            union with their built-in defaults rather than replace.
            """
            cfg = load_config(repo_root)
            dirs = set(_string_list(cfg, "exclude_dirs"))
            pats = _string_list(cfg, "exclude_patterns")
            return dirs, pats
        
        
        def _dir_list(config: dict, key: str) -> list[str]:
            """``_string_list`` normalised to repo-relative posix directory paths:
            ``./journal/`` -> ``journal``; an entry that names no directory is dropped."""
            out = []
            for raw in _string_list(config, key):
                path = raw.strip().replace("\\", "/").strip("/")
                while path.startswith("./"):
                    path = path[2:]
                if path and path != ".":
                    out.append(path)
            return out
        
        
        class WorkingNotesConfig(NamedTuple):
            """The working-notes overrides, as repo-relative directory paths."""
        
            dirs: list[str]
            ignore: list[str]
        
        
        def load_working_notes_config(repo_root: Path) -> WorkingNotesConfig:
            """Return the working-notes overrides for ``build_doc_graph``.
        
            ``working_notes_dirs`` forces each listed directory to be reported as a
            working-notes tree whatever its size or fingerprint (issue #367);
            ``working_notes_ignore`` keeps each listed directory, and everything under
            it, out of every working-notes tree even when the fingerprint matches.
            Both are lists of repo-relative directory paths. Honours the same "degrade
            silently" contract as the other loaders.
            """
            cfg = load_config(repo_root)
            return WorkingNotesConfig(
                dirs=_dir_list(cfg, "working_notes_dirs"),
                ignore=_dir_list(cfg, "working_notes_ignore"),
            )
        
        
        def split_cli_excludes(cli_excludes: list[str]) -> tuple[set[str], list[str]]:
            """Split repeatable `--exclude PATTERN` values into `(dirs, glob_patterns)`.
        
            A pattern containing a glob metacharacter (`*?[`) is a basename glob; any
            other value is treated as a directory name. This is the same shape the
            treemap CLI accepts, so `--exclude regulatory-raw` works as a dir match
            without the user picking the right list.
            """
            dirs: set[str] = set()
            patterns: list[str] = []
            for pat in cli_excludes:
                if any(c in pat for c in "*?["):
                    patterns.append(pat)
                else:
                    dirs.add(pat)
            return dirs, patterns
        
        
        def resolve_excludes(
            repo_root: Path, cli_excludes: list[str] | None = None,
        ) -> tuple[set[str], list[str]]:
            """Config excludes (`.assess/config.toml`) + CLI `--exclude`, combined.
        
            The single resolution path shared by the treemap CLI and the doc-graph SVG
            so every artifact computes over the *identical* doc/code set. Both extend
            the built-in defaults; callers union the result with their own defaults
            rather than replacing them (issue #177).
            """
            cfg_dirs, cfg_patterns = load_excludes(repo_root)
            cli_dirs, cli_patterns = split_cli_excludes(cli_excludes or [])
            return cfg_dirs | cli_dirs, cfg_patterns + cli_patterns
        
      • badge.py 7.4 KB
        """Shields.io endpoint badge for the .assess wiki.
        
        Renders ``.assess/badge.json`` in the `shields.io endpoint schema
        <https://shields.io/badges/endpoint-badge>`_ so a README can embed a live
        AI-readiness badge with zero infrastructure::
        
            ![AI-readiness](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/<owner>/<repo>/<branch>/.assess/badge.json)
        
        Two producers, one default:
        
        - ``fallback_badge`` - the deterministic default ("2 findings · 0 stale
          markers"), the badge ``assess_core.py`` always writes to ``badge.json``. Its
          message is a pure function of measured run data - never an LLM-authored score
          - and ``assess_core`` stamps a ``link`` funnelling a badge-clicker to
          ``assess-report.md``, where the LLM-derived grade lives.
        - ``score_badge`` - the headline form ("7.0/8 · AI-Native"), derived from the
          LLM layered score. It is the in-report headline only; it is *not* written to
          ``badge.json``, so the shipped badge never claims a grade a deterministic run
          cannot reproduce.
        
        A badge is a self-description, so it inherits the truth-pressure rule: the
        shipped badge's colours and messages are pure functions of run data (tested
        thresholds, no judgement), and it only ever claims what the producing run
        measured. The LLM-derived grade is one click away in the report, not baked into
        a badge that would need re-earning on every scoring run.
        """
        from __future__ import annotations
        
        import json
        from pathlib import Path
        from typing import Any
        
        BADGE_FILENAME = "badge.json"
        LABEL = "AI-readiness"
        
        # Score -> shields colour, expressed as a *fraction of the denominator* so the
        # bands hold whether the score is over the full 0-8 software scale or a
        # renormalised knowledge-base denominator (issue #224). Ordered thresholds,
        # first match wins; the top band starts where "AI-Native" lands (7/8 = 0.875),
        # the bottom is reserved for near-zero scaffolding. For the default
        # denominator 8 these fractions reproduce the original absolute thresholds
        # exactly (0.875·8 = 7.0, 0.6875·8 = 5.5, ...).
        _SCORE_COLOR_RATIOS: list[tuple[float, str]] = [
            (0.875, "brightgreen"),
            (0.6875, "green"),
            (0.5, "yellowgreen"),
            (0.3125, "yellow"),
            (0.125, "orange"),
            (0.0, "red"),
        ]
        
        
        def score_color(score: float, denominator: float = 8) -> str:
            """Deterministic colour band for a layered score over its denominator."""
            ratio = (score / denominator) if denominator else 0.0
            for floor, color in _SCORE_COLOR_RATIOS:
                if ratio >= floor:
                    return color
            return "red"
        
        
        # Maturity ladder: the renormalised fraction (score / denominator) mapped to a
        # named tier. The top tier's floor is the same 0.875 that opens
        # ``_SCORE_COLOR_RATIOS`` (the brightgreen band where "AI-Native" lands); the
        # finer tiers below follow the scoring ladder documented in
        # ``agents/assess-layer-scorer.md`` (>=0.625 Solid, >=0.375 Basic, else Not
        # Ready), so the label finalize accepts is exactly the label the scorer was told
        # to emit from the same fraction. Ordered, first match wins - the single source
        # of truth for "what tier does this score earn?" that assess_finalize's
        # consistency invariant reconciles the LLM-supplied ``maturity_label`` against.
        _MATURITY_BANDS: list[tuple[float, str]] = [
            (0.875, "AI-Native"),
            (0.625, "Solid"),
            (0.375, "Basic"),
            (0.0, "Not Ready"),
        ]
        
        
        def maturity_band(score: float, denominator: float = 8) -> str:
            """The canonical maturity tier a score earns over its denominator.
        
            Derived from the same fraction the badge colour uses; the tier names come
            from the documented scoring ladder. Used by ``assess_finalize`` to reject a
            ``maturity_label`` that overstates (or understates) the score band.
            """
            ratio = (score / denominator) if denominator else 0.0
            for floor, label in _MATURITY_BANDS:
                if ratio >= floor:
                    return label
            return "Not Ready"
        
        
        def _scoped_label(scope: str | None) -> str:
            """The badge label, suffixed with the scope for a `/assess <path>` run.
        
            ``scope`` is the repo-relative subtree (e.g. ``services/api``). None (a
            whole-repo run) yields the bare ``LABEL`` so default output is unchanged.
            """
            return f"{LABEL} ({scope})" if scope else LABEL
        
        
        def score_badge(
            score: float, maturity_label: str, denominator: int = 8,
            run_id: str | None = None, scope: str | None = None,
        ) -> dict[str, Any]:
            """The in-report headline form: layered score + maturity label.
        
            No longer written to ``badge.json`` - the shipped badge is always the
            deterministic ``fallback_badge`` (which links to the report). This is the
            LLM-derived grade that appears inside ``assess-report.md``.
        
            ``denominator`` is 8 for a software repo (the display ceiling) and the
            count of applicable layers for a knowledge base (issue #224), so the badge
            reads e.g. ``2.5/3 · Knowledge Base · Solid`` instead of a misleading
            ``2.5/8``.
        
            ``run_id`` (when supplied) is stamped as a non-rendering provenance field so
            the badge traces back to the run that produced it. shields.io ignores keys
            it doesn't recognise, so the extra field never changes what the badge shows.
        
            ``scope`` (the repo-relative subtree) suffixes the label for a
            ``/assess <path>`` monorepo run so the badge names what it measured; None
            keeps the bare label.
            """
            badge = {
                "schemaVersion": 1,
                "label": _scoped_label(scope),
                "message": f"{score}/{denominator} · {maturity_label}",
                "color": score_color(score, denominator),
            }
            if run_id is not None:
                badge["run_id"] = run_id
            return badge
        
        
        def fallback_badge(
            concern_count: int, stale_markers: int, run_id: str | None = None,
            scope: str | None = None,
        ) -> dict[str, Any]:
            """The default shipped badge: a deterministic, always-written self-description.
        
            ``assess_core.py`` writes this to ``badge.json`` on every run and stamps a
            ``link`` to ``assess-report.md``; the LLM-derived score lives in the report,
            not the badge.
        
            ``concern_count`` is the number of derived findings with non-empty paths
            (``refactor_boundary`` excluded - it is the positive finding);
            ``stale_markers`` is ``promissory_markers.total_stale`` (0 when the scan
            was unavailable - the message stays truthful because it only counts what
            was measured). ``run_id`` (when supplied) is stamped as a non-rendering
            provenance field, exactly as in ``score_badge``. ``scope`` suffixes the
            label for a ``/assess <path>`` run.
            """
            badge = {
                "schemaVersion": 1,
                "label": _scoped_label(scope),
                "message": f"{concern_count} findings · {stale_markers} stale markers",
                "color": (
                    "green"
                    if concern_count == 0 and stale_markers == 0
                    else "yellow"
                    if concern_count <= 2
                    else "orange"
                ),
            }
            if run_id is not None:
                badge["run_id"] = run_id
            return badge
        
        
        def concern_count_from_findings(derived_findings: list[dict]) -> int:
            """Count negative findings that actually fired (non-empty paths)."""
            return sum(
                1
                for f in derived_findings
                if isinstance(f, dict)
                and f.get("name") != "refactor_boundary"
                and f.get("paths")
            )
        
        
        def write_badge(assess_dir: Path, badge: dict[str, Any]) -> None:
            (assess_dir / BADGE_FILENAME).write_text(
                json.dumps(badge, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
            )
        
        
        def badge_exists(assess_dir: Path) -> bool:
            return (assess_dir / BADGE_FILENAME).exists()
        
      • change_coupling.py 22.9 KB
        """Git-log change-coupling and authorship primitives for the /assess core.
        
        Three deterministic signals derived purely from ``git log``, mirroring the
        subprocess+stdlib style of ``lib.git_churn`` (no AI, no heavy deps, every git
        call capped by ``GIT_TIMEOUT_SECONDS`` and degrading to an empty/neutral result
        on failure):
        
          - **B1 change-coupling** (:func:`change_coupling_pairs`) - file pairs that
            keep co-changing in the same commit. Hidden edges an agent can't see from
            the import graph: edit A and you probably need to edit B.
          - **B2 containment** (:func:`containment_ratio`) - what fraction of the
            commits that touch a module touch *only* that module. High = a safe island
            an agent can change without ripples leaking out.
          - **B4 authorship** (:func:`authorship_analysis`) - whether a path has a human
            anchor and a human intent source, and a deliberately conservative
            human/agent/mixed/unknown class. We never label a human's work "agent" on
            weak evidence (PRD Open Question 6): detection is e-mail-based, never on a
            person's *name* (someone really can be called "Claude").
        
        All returned structures are JSON-serialisable (paths as strings, plain
        dict/list/bool/number) so task #5 can drop them straight into run-context.json.
        """
        from __future__ import annotations
        
        import re
        import subprocess
        from dataclasses import dataclass
        from itertools import combinations
        from pathlib import Path
        
        # Cap every git call so a stuck invocation (huge repo, lock contention, a hung
        # credential prompt) degrades to "no data" rather than blocking the run. Same
        # value and rationale as lib.git_churn.
        GIT_TIMEOUT_SECONDS = 20
        
        # Commits that touch more files than this are bulk/mechanical (mass renames,
        # vendoring, reformatting, license headers). Pairing every file in them would
        # both explode combinatorially (n^2) and drown the genuine coupling signal in
        # noise, so they are excluded from pair generation. They still count toward the
        # commit total (the support_pct denominator).
        MAX_COMMIT_FILES_FOR_COUPLING = 50
        
        # --- Agent detection (e-mail based, conservative) ----------------------------
        # We classify by e-mail, NEVER by display name: "Claude", "Cursor" and "Codex"
        # are all real human given names/surnames, and mislabelling a person's work as
        # agent-generated is the failure mode PRD Open Question 6 tells us to avoid.
        # `[bot]` is the GitHub Apps convention (dependabot, renovate, github-actions,
        # copilot all commit as `name[bot]`); the anthropic/copilot addresses cover the
        # common Co-Authored-By trailers. We deliberately do NOT treat bare
        # `noreply@github.com` / `users.noreply.github.com` as a bot - those are the
        # privacy addresses humans use for web-UI commits.
        AI_EMAIL_HINTS = (
            "[bot]",
            "noreply@anthropic.com",
            "copilot@",
        )
        
        _CO_AUTHORED_BY = re.compile(r"co-authored-by:\s*(.+)", re.IGNORECASE)
        
        
        def repo_top(repo_root: Path) -> str | None:
            """Absolute repo top-level for ``repo_root``, or None if not in a git repo."""
            try:
                return subprocess.run(
                    ["git", "-C", str(repo_root), "rev-parse", "--show-toplevel"],
                    capture_output=True, text=True, check=True, timeout=GIT_TIMEOUT_SECONDS,
                ).stdout.strip()
            except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
                return None
        
        
        def parse_commit_file_sets(
            repo_root: Path, since: str | None = None, *, top: str | None = None,
        ) -> list[set[Path]]:
            """Return one set of touched files per commit, parsed from ``git log``.
        
            Each set holds the repo-relative paths (as git prints them with
            ``--name-only``) changed by a single commit, newest first. Merge commits and
            commits that changed no files yield an empty set, so the list length equals
            the number of commits in the window - callers that need a commit count can
            use ``len(...)``. Returns ``[]`` when ``repo_root`` is not inside a git repo.
        
            Pass ``since`` as a git date expression (e.g. ``"12 months ago"``) to window
            the history; ``None`` (default) means full history reachable from HEAD.
            Renames are not followed: a commit made before a rename lists the file under
            its old path. Pass the sets through :func:`fold_renames` with
            :func:`build_rename_map`'s paths to count that history under current paths.
            ``top`` is the repo top-level when the caller already resolved it (see
            :func:`repo_top`), saving a ``git rev-parse``.
            """
            top = top or repo_top(repo_root)
            if top is None:
                return []
        
            # \x1e (ASCII record separator) marks the start of each commit so we can
            # split unambiguously; the name-only file list follows on its own lines.
            # -M pinned so a rename commit lists only the new name whatever the user's
            # diff.renames setting, matching build_rename_map's detection.
            # core.quotepath=false keeps non-ASCII paths literal, not octal-escaped, so
            # they match files on disk.
            cmd = ["git", "-c", "core.quotepath=false", "-C", top, "log", "--name-only", "-M",
                   "--pretty=format:\x1e%H"]
            if since:
                cmd.append(f"--since={since}")
            try:
                raw = subprocess.run(
                    cmd, capture_output=True, text=True, check=True, timeout=GIT_TIMEOUT_SECONDS,
                ).stdout
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                return []
        
            commit_sets: list[set[Path]] = []
            for chunk in raw.split("\x1e"):
                if not chunk.strip():
                    continue
                lines = chunk.splitlines()
                # lines[0] is the commit hash; the rest are touched files.
                files: set[Path] = set()
                for line in lines[1:]:
                    line = line.strip()
                    if line:
                        files.add(Path(line))
                commit_sets.append(files)
            return commit_sets
        
        
        @dataclass(frozen=True)
        class RenameMap:
            """Historical path -> current path, and whether git history was read.
        
            ``complete`` is False when git history exists but could not be read (git
            failed or timed out). Outside a git repo there is no history to rename, so
            the empty map is complete. An empty ``paths`` then means "unknown", not "no
            renames", so a caller must not treat an unmapped old path as deleted.
            """
        
            paths: dict[str, str]
            complete: bool
        
        
        def build_rename_map(repo_root: Path, *, top: str | None = None) -> RenameMap:
            """Map each historical path that git saw renamed to its current path.
        
            Parsed from ``git log --topo-order --name-status -M --diff-filter=R``. A
            path starts from its first rename, and the chain moves on only through a
            rename made in a commit that descends from the one before it (``a -> b``
            then ``b -> c`` maps ``a`` to ``c``; ``b -> c`` then ``a -> b`` maps ``a``
            to ``b``, and so do renames on sibling branches). A source path that exists
            again in the working tree is left out, so a name reused after a rename keeps
            its own history. Paths are repo-relative, as :func:`parse_commit_file_sets`
            prints them. Outside a git repo the result is empty and complete; on a git
            failure it is empty and ``complete`` is False. ``top`` is as for
            :func:`parse_commit_file_sets`.
            """
            top = top or repo_top(repo_root)
            if top is None:
                return RenameMap({}, complete=True)
            # \x1e marks each commit and carries its hash. --topo-order lists every
            # commit before its ancestors, so reversed, an ancestor always has the
            # smaller index.
            cmd = ["git", "-c", "core.quotepath=false", "-C", top, "log", "--topo-order",
                   "--name-status", "-M", "--diff-filter=R", "--pretty=format:\x1e%H"]
            try:
                raw = subprocess.run(
                    cmd, capture_output=True, text=True, check=True, timeout=GIT_TIMEOUT_SECONDS,
                ).stdout
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                return RenameMap({}, complete=False)
        
            # edges[src] lists (index, commit, dst) with the oldest rename first.
            edges: dict[str, list[tuple[int, str, str]]] = {}
            chunks = [c for c in raw.split("\x1e") if c.strip()]
            for index, chunk in enumerate(reversed(chunks)):
                lines = chunk.splitlines()
                for line in lines[1:]:
                    parts = line.split("\t")
                    if len(parts) == 3 and parts[0].startswith("R"):
                        edges.setdefault(parts[1], []).append((index, lines[0].strip(), parts[2]))
        
            ancestry: dict[tuple[str, str], bool] = {}
        
            def descends(commit: str, ancestor: str) -> bool:
                """True when ``commit`` has ``ancestor`` in its history (cached).
        
                Exit 0 is yes and 1 is no. Anything else (128 for an object git cannot
                resolve, as at a shallow or grafted boundary) is a failure, raised so the
                map comes back incomplete rather than reading as unrelated commits.
                """
                key = (ancestor, commit)
                if key not in ancestry:
                    cmd = ["git", "-C", top, "merge-base", "--is-ancestor", ancestor, commit]
                    result = subprocess.run(cmd, capture_output=True, timeout=GIT_TIMEOUT_SECONDS)
                    if result.returncode not in (0, 1):
                        raise subprocess.CalledProcessError(result.returncode, cmd)
                    ancestry[key] = result.returncode == 0
                return ancestry[key]
        
            def next_edge(path: str, index: int, commit: str) -> tuple[int, str, str] | None:
                """The first rename of ``path`` in a later commit descending from ``commit``."""
                return next((e for e in edges.get(path, [])
                             if e[0] > index and descends(e[1], commit)), None)
        
            # The walk follows an edge only when its commit descends from the one that
            # moved the content to the current name, so a name freed by one rename and
            # refilled by another (b -> c, then a -> b, in sequence or on sibling
            # branches) is not chained through: a maps to b, not c. Chains resolve before
            # sources that exist again in the working tree are dropped, so a reused
            # intermediate name (a -> b, b -> c, then a fresh b) still leads a to c.
            resolved: dict[str, str] = {}
            try:
                for old, outgoing in edges.items():
                    index, commit, cur = outgoing[0]
                    hop = next_edge(cur, index, commit)
                    while hop is not None:
                        index, commit, cur = hop
                        hop = next_edge(cur, index, commit)
                    resolved[old] = cur
            except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
                return RenameMap({}, complete=False)
            top_path = Path(top)
            return RenameMap(
                {old: new for old, new in resolved.items() if not (top_path / old).exists()},
                complete=True,
            )
        
        
        def fold_renames(
            commit_sets: list[set[Path]], rename_map: dict[str, str],
        ) -> list[set[Path]]:
            """Rewrite each commit's paths through ``rename_map`` (a :class:`RenameMap`'s paths).
        
            History recorded under an old path is counted under the current one, so a
            pair of files renamed after they co-changed keeps its co-change count under
            the new names. Returns ``commit_sets`` unchanged when the map is empty.
            """
            if not rename_map:
                return commit_sets
            return [
                {Path(rename_map.get(f.as_posix(), f.as_posix())) for f in files}
                for files in commit_sets
            ]
        
        
        def change_coupling_pairs(
            commit_sets: list[set[Path]], min_support: int = 3,
        ) -> list[dict]:
            """B1: file pairs that co-change, from :func:`parse_commit_file_sets` output.
        
            For every commit, all unordered file pairs are tallied; a pair is reported
            only when its ``co_change_count`` reaches ``min_support`` (default 3). Each
            entry is ``{file_a, file_b, co_change_count, support_pct}`` with paths as
            strings and ``file_a < file_b`` lexicographically. ``support_pct`` is the
            percentage of *all* commits in the window in which the pair co-changed
            (``100 * co_change_count / len(commit_sets)``).
        
            Commits touching more than ``MAX_COMMIT_FILES_FOR_COUPLING`` files are
            skipped for pairing (bulk/mechanical noise) but still count toward the
            support denominator. Results are sorted by count descending, then path.
            """
            total_commits = len(commit_sets)
            counts: dict[tuple[str, str], int] = {}
            for files in commit_sets:
                if len(files) < 2 or len(files) > MAX_COMMIT_FILES_FOR_COUPLING:
                    continue
                # sorted() over Paths gives a deterministic, file_a<file_b ordering.
                for a, b in combinations(sorted(files), 2):
                    key = (str(a), str(b))
                    counts[key] = counts.get(key, 0) + 1
        
            pairs = [
                {
                    "file_a": a,
                    "file_b": b,
                    "co_change_count": count,
                    "support_pct": round(100.0 * count / total_commits, 2) if total_commits else 0.0,
                }
                for (a, b), count in counts.items()
                if count >= min_support
            ]
            # dict values are heterogeneous (str | int | float), so mypy types the
            # lookup as ``object``; the negation is valid at runtime (count is int).
            pairs.sort(key=lambda d: (-d["co_change_count"], d["file_a"], d["file_b"]))  # type: ignore[operator]
            return pairs
        
        
        def _normalise_module(repo_root: Path, module_path: Path | str) -> Path:
            """Return ``module_path`` as a repo-relative Path to match commit file sets."""
            mod = Path(module_path)
            if mod.is_absolute():
                top = repo_top(repo_root)
                if top:
                    try:
                        mod = mod.resolve().relative_to(Path(top).resolve())
                    except ValueError:
                        pass
            return mod
        
        
        def containment_ratio(
            repo_root: Path, module_path: Path | str, commit_sets: list[set[Path]],
        ) -> float:
            """B2: fraction of commits touching ``module_path`` that touch *only* it.
        
            ``commits touching only files in the module / commits touching the module at
            all``. 1.0 means every change to the module was self-contained (a safe
            island an agent can edit without ripples); a low value means edits to the
            module routinely drag in files elsewhere.
        
            ``module_path`` may be a directory or a file, absolute or repo-relative; it
            is normalised to the repo-relative form that :func:`parse_commit_file_sets`
            produces. A file is "in the module" if it equals or sits under that path.
        
            **Zero commits touch the module -> returns 1.0.** With no observed bleed
            there is nothing to contradict containment, so it is treated as vacuously
            contained. Callers that must tell "safe island" from "no history" should
            check module activity separately (e.g. via churn).
            """
            mod = _normalise_module(repo_root, module_path)
        
            def in_module(f: Path) -> bool:
                if f == mod:
                    return True
                try:
                    f.relative_to(mod)
                    return True
                except ValueError:
                    return False
        
            touching = 0
            only = 0
            for files in commit_sets:
                if not files:
                    continue
                in_mod = [f for f in files if in_module(f)]
                if in_mod:
                    touching += 1
                    if len(in_mod) == len(files):
                        only += 1
        
            if touching == 0:
                return 1.0
            return only / touching
        
        
        def _identity_is_agent(email: str, name: str = "") -> bool:
            """True if an identity belongs to a known bot/agent.
        
            Matches the ``[bot]`` GitHub Apps marker in either name or e-mail (a reserved
            convention no human carries, so safe to match on the name), plus the
            AI-tool e-mail hints. AI tool *names* ("Claude", "Cursor") are never matched
            - those are real human names too (PRD Open Question 6).
            """
            e = email.lower()
            n = name.lower()
            if "[bot]" in e or "[bot]" in n:
                return True
            return any(hint in e for hint in AI_EMAIL_HINTS)
        
        
        def _coauthors_have_agent(coauthor_field: str) -> bool:
            """True if any Co-Authored-By trailer names an agent (matched on its e-mail)."""
            # Trailer values look like "Claude <noreply@anthropic.com>"; we match the
            # bracketed e-mail, never the display name.
            for value in coauthor_field.split("\x1d"):
                value = value.strip()
                if not value:
                    continue
                emails = re.findall(r"<([^>]+)>", value)
                target = emails[0] if emails else value
                if _identity_is_agent(target):
                    return True
            return False
        
        
        def authorship_analysis(repo_root: Path, path: Path | str) -> dict:  # noqa: C901  # multi-signal B4 heuristic; ccn 19, ratchet target
            """B4: human-anchor / intent-source signals and a conservative class for ``path``.
        
            Returns ``{human_anchor, authorship_class, intent_source, contributors}``:
        
              - ``human_anchor`` (bool): a confirmed human authored at least one commit -
                someone who can be asked about the code.
              - ``intent_source`` (bool): a human authored *or* committed at least one
                commit - a human directed the change even if an agent wrote the diff.
              - ``authorship_class``: one of ``'human'``, ``'agent'``, ``'mixed'``,
                ``'unknown'``. Deliberately cautious - ``'agent'`` requires that *every*
                commit is a confirmed agent with no human and no ambiguous author;
                anything uncertain falls back to ``'unknown'`` rather than risk
                attributing a person's work to a machine (PRD Open Question 6).
              - ``contributors``: per-author ``{name, email, commits, lines_added,
                lines_removed, classification}``, sorted by commit count descending.
        
            Degrades to ``{human_anchor: False, authorship_class: 'unknown',
            intent_source: False, contributors: []}`` when there is no git history (path
            untracked, repo absent, git missing/slow).
            """
            default = {
                "human_anchor": False,
                "authorship_class": "unknown",
                "intent_source": False,
                "contributors": [],
            }
            top = repo_top(repo_root)
            if top is None:
                return default
        
            # One record per commit, RS-delimited; fields US-delimited. Co-author
            # trailers are folded onto one line with a GS (\x1d) separator so the header
            # stays single-line and numstat rows follow it cleanly.
            fmt = "\x1e%H\x1f%an\x1f%ae\x1f%cn\x1f%ce\x1f%(trailers:key=Co-authored-by,valueonly,separator=%x1d)"
            try:
                raw = subprocess.run(
                    ["git", "-C", top, "log", "--no-merges", "--numstat",
                     f"--format={fmt}", "--", str(path)],
                    capture_output=True, text=True, check=True, timeout=GIT_TIMEOUT_SECONDS,
                ).stdout
            except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
                return default
        
            any_agent = False
            any_human = False
            n_unknown = 0
            intent_source = False
            # Aggregate per author identity (name, email).
            contributors: dict[tuple[str, str], dict] = {}
            total_commits = 0
        
            for chunk in raw.split("\x1e"):
                if not chunk.strip():
                    continue
                lines = chunk.split("\n")
                fields = lines[0].split("\x1f")
                # Pad in case a field is empty/missing.
                fields += [""] * (6 - len(fields))
                _h, an, ae, cn, ce, coauthors = fields[:6]
                total_commits += 1
        
                author_agent = _identity_is_agent(ae, an)
                committer_agent = _identity_is_agent(ce, cn)
                coauthor_agent = _coauthors_have_agent(coauthors)
                author_human = bool(ae.strip()) and "@" in ae and not author_agent
                committer_human = bool(ce.strip()) and "@" in ce and not committer_agent
        
                agent_involved = author_agent or committer_agent or coauthor_agent
                if agent_involved:
                    any_agent = True
                if author_human:
                    any_human = True
                if author_human or committer_human:
                    intent_source = True
                if not author_human and not agent_involved:
                    n_unknown += 1
        
                # Line stats: numstat rows are "added\tremoved\tfile"; "-" marks binary.
                added = removed = 0
                for row in lines[1:]:
                    row = row.strip()
                    if not row:
                        continue
                    parts = row.split("\t")
                    if len(parts) >= 2:
                        added += int(parts[0]) if parts[0].isdigit() else 0
                        removed += int(parts[1]) if parts[1].isdigit() else 0
        
                if author_agent:
                    classification = "agent"
                elif author_human:
                    classification = "human"
                else:
                    classification = "unknown"
                key = (an, ae)
                agg = contributors.get(key)
                if agg is None:
                    contributors[key] = {
                        "name": an,
                        "email": ae,
                        "commits": 1,
                        "lines_added": added,
                        "lines_removed": removed,
                        "classification": classification,
                    }
                else:
                    agg["commits"] += 1
                    agg["lines_added"] += added
                    agg["lines_removed"] += removed
        
            if total_commits == 0:
                return default
        
            if any_agent and any_human:
                authorship_class = "mixed"
            elif any_agent and not any_human:
                # Agent evidence but no confirmed human. Only call it pure 'agent' when
                # there is zero ambiguity; otherwise stay 'unknown' to avoid claiming a
                # possibly-human commit was agent-made.
                authorship_class = "agent" if n_unknown == 0 else "unknown"
            elif any_human and not any_agent:
                authorship_class = "human"
            else:
                authorship_class = "unknown"
        
            contributor_list = sorted(
                contributors.values(), key=lambda c: (-c["commits"], c["name"], c["email"]),
            )
            return {
                "human_anchor": any_human,
                "authorship_class": authorship_class,
                "intent_source": intent_source,
                "contributors": contributor_list,
            }
        
        
        # --- E2: self-referential test authorship -------------------------------------
        
        def find_self_referential_tests(
            repo_root: Path,
            test_to_code_map: dict[str, str],
            commit_sets: list[set[Path]] | None = None,
        ) -> list[dict]:
            """E2: tests added in the same commit as the code they cover.
        
            A test introduced alongside its subject in one commit risks verifying
            *internal consistency* (the author's mental model at the moment of writing)
            rather than *truth* (independently specified behaviour). This is the
            high-precision, same-commit signal: each ``{test_file, source_file}`` pair
            from ``test_to_code_map`` is flagged when at least one commit touches both
            files. It deliberately does NOT chase code+tests split across two commits -
            start with the precise signal, measure the miss rate before widening.
        
            ``commit_sets`` is reused from the orchestrator's single ``git log`` parse
            when supplied; otherwise it is parsed here. Returns a list of
            ``{test_file, source_file, reason}`` dicts, sorted by ``test_file`` for
            determinism. Empty map or no git history yields ``[]``.
            """
            if not test_to_code_map:
                return []
            if commit_sets is None:
                commit_sets = parse_commit_file_sets(Path(repo_root))
            commit_path_sets = [{str(f) for f in files} for files in commit_sets]
            self_ref: list[dict] = []
            for test_file, source_file in sorted(test_to_code_map.items()):
                for paths in commit_path_sets:
                    if test_file in paths and source_file in paths:
                        self_ref.append({
                            "test_file": test_file,
                            "source_file": source_file,
                            "reason": "test added in same commit as code",
                        })
                        break
            return self_ref
        
      • ci_workflow.py 9.3 KB
        """Emit the frozen-harness GitHub Action for /assess.
        
        The third end-of-run offer turns ``/assess`` from a thing-you-run into a
        thing-that-runs: it writes a GitHub Action that runs the deterministic core on
        every pull request and gates on AI-readiness floors (and, opt-in, cross-run
        regressions) via ``assess_gate.py``. The AI writes the workflow once, baking in
        the toolchain this run discovered; from then on it is a contract, not a norm.
        
        This module renders the workflow from ``templates/assess-gate.yml.template`` (a
        stdlib ``string.Template`` - no new dependency, honouring the deterministic-core
        contract). It is pure-render plus a thin file-writer, so the rendering is unit
        testable without touching disk.
        """
        from __future__ import annotations
        
        import re
        from pathlib import Path
        from string import Template
        
        # Where the template lives relative to this module: scripts/lib/ -> skills/assess/.
        _TEMPLATE_PATH = Path(__file__).resolve().parent.parent.parent / "templates" / "assess-gate.yml.template"
        
        # Discovered binaries we know how to install in CI. lizard / squarify / grimp /
        # networkx are Python deps the scripts pull via uv, so they need no OS step; only
        # external binaries (scc and the per-language dead-code tools) get a step here.
        # A discovered tool with no recipe is surfaced as a comment so the maintainer
        # wires it in rather than the gate silently dropping it.
        #
        # Two invariants, both enforced by tests:
        # - Every install is pinned to an exact release. A floating @latest can shift
        #   complexity-stats.json between runs and move the regression baseline with no
        #   change in the assessed tree (a future scc release did exactly this risk).
        # - Every install carries ``continue-on-error: true``. The gate's contract is
        #   warn-only until the repo opts in via .assess/config.toml; an install failure
        #   is infrastructure, not a finding, so it degrades to reduced coverage (the
        #   core skips tools missing from PATH) instead of a red check.
        _CONTINUE = "        continue-on-error: true  # missing tool degrades coverage, not the check\n"
        _TOOL_STEPS: dict[str, str] = {
            "scc": (
                "      - name: Install scc\n"
                + _CONTINUE
                + "        run: go install github.com/boyter/scc/v3@v3.7.0\n"
            ),
            "staticcheck": (
                "      - name: Install staticcheck\n"
                + _CONTINUE
                + "        run: go install honnef.co/go/tools/cmd/staticcheck@2026.1\n"
            ),
            "ts-prune": (
                "      - name: Install ts-prune\n"
                + _CONTINUE
                + "        run: npm install -g ts-prune@0.10.3\n"
            ),
            "knip": (
                "      - name: Install knip\n"
                + _CONTINUE
                + "        run: npm install -g knip@6.16.1\n"
            ),
        }
        
        
        def _render_tool_steps(discovered_tools: list[str]) -> str:
            """Render the install steps for discovered external tools.
        
            Returns a block beginning with a leading newline so it slots cleanly between
            two existing steps in the template, or an empty string when nothing external
            needs installing (the deterministic core's Python deps come via uv).
            """
            steps: list[str] = []
            unknown: list[str] = []
            seen: set[str] = set()
            for tool in discovered_tools:
                if tool in seen:
                    continue
                seen.add(tool)
                recipe = _TOOL_STEPS.get(tool)
                if recipe is not None:
                    steps.append(recipe)
                elif tool not in {"lizard", "squarify", "grimp", "networkx", "matplotlib", "numpy"}:
                    unknown.append(tool)
            if unknown:
                listed = ", ".join(sorted(unknown))
                steps.append(
                    f"      # Discovered tools without an install recipe: {listed}.\n"
                    "      # Add a step above if the gate should depend on them.\n"
                )
            if not steps:
                return ""
            return "\n" + "".join(steps)
        
        
        # The ignore list written when neither --paths nor --paths-ignore is given and an
        # existing workflow already filters pull requests by path: docs-only PRs skip
        # the gate the way they skip the repo's other path-filtered checks, and a PR that
        # only refreshes the committed .assess/ snapshot does not gate against itself.
        # The cost: doc-truth findings (lying_map, orphaned_understanding) no longer gate
        # docs-only PRs, so the CLI's notice says so.
        DEFAULT_PATHS_IGNORE = ["**/*.md", ".assess/**"]
        
        # A line scan, not a YAML parse: the deterministic core carries no YAML dependency.
        # Only a pull-request trigger counts as evidence that PR checks are path-scoped; a
        # ``paths:`` on ``push`` (a publish trigger, say) says nothing about PR checks.
        _PR_TRIGGER_RE = re.compile(r"^(\s*)pull_request(?:_target)?\s*:(.*)$")
        _PATHS_KEY_RE = re.compile(r"^\s*paths(?:-ignore)?\s*:")
        _FLOW_PATHS_RE = re.compile(r"[{,]\s*paths(?:-ignore)?\s*:")
        
        
        def _indent(line: str) -> int:
            return len(line) - len(line.lstrip())
        
        
        def _filters_pull_requests_by_path(text: str) -> bool:
            """True when a ``pull_request`` / ``pull_request_target`` trigger carries a
            ``paths:`` or ``paths-ignore:`` key, or a step uses ``dorny/paths-filter``
            (which only has a diff to filter on pull-request-shaped events)."""
            if "dorny/paths-filter" in text:
                return True
            lines = text.splitlines()
            for i, line in enumerate(lines):
                m = _PR_TRIGGER_RE.match(line)
                if m is None:
                    continue
                if _FLOW_PATHS_RE.search(m[2]):  # pull_request: {paths: [...]}
                    return True
                depth = len(m[1])
                for child in lines[i + 1:]:
                    if not child.strip() or child.lstrip().startswith("#"):
                        continue
                    if _indent(child) <= depth:
                        break
                    if _PATHS_KEY_RE.match(child):
                        return True
            return False
        
        
        def _yaml_quote(value: str) -> str:
            """Single-quoted YAML scalar: globs start with ``*`` (an alias) otherwise."""
            return "'" + value.replace("'", "''") + "'"
        
        
        def _render_path_filters(paths: list[str] | None, paths_ignore: list[str] | None) -> str:
            """Render ``paths:`` / ``paths-ignore:`` lists for the ``on.pull_request`` block.
        
            Each list keeps its given order. Returns lines ending in a newline, or an empty
            string when neither list has entries.
            """
            lines: list[str] = []
            for key, globs in (("paths", paths), ("paths-ignore", paths_ignore)):
                if globs:
                    lines.append(f"    {key}:\n")
                    lines.extend(f"      - {_yaml_quote(g)}\n" for g in globs)
            return "".join(lines)
        
        
        def find_path_filtered_workflow(repo_root: Path) -> Path | None:
            """The first existing workflow that filters pull requests by path, else None.
        
            Scans ``.github/workflows/*.yml`` and ``*.yaml`` in name order, skipping the
            gate's own ``assess-gate.yml`` so a regenerated gate never detects its own
            default. A file counts when its ``pull_request`` or ``pull_request_target``
            trigger has a ``paths:`` or ``paths-ignore:`` key, or it uses
            ``dorny/paths-filter``.
            """
            workflows = repo_root / ".github" / "workflows"
            if not workflows.is_dir():
                return None
            candidates = sorted(p for p in workflows.iterdir() if p.suffix in {".yml", ".yaml"} and p.is_file())
            for path in candidates:
                if path.name == "assess-gate.yml":
                    continue
                try:
                    text = path.read_text(encoding="utf-8", errors="replace")
                except OSError:
                    continue
                if _filters_pull_requests_by_path(text):
                    return path
            return None
        
        
        def render_ci_workflow(
            plugin_version: str,
            default_branch: str = "main",
            discovered_tools: list[str] | None = None,
            generated_date: str = "an /assess run",
            paths: list[str] | None = None,
            paths_ignore: list[str] | None = None,
        ) -> str:
            """Render the assess-gate workflow YAML as a string.
        
            Pure: no disk writes. ``discovered_tools`` are the binaries this run found
            (e.g. ``["lizard", "scc"]``); only the external ones get an install step.
            ``paths`` / ``paths-ignore`` become lists under ``on.pull_request``. Passing
            both raises ``ValueError``: GitHub rejects the two on one event, and the gate
            would then never run.
            """
            if paths and paths_ignore:
                raise ValueError("paths and paths_ignore cannot both be set on one pull_request trigger")
            template = Template(_TEMPLATE_PATH.read_text(encoding="utf-8"))
            return template.substitute(
                plugin_version=plugin_version,
                default_branch=default_branch,
                generated_date=generated_date,
                tool_steps=_render_tool_steps(discovered_tools or []),
                path_filters=_render_path_filters(paths, paths_ignore),
            )
        
        
        def emit_ci_workflow(
            repo_root: Path,
            discovered_tools: list[str],
            plugin_version: str,
            default_branch: str = "main",
            generated_date: str = "an /assess run",
            paths: list[str] | None = None,
            paths_ignore: list[str] | None = None,
        ) -> Path:
            """Write ``.github/workflows/assess-gate.yml`` with the discovered tools baked in.
        
            Returns the path written. Creates ``.github/workflows/`` if absent.
            """
            workflow = render_ci_workflow(
                plugin_version=plugin_version,
                default_branch=default_branch,
                discovered_tools=discovered_tools,
                generated_date=generated_date,
                paths=paths,
                paths_ignore=paths_ignore,
            )
            workflow_path = repo_root / ".github" / "workflows" / "assess-gate.yml"
            workflow_path.parent.mkdir(parents=True, exist_ok=True)
            workflow_path.write_text(workflow, encoding="utf-8")
            return workflow_path
        
      • config_drift.py 17.4 KB
        """Configuration drift: committed platform-config snapshots vs the live setting.
        
        Repositories commit copies of GitHub configuration - ruleset exports, classic
        branch-protection exports - and describe them as restorable. When the live
        setting moves and the snapshot does not, the snapshot is a lying map: restoring
        it silently reverts a deliberate change. This scan diffs each tracked snapshot
        against the live value read through ``gh`` and reports every differing
        parameter. It is a JSON diff; no judgement is involved.
        
        Snapshots (git-tracked only, paths relative to ``repo_root``):
        
        - **Ruleset**: a tracked JSON object with a ``name`` or ``id`` and a top-level
          ``rules`` array of ``{"type": ...}`` entries (the ``.github/rulesets/*.json``
          convention, or anywhere else). A file missing either is not a ruleset export
          and is skipped, never reported. Matched to a live ruleset by ``id``, else by
          ``name``.
        - **Classic branch protection**: a JSON file under ``.github/`` whose top-level
          object has any of ``required_status_checks``, ``enforce_admins``,
          ``required_pull_request_reviews``. The branch comes from the export's ``url``
          (``.../branches/<branch>/protection``), else the file stem.
        
        The diff is driven by the snapshot: every key it records is compared; keys only
        the live API returns (metadata, fields the export left out) are not drift.
        Ids, timestamps and links are never reported. Lists are sets, not sequences.
        A write-shape payload lists restricted users, teams and apps as plain names
        (``"users": ["octocat"]``) where the read returns objects; the objects are
        projected onto ``login`` / ``slug`` / ``name`` and both sides compare as names.
        A changed scalar list is one entry whose ``tracked`` is ``{"count", "removed",
        "sample"}`` and whose ``live`` is ``{"count", "added", "sample"}``: counts of the
        list and of the names that left or joined it, plus at most ``MAX_SAMPLE`` of
        those names, never the whole live list. Lists of objects pair items by identity
        (``login``, ``slug``, ``type``, ``context``, ``actor_type``/``actor_id``,
        ``name``; a user's or team's ``type`` is a discriminator, so ``login``/``slug`` win) in both
        directions, so an item added or dropped live is drift and a reorder is not. An
        item present on one side only - and a snapshot key the live response omits (the
        protection read drops ``required_pull_request_reviews`` once reviews are turned
        off) - is recorded as ``"present"`` / ``"absent"``, never as the live object, so
        live org configuration does not land in the committed wiki. The item's identity
        does travel in the ``key`` (``bypass_actors[Team:4821]``); that much is needed
        to say which item moved. Repository-level snapshots are matched only against
        repository-level rulesets (``includes_parents=false``). A missing live ruleset, an unprotected branch and a branch that no longer
        exists are drift entries, not outages.
        
        What is stored, then: scalar values of changed settings (booleans, counts,
        enforcement modes), the identity of a paired or one-sided list item in ``key``,
        and up to ``MAX_SAMPLE`` added names per changed scalar list. Never stored: a
        live object, a whole live list, or more than ``MAX_ENTRIES`` entries.
        
        Block: ``{"available", "entries": [{"file", "key", "tracked", "live"}],
        "dropped", "snapshots"}``. Entries are ranked worst first: one-sided
        (``"absent"``) entries, then boolean flips, then other value changes; only the
        first ``MAX_ENTRIES`` are kept and ``dropped`` counts the rest. No snapshots, or
        none that differ, gives ``available: True`` with ``entries: []``. Any GitHub read that fails degrades the whole block to
        ``{"available": False, "reason"}`` (``no_access`` on HTTP 403) - never a
        partial clean result. GitHub access goes through ``gh_cli``.
        """
        from __future__ import annotations
        
        import json
        import re
        from collections import Counter
        from pathlib import Path
        from typing import Any
        from urllib.parse import quote
        
        from lib.gh_cli import GhUnavailable, gh_api, open_github, unavailable
        from lib.git_churn import tracked_files
        
        # Metadata the API adds or rewrites on every read; never configuration.
        IGNORED_KEYS = frozenset({
            "id", "node_id", "created_at", "updated_at",
            "url", "html_url", "contexts_url", "_links", "links",
            "source", "source_type", "current_user_can_bypass",
        })
        
        PROTECTION_KEYS = ("required_status_checks", "enforce_admins", "required_pull_request_reviews")
        
        # A snapshot bigger than this is not a hand-kept config export.
        MAX_SNAPSHOT_BYTES = 1_000_000
        
        # Entries kept in the block after ranking; the rest are counted in ``dropped``.
        MAX_ENTRIES = 10
        # Names kept per side of a scalar-list change; the rest are counted only.
        MAX_SAMPLE = 3
        
        # The field a read-shape object carries that a write-shape payload lists as a
        # plain string: users by ``login``, teams and apps by ``slug``.
        _NAME_FIELDS = ("login", "slug", "name")
        
        _BRANCH_FROM_URL = re.compile(r"/branches/(?P<branch>.+)/protection/?$")
        
        
        # A snapshot must name one of these keys; a file whose text holds none of them
        # is skipped without a JSON parse, so a repo with many tracked JSON files pays
        # a substring probe per file, not a parse.
        _PROBE_KEYS = ('"rules"', '"required_status_checks"', '"enforce_admins"',
                       '"required_pull_request_reviews"')
        
        
        def _load_json(path: Path) -> Any:
            try:
                if path.stat().st_size > MAX_SNAPSHOT_BYTES:
                    return None
                text = path.read_text(encoding="utf-8")
                if not any(k in text for k in _PROBE_KEYS):
                    return None
                return json.loads(text)
            except (OSError, UnicodeDecodeError, json.JSONDecodeError):
                return None
        
        
        def _is_ruleset(doc: Any) -> bool:
            if not isinstance(doc, dict) or not (doc.get("name") or doc.get("id") is not None):
                return False
            rules = doc.get("rules")
            return (
                isinstance(rules, list) and bool(rules)
                and all(isinstance(r, dict) and "type" in r for r in rules)
            )
        
        
        def _is_protection(doc: Any) -> bool:
            return isinstance(doc, dict) and any(k in doc for k in PROTECTION_KEYS)
        
        
        def find_snapshots(repo_root: Path) -> list[dict[str, Any]]:
            """Tracked snapshots under ``repo_root``, sorted by path.
        
            Each item: ``{"file", "kind": "ruleset"|"branch_protection", "doc"}``,
            plus ``"branch"`` for a protection export.
            """
            root = repo_root.resolve()
            tracked = tracked_files(root)
            if not tracked:
                return []
            out: list[dict[str, Any]] = []
            for path in sorted(tracked):
                if path.suffix != ".json":
                    continue
                try:
                    rel = path.relative_to(root).as_posix()
                except ValueError:
                    continue
                under_github = rel.startswith(".github/")
                doc = _load_json(path)
                if doc is None:
                    continue
                if _is_ruleset(doc):
                    out.append({"file": rel, "kind": "ruleset", "doc": doc})
                elif under_github and _is_protection(doc):
                    m = _BRANCH_FROM_URL.search(str(doc.get("url") or ""))
                    branch = m.group("branch") if m else path.stem
                    out.append({"file": rel, "kind": "branch_protection", "doc": doc, "branch": branch})
            return out
        
        
        def _normalize(value: Any) -> Any:
            """Collapse the ``{"url", "enabled": X}`` read shape to ``X`` so an export
            in the write shape (``"enforce_admins": true``) compares equal."""
            if isinstance(value, dict):
                rest = {k: v for k, v in value.items() if k not in IGNORED_KEYS}
                if set(rest) == {"enabled"}:
                    return rest["enabled"]
            return value
        
        
        def diff_values(tracked: Any, live: Any, key: str = "") -> list[tuple[str, Any, Any]]:
            """``(key_path, tracked, live)`` for every snapshot-recorded value that differs."""
            tracked, live = _normalize(tracked), _normalize(live)
            if isinstance(tracked, dict) and isinstance(live, dict):
                out: list[tuple[str, Any, Any]] = []
                for k in tracked:
                    if k in IGNORED_KEYS:
                        continue
                    sub = f"{key}.{k}" if key else k
                    if k not in live:
                        # The live response omits the key entirely (e.g. a requirement
                        # switched off): removed, not "compared against null".
                        was = _normalize(tracked[k])
                        if was is None:
                            # A write-shape export disables a block with null; the live
                            # read omits it. Same state - not drift.
                            continue
                        gone = "present" if isinstance(was, (dict, list)) else was
                        out.append((sub, gone, "absent"))
                        continue
                    out += diff_values(tracked[k], live[k], sub)
                return out
            if isinstance(tracked, list) and isinstance(live, list):
                return _diff_lists(tracked, live, key)
            if tracked == live:
                return []
            # Scalar-vs-container mismatch (a PUT-shape export disables a block with
            # null; live has the block set): record presence, never the live object,
            # so live org configuration does not reach the committed wiki.
            return [(key, _presence(tracked), _presence(live))]
        
        
        def _presence(value: Any) -> Any:
            return "present" if isinstance(value, (dict, list)) else value
        
        
        def _identity(item: Any) -> str | None:
            """The name a list item is paired by, or None when it has none."""
            if not isinstance(item, dict):
                return None
            # login/slug first: users and teams carry `type` too ("User", "organization"),
            # but there it is a class discriminator shared by every item, not an identity.
            for fields in (("login",), ("slug",), ("type",), ("context",),
                           ("actor_type", "actor_id"), ("name",)):
                if all(item.get(f) is not None for f in fields):
                    return ":".join(str(item[f]) for f in fields)
            return None
        
        
        def _canonical(item: Any) -> str:
            return json.dumps(item, sort_keys=True, default=str)
        
        
        def _as_names(items: list) -> list | None:
            """Project read-shape objects onto the name a write-shape payload lists
            (``restrictions.users: ["octocat"]`` against ``[{"login": "octocat", ...}]``);
            None when an item carries none of the name fields."""
            out = []
            for item in items:
                name = next((item.get(f) for f in _NAME_FIELDS
                             if isinstance(item, dict) and isinstance(item.get(f), str)), None)
                if name is None:
                    return None
                out.append(name)
            return out
        
        
        def _scalar_list_change(tracked: list, live: list, key: str) -> list[tuple[str, Any, Any]]:
            """Scalar lists as multisets. A change is recorded as what was removed from
            and added to the tracked list - counts plus a sorted sample of at most
            ``MAX_SAMPLE`` names per side - never the whole live list."""
            t, lv = Counter(map(_canonical, tracked)), Counter(map(_canonical, live))
            if t == lv:
                return []
            removed = sorted((t - lv).elements())
            added = sorted((lv - t).elements())
            def sample(xs: list[str]) -> list[Any]:
                return [json.loads(x) for x in xs[:MAX_SAMPLE]]
        
            return [(key,
                     {"count": len(tracked), "removed": len(removed), "sample": sample(removed)},
                     {"count": len(live), "added": len(added), "sample": sample(added)})]
        
        
        def _is_scalar(x: Any) -> bool:
            return not isinstance(x, (dict, list))
        
        
        def _diff_lists(tracked: list, live: list, key: str) -> list[tuple[str, Any, Any]]:
            """Lists in GitHub configuration are sets: compare without regard to order."""
            if all(_is_scalar(x) for x in [*tracked, *live]):
                return _scalar_list_change(tracked, live, key)
            # Write shape (plain names) on one side, read shape (objects) on the other.
            t_names = tracked if all(isinstance(x, str) for x in tracked) else _as_names(tracked)
            l_names = live if all(isinstance(x, str) for x in live) else _as_names(live)
            mixed = (all(_is_scalar(x) for x in tracked) or all(_is_scalar(x) for x in live))
            if mixed and t_names is not None and l_names is not None:
                return _scalar_list_change(t_names, l_names, key)
            t_ids = [_identity(x) for x in tracked]
            l_ids = [_identity(x) for x in live]
            ids_usable = (
                None not in t_ids and None not in l_ids
                and len(set(t_ids)) == len(t_ids) and len(set(l_ids)) == len(l_ids)
            )
            if not ids_usable:
                # No identity to pair by: compare as a multiset of normalised items and
                # report only the counts, never the live objects themselves.
                t_set = sorted(_canonical(_strip(x)) for x in tracked)
                l_set = sorted(_canonical(_strip(x)) for x in live)
                if t_set == l_set:
                    return []
                return [(f"{key}.count", len(tracked), len(live))] if len(tracked) != len(live) \
                    else [(f"{key}.items", "differs", "differs")]
            by_t = dict(zip(t_ids, tracked))
            by_l = dict(zip(l_ids, live))
            out: list[tuple[str, Any, Any]] = []
            for ident, item in by_t.items():
                sub = f"{key}[{ident}]"
                if ident not in by_l:
                    out.append((sub, "present", "absent"))
                else:
                    out += diff_values(item, by_l[ident], sub)
            for ident in by_l:
                if ident not in by_t:
                    out.append((f"{key}[{ident}]", "absent", "present"))
            return out
        
        
        def _strip(value: Any) -> Any:
            """Drop ignored metadata keys at every depth, for multiset comparison."""
            value = _normalize(value)
            if isinstance(value, dict):
                return {k: _strip(v) for k, v in value.items() if k not in IGNORED_KEYS}
            if isinstance(value, list):
                return [_strip(v) for v in value]
            return value
        
        
        def _live_ruleset(slug: str, doc: dict, summaries: list) -> dict | None:
            tid, tname = doc.get("id"), doc.get("name")
            match = next((s for s in summaries if tid is not None and s.get("id") == tid), None)
            if match is None:
                match = next((s for s in summaries if tname and s.get("name") == tname), None)
            if match is None or match.get("id") is None:
                return None
            detail = gh_api(f"repos/{slug}/rulesets/{match['id']}")
            return detail if isinstance(detail, dict) else None
        
        
        # A (key, tracked, live) drift entry for a snapshot with nothing live to diff.
        Missing = tuple[str, Any, Any]
        
        
        def _live_protection(slug: str, branch: str) -> dict | Missing:
            try:
                live = gh_api(f"repos/{slug}/branches/{quote(branch, safe='')}/protection")
            except GhUnavailable as e:
                # GitHub answers 404 "Branch not protected" for an unprotected branch;
                # the snapshot says it is protected, so that is drift, not an outage.
                reason = e.reason.lower()
                if reason.startswith("not_found") and "not protected" in reason:
                    return ("branch_protection", "present", "absent")
                # A deleted or renamed branch: the snapshot protects a branch that is
                # not there - drift for this snapshot, not an outage for the scan.
                if reason.startswith("not_found") and "branch not found" in reason:
                    return ("branch", branch, "absent")
                raise
            return live if isinstance(live, dict) else ("branch_protection", "present", "absent")
        
        
        def scan_config_drift(repo_root: Path) -> dict[str, Any]:
            """Build the ``config_drift`` run-context block (see module docstring)."""
            snapshots = find_snapshots(repo_root)
            listed = [{"file": s["file"], "kind": s["kind"]} for s in snapshots]
            if not snapshots:
                return {"available": True, "entries": [], "dropped": 0, "snapshots": []}
            try:
                repo = open_github(repo_root)
                summaries: list | None = None
                entries: list[dict[str, Any]] = []
                for snap in snapshots:
                    if snap["kind"] == "ruleset":
                        if summaries is None:
                            # Repository-level rulesets only: an inherited org ruleset
                            # is not what a repo snapshot mirrors, and its id does not
                            # resolve on the repo-scoped detail endpoint. 100 is the
                            # API's page maximum and a deliberate ceiling - a repo with
                            # more rulesets of its own than that is out of scope.
                            got = gh_api(
                                f"repos/{repo.slug}/rulesets?per_page=100&includes_parents=false"
                            )
                            summaries = [s for s in got if isinstance(s, dict)] if isinstance(got, list) else []
                        found = _live_ruleset(repo.slug, snap["doc"], summaries)
                        name = snap["doc"].get("name") or snap["doc"].get("id")
                        live: dict | Missing = ("ruleset", name, "absent") if found is None else found
                    else:
                        live = _live_protection(repo.slug, snap["branch"])
                    diffs = [live] if isinstance(live, tuple) else diff_values(snap["doc"], live)
                    entries += [
                        {"file": snap["file"], "key": k, "tracked": t, "live": lv}
                        for k, t, lv in diffs
                    ]
            except GhUnavailable as e:
                return {**unavailable(e.reason), "snapshots": listed}
            ranked = rank_entries(entries)
            return {"available": True, "entries": ranked[:MAX_ENTRIES],
                    "dropped": max(0, len(ranked) - MAX_ENTRIES), "snapshots": listed}
        
        
        def _severity(entry: dict[str, Any]) -> int:
            """0 = something exists on one side only (a rule, requirement or branch gone
            or added); 1 = a boolean flipped; 2 = any other value change."""
            if "absent" in (entry["tracked"], entry["live"]):
                return 0
            if isinstance(entry["tracked"], bool) or isinstance(entry["live"], bool):
                return 1
            return 2
        
        
        def rank_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
            """Worst first, stable within a tier (path, then snapshot key order).
        
            The report renders only ``entries[0]``, so the order decides which drift a
            reader sees - it must not be the export file's serialisation order.
            """
            return sorted(entries, key=_severity)
        
      • coupling_analysis.py 10.7 KB
        """B3 static-vs-historical disagreement: the A x B cross for the /assess core.
        
        The static lens (``lib.structure_graph``, A2/A3) reads the import graph and asks
        "does this *look* modular?"; the historical lens (``lib.change_coupling``, B1/B2)
        reads the commit log and asks "does it *behave* modularly -- do edits stay
        contained?". Each is blind to what the other sees. Where they **disagree** is
        the most valuable output of the whole assessment (PRD Signal B3 + the derived-
        findings table):
        
          - **Hidden coupling** -- a module that looks modular statically (high
            modularity / a clean front door) yet bleeds historically (low containment:
            its commits keep dragging in files elsewhere). The static boundary is lying;
            recommend *investigating the seam* before trusting it.
          - **Bleeding module** -- the v1 graceful fallback. When no static graph is
            available (a non-Python repo, or grimp/networkx absent) we have only the
            historical lens, so a low-containment module is flagged on history alone
            rather than cross-checked against a (missing) static boundary.
          - **Looks-coupled-but-never-co-changes** -- the inverse disagreement. The
            static graph says "coupled" but history shows the edits stay contained. This
            is fine in practice, so it is **suppressed** (``finding=None``): the static
            graph already surfaces the coupling via its SCCs / burrow edges, and there
            is no behavioural bleed to act on.
          - **Refactor boundary** *(the one positive finding)* -- high containment plus
            low external coupling: empirically, edits here stay put. This is the direct
            yes to the assessment's core question -- *can an agent safely change this
            area through a keyhole?* -- so these are surfaced as safe zones.
        
        This module is a pure function of its inputs (containment ratios + an optional
        per-directory static-modularity view); it runs no git or grimp itself. That
        keeps it cheap to test (mock the two inputs) and lets task #5 wire the real
        ``containment_by_dir`` / ``change_coupling_pairs`` / ``analyze_structure``
        outputs into the run-context ``behaviour`` block. All returned structures are
        JSON-serialisable (paths as strings, plain dict/list/number/None).
        
        **Static-modularity input shape.** ``static_modularity`` maps a directory path
        (matching the keys of ``containment_by_dir``) to a metrics dict
        ``{'modularity_q': float | None, 'front_door_ratio': float | None}``. Because
        ``lib.structure_graph`` currently emits repo-level ``modularity_q`` /
        ``front_door_ratio``, the caller (task #5) is responsible for projecting those
        onto the directories it cares about; this module only consumes the per-directory
        view. ``None`` for the whole argument means "no static graph at all" -> the
        graceful historical-only path. A directory absent from an otherwise-present dict
        is treated the same way per-directory (no static evidence for *that* dir).
        """
        from __future__ import annotations
        
        # Containment below this fraction means a module bleeds: most of its commits
        # drag in files outside it. The PRD leaves the exact island threshold open for
        # calibration (Open Question: "what containment ratio counts as an island?");
        # 0.3 / 0.7 are the v1 defaults and are overridable per call.
        DEFAULT_LOW_CONTAINMENT = 0.3
        DEFAULT_HIGH_CONTAINMENT = 0.7
        
        # A directory "looks modular" statically when EITHER its modularity is high
        # (cohesive clusters, sparse cross-talk -- A2) OR most inbound cross-package
        # edges hit its front door rather than burrowing into internals (a real
        # contract -- A3). Either clean-boundary signal is enough to make the static map
        # *claim* modularity; when history then contradicts it, that claim is the lie
        # worth investigating. OR (not AND) is deliberate: it makes hidden-coupling
        # detection more sensitive, erring toward surfacing a seam for human review.
        DEFAULT_HIGH_MODULARITY_Q = 0.3
        DEFAULT_HIGH_FRONT_DOOR_RATIO = 0.7
        
        
        def _looks_modular(
            metrics: dict | None,
            high_modularity_q: float,
            high_front_door_ratio: float,
        ) -> bool:
            """True if a directory's static metrics present a clean (modular) boundary.
        
            ``metrics`` is ``{'modularity_q': float | None, 'front_door_ratio': float |
            None}`` (or ``None`` when there is no static evidence for the directory).
            Modular = high modularity OR high front-door ratio; ``None`` sub-metrics are
            simply skipped, so a dict carrying only one of the two still works.
            """
            if not metrics:
                return False
            q = metrics.get("modularity_q")
            fd = metrics.get("front_door_ratio")
            if q is not None and q >= high_modularity_q:
                return True
            if fd is not None and fd >= high_front_door_ratio:
                return True
            return False
        
        
        def detect_hidden_coupling(
            containment_by_dir: dict[str, float],
            static_modularity: dict | None = None,
            threshold_low_containment: float = DEFAULT_LOW_CONTAINMENT,
            high_modularity_q: float = DEFAULT_HIGH_MODULARITY_Q,
            high_front_door_ratio: float = DEFAULT_HIGH_FRONT_DOOR_RATIO,
        ) -> list[dict]:
            """B3: cross static modularity with historical containment to find lying boundaries.
        
            For each directory in ``containment_by_dir`` whose containment is **below**
            ``threshold_low_containment`` (it bleeds historically), classify the bleed
            against the static lens:
        
              - static graph present *and* the directory looks modular -> ``'hidden_coupling'``
                (the static boundary is lying; recommend investigating the seam),
              - no static graph at all (``static_modularity is None``) or no static
                evidence for this directory -> ``'bleeding_module'`` (historical-only
                fallback),
              - static graph present *and* the directory also looks coupled -> ``None``
                (static and history agree it is coupled; not *hidden*, and already
                visible in the static SCC / burrow-edge output -- nothing new to flag).
        
            Directories that do **not** bleed (containment at or above the threshold) are
            not the concern of this function -- the inverse "looks-coupled-but-never-co-
            changes" case is suppressed here and the positive case is handled by
            :func:`find_refactor_boundaries` -- so they are omitted from the result.
        
            Returns a list of ``{'path', 'containment_ratio', 'finding', 'recommendation'}``
            sorted by containment ascending (worst bleed first), then path. ``finding``
            is ``'hidden_coupling'``, ``'bleeding_module'`` or ``None`` (suppressed but
            reported, so a caller can see the directory was evaluated and consciously
            left alone).
            """
            results: list[dict] = []
            for path in sorted(containment_by_dir):
                containment = containment_by_dir[path]
                if containment >= threshold_low_containment:
                    continue  # does not bleed: not this function's concern
        
                metrics = static_modularity.get(path) if static_modularity is not None else None
                has_static_evidence = static_modularity is not None and metrics is not None
        
                if has_static_evidence:
                    if _looks_modular(metrics, high_modularity_q, high_front_door_ratio):
                        finding: str | None = "hidden_coupling"
                        recommendation = (
                            "investigate the seam - the static boundary looks modular but "
                            "its commits bleed outside it; the boundary is lying"
                        )
                    else:
                        # Static and history agree this is coupled. Not hidden, and the
                        # static SCC / burrow-edge output already surfaces it.
                        finding = None
                        recommendation = (
                            "static and historical lenses agree this is coupled; already "
                            "visible in the static structure output, nothing hidden to flag"
                        )
                else:
                    finding = "bleeding_module"
                    recommendation = (
                        "edits here bleed outside the directory (low containment); no "
                        "static import graph available to cross-check the boundary"
                    )
        
                results.append(
                    {
                        "path": path,
                        "containment_ratio": containment,
                        "finding": finding,
                        "recommendation": recommendation,
                    }
                )
        
            results.sort(key=lambda d: (d["containment_ratio"], d["path"]))
            return results
        
        
        def find_refactor_boundaries(
            containment_by_dir: dict[str, float],
            threshold_high_containment: float = DEFAULT_HIGH_CONTAINMENT,
            static_modularity: dict | None = None,
            high_modularity_q: float = DEFAULT_HIGH_MODULARITY_Q,
            high_front_door_ratio: float = DEFAULT_HIGH_FRONT_DOOR_RATIO,
        ) -> list[dict]:
            """B3 positive finding: directories an agent can safely refactor in isolation.
        
            A directory whose containment is **above** ``threshold_high_containment`` is
            a refactor boundary: empirically its edits stay put, so it answers the
            assessment's core question -- *can an agent safely change this through a
            keyhole?* -- with a yes. Containment (B2) is itself the direct
            low-external-coupling signal, so high containment alone qualifies a boundary
            even with no static graph (the v1 graceful path).
        
            ``static_modularity``, when present, only *enriches* the recommendation: a
            boundary that also looks modular statically gets a stronger "static and
            historical lenses agree" note, while one that looks coupled statically still
            qualifies (it never co-changes in practice -- the benign inverse-disagreement
            case) but is noted as historically-backed only. It never disqualifies a
            boundary, because lived behaviour (containment) outranks the static guess.
        
            Returns ``{'path', 'containment_ratio', 'finding': 'refactor_boundary',
            'recommendation'}`` entries, sorted by containment descending (safest first),
            then path.
            """
            results: list[dict] = []
            for path in sorted(containment_by_dir):
                containment = containment_by_dir[path]
                if containment <= threshold_high_containment:
                    continue
        
                metrics = static_modularity.get(path) if static_modularity is not None else None
                if metrics is not None and _looks_modular(
                    metrics, high_modularity_q, high_front_door_ratio
                ):
                    recommendation = (
                        "safe to hand an agent in isolation - high containment and a "
                        "clean static boundary agree this area does not bleed"
                    )
                else:
                    recommendation = (
                        "safe to hand an agent in isolation - edits here stay contained "
                        "(high containment)"
                    )
        
                results.append(
                    {
                        "path": path,
                        "containment_ratio": containment,
                        "finding": "refactor_boundary",
                        "recommendation": recommendation,
                    }
                )
        
            results.sort(key=lambda d: (-d["containment_ratio"], d["path"]))
            return results
        
      • coverage_report.py 8.6 KB
        """Parse an *existing* coverage report into the shape ``scan_test_pressure``
        already consumes - no coverage run, no third-party library, read-only.
        
        `/assess` never runs the test suite (it stays read-only and fast), so the only
        honest source of line-coverage truth is a report the project already generated
        in CI or locally. This module reads two ubiquitous formats - Cobertura
        ``coverage.xml`` and ``lcov.info`` - and reduces each to the documented shape the
        test-pressure scan's ``coverage_data=`` param expects:
        
            {"_overall": float, "per_file": {relpath: line_rate}}
        
        ``_overall`` feeds the Layer 1 coverage-vs-mutation gap signal; ``per_file`` is
        the per-file line ratio. Both are *informational* truth pulled from the
        project's own tooling, never a gate.
        
        Honest degradation is the hard contract here: a missing report, a malformed one,
        or an unreadable file degrades to ``None`` - never an exception, never a block to
        the assessment. The provenance distinction (a real read vs. "none found") is
        recorded by the orchestrator from ``detect_coverage_report`` so the report can
        state which it was.
        
        A bare ``.coverage`` SQLite *file* is deliberately out of scope: reading it needs
        the ``coverage.py`` library (a runtime dependency the deterministic core does not
        take), so it degrades as if absent.
        
        Inward-only imports: this module imports stdlib only and is imported by the
        orchestrator (`assess_core.py`); it never imports an orchestrator itself.
        """
        from __future__ import annotations
        
        import xml.etree.ElementTree as ET
        from pathlib import Path
        from typing import Any
        
        # Cobertura first, then lcov; repo root, then the common report sub-directories.
        # A ``.coverage`` directory is searched for nested reports; a ``.coverage`` SQLite
        # *file* never matches (``is_file`` on a path inside it fails) - out of scope by
        # construction, no special-case needed.
        _CANDIDATES: tuple[tuple[str, str], ...] = (
            ("coverage.xml", "cobertura"),
            ("coverage/coverage.xml", "cobertura"),
            (".coverage/coverage.xml", "cobertura"),
            ("lcov.info", "lcov"),
            ("coverage/lcov.info", "lcov"),
            (".coverage/lcov.info", "lcov"),
        )
        
        
        def _parse_cobertura(path: Path) -> dict[str, Any] | None:
            """Parse a Cobertura ``coverage.xml`` into ``{_overall, per_file}``.
        
            ``_overall`` is the root ``line-rate``; ``per_file`` maps each ``<class>``
            element's ``filename`` to its ``line-rate``. ``root.iter("class")`` walks the
            whole tree, so both the flat (``<coverage><classes><class>``) and nested
            (``<coverage><packages><package><classes><class>``) schemas are handled by
            the same pass. Returns ``None`` on any parse/read error or empty report.
            """
            try:
                root = ET.parse(str(path)).getroot()
            except (ET.ParseError, OSError, ValueError):
                return None
            try:
                rate = root.get("line-rate")
                overall: float | None = float(rate) if rate is not None else None
        
                per_file: dict[str, float] = {}
                for cls in root.iter("class"):
                    filename = cls.get("filename")
                    cls_rate = cls.get("line-rate")
                    if filename is None or cls_rate is None:
                        continue
                    try:
                        per_file[filename] = float(cls_rate)
                    except (TypeError, ValueError):
                        continue
        
                if overall is None and not per_file:
                    return None
                return {"_overall": overall, "per_file": per_file}
            except (TypeError, ValueError):  # pragma: no cover - defensive
                return None
        
        
        def _normalise_lcov_path(raw: str, root: Path | None) -> str:
            """Map an lcov ``SF:`` path to the repo-relative POSIX key ``per_file`` uses.
        
            lcov records whatever path the runner emitted: absolute (Jest, c8, Vitest
            usually) or ``./``-prefixed, with backslashes when a Windows runner wrote
            it. A raw key never matches the repo-relative path the rest of the core
            looks files up by (#317), so: an absolute path under
            ``root`` becomes relative to it (both sides resolved, so macOS ``/var`` vs
            ``/private/var`` still matches); a leading ``./`` is stripped. An absolute
            path outside ``root``, or any path when ``root`` is None, keeps its spelling;
            a relative path has backslashes converted to ``/`` and a ``.\\`` prefix
            stripped the same way.
            """
            path = raw
            if root is not None and Path(raw).is_absolute():
                try:
                    return Path(raw).resolve().relative_to(root).as_posix()
                except (OSError, ValueError):
                    return raw
            # A Windows runner spells a relative path with backslashes (``.\\src\\a.ts``
            # or ``src\\a.ts``); the repo-relative key is always POSIX.
            if "\\" in path and not _is_windows_absolute(path):
                path = path.replace("\\", "/")
            while path.startswith("./"):
                path = path[2:]
            return path
        
        
        def _is_windows_absolute(path: str) -> bool:
            """True for a drive-letter (``C:\\...``) or UNC (``\\\\host\\...``) path,
            which keeps its spelling like any other absolute path outside the root."""
            return path.startswith("\\\\") or (len(path) > 2 and path[1] == ":" and path[0].isalpha())
        
        
        def _parse_lcov(path: Path, repo_root: Path | str | None = None) -> dict[str, Any] | None:
            """Parse an ``lcov.info`` tracefile into ``{_overall, per_file}``.
        
            ``SF:`` paths are normalised to repo-relative POSIX keys against
            ``repo_root`` (see ``_normalise_lcov_path``); without a ``repo_root`` only
            a ``./`` prefix is stripped.
        
            Line-by-line: ``SF:`` opens a record (the source file), ``LF:`` is lines
            found, ``LH:`` is lines hit. Per-file ``line_rate = LH / LF``; overall is
            ``sum(LH) / sum(LF)`` across all records. A record is flushed on
            ``end_of_record``, on the next ``SF:``, or at end-of-file, so a tracefile
            that omits the terminator still parses. Returns ``None`` on read error or an
            empty / zero-line report.
            """
            try:
                text = Path(path).read_text(encoding="utf-8", errors="replace")
            except (OSError, ValueError):
                return None
        
            try:
                root = Path(repo_root).resolve() if repo_root is not None else None
            except (OSError, ValueError):  # pragma: no cover - defensive
                root = None
        
            per_file: dict[str, float] = {}
            total_lf = 0
            total_lh = 0
            current: str | None = None
            lf = 0
            lh = 0
        
            def flush() -> None:
                nonlocal total_lf, total_lh
                if current is not None and lf > 0:
                    per_file[current] = lh / lf
                    total_lf += lf
                    total_lh += lh
        
            try:
                for raw in text.splitlines():
                    line = raw.strip()
                    if line.startswith("SF:"):
                        flush()
                        current = _normalise_lcov_path(line[3:].strip(), root)
                        lf = lh = 0
                    elif line.startswith("LF:"):
                        lf = int(line[3:].strip())
                    elif line.startswith("LH:"):
                        lh = int(line[3:].strip())
                    elif line == "end_of_record":
                        flush()
                        current = None
                        lf = lh = 0
                flush()
            except (TypeError, ValueError):
                return None
        
            if not per_file or total_lf == 0:
                return None
            return {"_overall": total_lh / total_lf, "per_file": per_file}
        
        
        def detect_coverage_report(repo_root: Path | str) -> dict[str, str] | None:
            """Locate a coverage report at the repo root or a common sub-directory.
        
            Searches ``coverage.xml`` (Cobertura) and ``lcov.info`` at the repo root,
            ``./coverage/``, and ``./.coverage/`` (as a directory). Returns
            ``{"source": <relpath>, "format": "cobertura"|"lcov"}`` for the first match,
            or ``None`` if none is found. A ``.coverage`` SQLite file is never matched -
            it is out of scope (see module docstring).
            """
            root = Path(repo_root)
            for rel, fmt in _CANDIDATES:
                if (root / rel).is_file():
                    return {"source": rel, "format": fmt}
            return None
        
        
        def load_coverage_data(repo_root: Path | str) -> dict[str, Any] | None:
            """Detect, then parse, a coverage report under ``repo_root``.
        
            Returns the documented ``{_overall, per_file}`` shape, or ``None`` when no
            report is found or the report cannot be parsed. Never raises - this is the
            read-only entry point the orchestrator wires before ``scan_test_pressure``.
            """
            try:
                detected = detect_coverage_report(repo_root)
                if detected is None:
                    return None
                path = Path(repo_root) / detected["source"]
                if detected["format"] == "cobertura":
                    return _parse_cobertura(path)
                if detected["format"] == "lcov":
                    return _parse_lcov(path, repo_root)
                return None
            except Exception:  # noqa: BLE001 - never raise into the assessment
                return None
        
      • dart_capabilities.py 8.7 KB
        """Dart capability entries for the detect-or-propose flow (issue #352).
        
        The JVM flow (``jvm_capabilities.py``) proved the capability-driven model on
        one ecosystem. This module applies it to Dart and Flutter for two capabilities,
        using the same entry fields (``state``, ``candidate_tool``, ``gloss``, ``note``,
        and ``served_by`` when credited) so the scorer reads both the same way:
        
          * ``linting``  - ``credited`` when a package's nearest ``analysis_options.yaml``
                           (the package directory or the closest ancestor, the analyzer's
                           own lookup) enables lint rules, through a top-level ``include:``
                           or a ``linter: rules:`` section, served by ``dart analyze`` or
                           ``flutter analyze``; ``honest_degrade`` naming ``dart analyze``
                           otherwise. Dart lints are opt-in, so a file that only sets
                           ``analyzer: exclude:`` credits nothing.
          * ``liveness`` - always ``honest_degrade``. The candidate is the analyzer's
                           built-in ``unused_*`` diagnostics. The scan neither runs the
                           analyzer nor reads its output, so it credits nothing and feeds
                           no candidates into ``dead_code``. No third-party package is
                           named: the one commonly suggested, ``dart_code_metrics``, is
                           discontinued for Dart 3.
        
        A repository is Dart when it holds a ``pubspec.yaml`` outside the shared and
        user-supplied excludes. The result surfaces in ``run-context.json`` under
        ``language_capabilities.dart``, a sibling of the JVM-only ``capability_offers``.
        """
        from __future__ import annotations
        
        import os
        import re
        from pathlib import Path
        from typing import Any
        
        from lib.doc_graph import is_excluded_path
        
        _LINTING_CANDIDATE = "dart analyze / flutter analyze"
        LIVENESS_CANDIDATE = "dart analyze (unused_* lints)"
        
        _CAPABILITY_GLOSS = {
            "linting": "style and bug-pattern static analysis",
            "liveness": "unused private declarations, imports and locals",
        }
        
        # Top-level keys that enable lint rules: ``include:`` pulls in a rule set such as
        # package:lints; ``linter:`` followed by an indented ``rules:`` lists rules
        # directly. ``analyzer: errors:`` only changes the severity of enabled rules.
        _INCLUDE_RE = re.compile(r"^include\s*:\s*\S", re.MULTILINE)
        _LINTER_KEY_RE = re.compile(r"linter\s*:")
        _RULES_KEY_RE = re.compile(r"rules\s*:")
        _COMMENT_RE = re.compile(r"(?m)^\s*#.*$|\s+#.*$")
        
        # What the liveness candidate would provide, and what it would not. Shared by the
        # capability note and the dead_code.tools entry so the two never drift apart.
        LIVENESS_REASON = (
            "Dart liveness is unserved: the scan does not run the Dart analyzer. Its "
            "built-in unused_* diagnostics (unused_element, unused_field, unused_import, "
            "unused_local_variable) would report unused private declarations, imports "
            "and locals; a public member no code calls is not reported."
        )
        
        
        def _read(path: Path) -> str:
            try:
                return path.read_text(encoding="utf-8", errors="ignore")
            except OSError:
                return ""
        
        
        def _scan_dart_tree(repo_root: Path,
                            extra_exclude_dirs: set[str] | None = None,
                            extra_exclude_patterns: list[str] | None = None,
                            ) -> tuple[list[str], dict[Path, bool]]:
            """One walk returning ``(pubspec_files, options)``, where ``options`` maps
            each directory holding an ``analysis_options.yaml`` to whether that file
            enables lint rules. Paths are relative to ``repo_root``, outside the
            excludes."""
            from lib.assess_config import is_user_excluded
            extra_dirs = extra_exclude_dirs or set()
            extra_pats = extra_exclude_patterns or []
            pubspecs: list[str] = []
            options: dict[Path, bool] = {}
            for dirpath, dirnames, filenames in os.walk(repo_root):
                rel_dir = Path(dirpath).relative_to(repo_root)
                dirnames[:] = [
                    d for d in dirnames
                    if not is_excluded_path(rel_dir / d)
                    and not is_user_excluded(rel_dir / d, extra_dirs, [])
                ]
                for name in ("pubspec.yaml", "analysis_options.yaml"):
                    if name not in filenames:
                        continue
                    rel = rel_dir / name
                    if is_excluded_path(rel) or is_user_excluded(rel, extra_dirs, extra_pats):
                        continue
                    if name == "pubspec.yaml":
                        pubspecs.append(rel.as_posix())
                    else:
                        options[rel_dir] = _enables_lint_rules(_read(repo_root / rel))
            return sorted(pubspecs), options
        
        
        def _enables_lint_rules(text: str) -> bool:
            """True when an ``analysis_options.yaml`` text enables lint rules."""
            text = _COMMENT_RE.sub("", text)
            return bool(_INCLUDE_RE.search(text)) or _linter_has_rules(text)
        
        
        def _linter_has_rules(text: str) -> bool:
            """True when a top-level ``linter:`` block holds an indented ``rules:`` key.
        
            A line scan, not a regex: one pass, no backtracking, so an arbitrary file
            cannot stall the walk. Blank lines stay inside the block; the next
            unindented line ends it."""
            in_linter = False
            for line in text.splitlines():
                if not line.strip():
                    continue
                if line[0] not in " \t":
                    in_linter = _LINTER_KEY_RE.match(line) is not None
                elif in_linter and _RULES_KEY_RE.match(line.lstrip()):
                    return True
            return False
        
        
        def _configured_packages(pubspecs: list[str], options: dict[Path, bool]) -> list[str]:
            """Pubspecs whose nearest ``analysis_options.yaml`` (the package directory,
            else the closest ancestor within the repository) enables lint rules. The
            analyzer reads only the nearest file, so a nearer file that enables nothing
            shadows an ancestor that does."""
            configured = []
            for rel in pubspecs:
                pkg = Path(rel).parent
                nearest = next((d for d in (pkg, *pkg.parents) if d in options), None)
                if nearest is not None and options[nearest]:
                    configured.append(rel)
            return configured
        
        
        def _analyzer_command(repo_root: Path, pubspecs: list[str]) -> str:
            """``flutter analyze`` when a package depends on the Flutter SDK, else
            ``dart analyze``."""
            for rel in pubspecs:
                if "sdk: flutter" in _read(repo_root / rel):
                    return "flutter analyze"
            return "dart analyze"
        
        
        def _linting(repo_root: Path, pubspecs: list[str], configured: list[str]) -> dict:
            cap: dict[str, Any] = {
                "candidate_tool": _LINTING_CANDIDATE,
                "gloss": _CAPABILITY_GLOSS["linting"],
            }
            if configured:
                cap.update({
                    "state": "credited",
                    "served_by": [_analyzer_command(repo_root, configured)],
                    "note": (f"analysis_options.yaml enables lint rules for "
                             f"{len(configured)} of {len(pubspecs)} Dart package(s); "
                             "`dart analyze` / `flutter analyze` apply it. Detected and "
                             "credited, not re-offered. The file's presence is what is "
                             "detected, not whether CI runs the analyzer."),
                })
            else:
                cap.update({
                    "state": "honest_degrade",
                    "note": ("No package's nearest analysis_options.yaml enables lint "
                             "rules (the file is absent, or enables no lint rules). "
                             "`dart analyze` (or `flutter analyze`) then reports errors "
                             "and default warnings only; an `include:` of package:lints "
                             "or package:flutter_lints, or a `linter: rules:` list, would "
                             "turn lints on."),
                })
            return cap
        
        
        def _liveness() -> dict:
            return {
                "state": "honest_degrade",
                "candidate_tool": LIVENESS_CANDIDATE,
                "gloss": _CAPABILITY_GLOSS["liveness"],
                "note": LIVENESS_REASON,
            }
        
        
        def scan_dart_capabilities(repo_root: Path, *,
                                   extra_exclude_dirs: set[str] | None = None,
                                   extra_exclude_patterns: list[str] | None = None,
                                   ) -> dict:
            """Capability-driven Dart scan. Read-only: runs no tool.
        
            Returns ``{"available": False, "pubspec_files": []}`` for a repository with
            no in-scope ``pubspec.yaml``, else ``available``, ``pubspec_files`` and a
            ``capabilities`` dict with ``linting`` and ``liveness`` entries.
            """
            repo_root = repo_root.resolve()
            pubspecs, options = _scan_dart_tree(
                repo_root,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
            )
            if not pubspecs:
                return {"available": False, "pubspec_files": []}
            configured = _configured_packages(pubspecs, options)
            return {
                "available": True,
                "pubspec_files": pubspecs,
                "capabilities": {
                    "linting": _linting(repo_root, pubspecs, configured),
                    "liveness": _liveness(),
                },
            }
        
      • dart_complexity.py 12.3 KB
        """Approximate per-function cyclomatic complexity for Dart (issue #364).
        
        lizard 1.23.0 has no Dart reader, so scc scores Dart at file level only and a
        Dart hotspot carries no worst-function figure. This module fills that gap with
        a brace-and-keyword scanner: no parser and no new dependency, which is why the
        treemap registers it as the ``dart-scanner`` backend with ``approximate: true``.
        
        What it counts, per function body: ``if``, ``for``, ``while``, ``case``,
        ``catch``, ``&&``, ``||``, ``??`` / ``??=`` and a ternary ``?`` (a ``?`` with
        whitespace before it, so ``int?`` and ``a?.b`` do not count). Complexity is one
        plus that count. ``else if`` counts once, through its ``if``.
        
        What it recognises as a function: a ``{`` or ``=>`` body after a parameter list
        whose ``(`` follows a plain identifier (``name(...)``, ``name<T>(...)``, with
        ``async`` / ``sync*`` allowed before the body), and a getter (``get name {`` or
        ``get name =>``). An anonymous closure (a parameter list after ``=``, ``(`` or
        ``,``) is folded into the function that encloses it, so its decision points
        count toward the parent. A closure (``{`` or ``=>`` body) outside any function, a constructor body
        after an initializer list (``: super(x) {``) and an ``operator`` overload are
        each scored on their own as ``<anonymous>``.
        
        Everything inside ``//`` and ``/* */`` comments (which nest in Dart) and inside
        single-, double-, triple-quoted and raw strings is skipped; ``${...}``
        interpolations are scanned as code. The scanner is one forward pass over at
        most ``_READ_BYTES`` of each file: every regex is a single character class or
        a literal alternation with no nested quantifier, so a pathological input
        (deep nesting, one very long line, an unterminated string or comment) costs
        time linear in its length and cannot backtrack.
        """
        from __future__ import annotations
        
        import re
        from pathlib import Path
        
        # Backend name recorded in the stats file (`fn_ccn.source`,
        # `fn_ccn.backend_by_language`). The treemap registers it as approximate.
        BACKEND_NAME = "dart-scanner"
        
        # Upper bound on bytes read per file, the same bound lib.generated_files uses.
        # A file past it is scored on its first megabyte.
        _READ_BYTES = 1024 * 1024
        
        _DECISION_WORDS = frozenset({"if", "for", "while", "case", "catch"})
        _DECISION_OPS = frozenset({"&&", "||", "??", "??="})
        # A parameter list after one of these heads opens a block, not a function.
        _NOT_A_NAME = frozenset({
            "if", "for", "while", "switch", "catch", "return", "super", "this",
            "assert", "await", "yield", "throw", "new", "const", "in", "is", "as",
        })
        # Heads whose parameter list is a condition, never a function's.
        _CONTROL_HEADS = frozenset({"if", "for", "while", "switch", "catch"})
        # Tokens allowed between a parameter list's `)` and its body.
        _BODY_MODIFIERS = frozenset({"async", "sync", "*"})
        
        _CODE_TOKEN = re.compile(
            r"(?P<ws>\s+)"
            r"|(?P<lc>//)"
            r"|(?P<bc>/\*)"
            r"|(?P<raw>r(?:'''|\"\"\"|'|\"))"
            r"|(?P<str>'''|\"\"\"|'|\")"
            r"|(?P<id>[A-Za-z_$][A-Za-z0-9_$]*)"
            r"|(?P<num>[0-9][0-9A-Za-z_]*)"
            r"|(?P<op>=>|&&|\|\||\?\?=|\?\?|\?\.|.)",
            re.DOTALL,
        )
        _BLOCK_COMMENT_EDGE = re.compile(r"/\*|\*/")
        
        
        def _string_end_pattern(quote: str, raw: bool) -> re.Pattern[str]:
            """What can end or interrupt a string opened by ``quote``."""
            parts = [re.escape(quote)]
            if len(quote) == 1:
                parts.append(r"\n")  # an unterminated one-line string ends at the line
            if not raw:
                parts += [r"\\", r"\$\{"]
            return re.compile("|".join(parts))
        
        
        _STRING_END = {
            (q, raw): _string_end_pattern(q, raw)
            for q in ("'", '"', "'''", '"""') for raw in (False, True)
        }
        
        
        class _Fn:
            __slots__ = ("name", "start", "count")
        
            def __init__(self, name: str, start: int) -> None:
                self.name = name
                self.start = start
                self.count = 0
        
        
        def scan_dart_functions(text: str) -> list[tuple[str, float]]:
            """Return ``[(name, ccn)]`` for every function in ``text``, in source order.
        
            ``ccn`` is one plus the function's decision points; see the module
            docstring for what counts and what is recognised as a function.
            """
            return _Scanner(text).run()
        
        
        class _Scanner:
            """One forward pass over a Dart source text; see ``scan_dart_functions``."""
        
            def __init__(self, text: str) -> None:
                self.text = text
                self.pos = 0
                self.done: list[_Fn] = []
                self.fns: list[_Fn] = []  # open functions, innermost last
                # One entry per open `{`: the function it opened, or None for a block.
                self.braces: list[_Fn | None] = []
                self.parens: list[str] = []  # the head token before each open `(`
                # Open named `=>` bodies: (function, brace depth, paren depth).
                self.arrows: list[tuple[_Fn, int, int]] = []
                # Open `${` interpolations: [string quote, raw flag, braces inside].
                self.interps: list[list] = []
                self.string: tuple[str, bool] | None = None  # (quote, raw) inside one
                self.prev = ""          # last significant code token
                self.prev_ws = False    # whitespace directly before the current token
                self.generic_name = ""  # identifier before the last `<` (name<T>(...))
                self.closed_head: str | None = None  # head of a just-closed `)`
                self.getter: str | None = None       # name after `get`, if pending
        
            def run(self) -> list[tuple[str, float]]:
                n = len(self.text)
                while self.pos < n:
                    if self.string is not None:
                        self._in_string()
                    else:
                        self._code_token()
                self.done.extend(self.fns)  # unterminated bodies (truncated input)
                self.done.sort(key=lambda f: f.start)
                return [(f.name, float(1 + f.count)) for f in self.done]
        
            # -- lexing ------------------------------------------------------------
        
            def _in_string(self) -> None:
                assert self.string is not None
                m = _STRING_END[self.string].search(self.text, self.pos)
                if m is None:
                    self.pos = len(self.text)
                    return
                self.pos = m.end()
                edge = m.group()
                if edge == "\\":
                    self.pos += 1  # skip the escaped character
                    return
                if edge == "${":
                    self.interps.append([*self.string, 0])
                # A closing quote, the newline ending a one-line string, or `${`.
                self.string = None
                self._reset("'")
        
            def _code_token(self) -> None:
                m = _CODE_TOKEN.match(self.text, self.pos)
                assert m is not None  # the op branch matches any single character
                self.pos = m.end()
                kind, tok = m.lastgroup, m.group()
                ws_before, self.prev_ws = self.prev_ws, kind in ("ws", "lc", "bc")
                if kind == "lc":
                    nl = self.text.find("\n", self.pos)
                    self.pos = len(self.text) if nl < 0 else nl
                elif kind == "bc":
                    self._skip_block_comment()
                elif kind in ("raw", "str"):
                    self.string = (tok[1:] if kind == "raw" else tok, kind == "raw")
                elif kind == "id":
                    self._on_word(tok)
                elif kind == "num":
                    self._reset(tok)
                elif kind == "op":
                    self._on_op(tok, ws_before, m.start())
        
            def _skip_block_comment(self) -> None:
                depth = 1  # Dart block comments nest
                while depth:
                    e = _BLOCK_COMMENT_EDGE.search(self.text, self.pos)
                    if e is None:
                        self.pos = len(self.text)
                        return
                    depth += 1 if e.group() == "/*" else -1
                    self.pos = e.end()
        
            # -- tokens ------------------------------------------------------------
        
            def _on_word(self, tok: str) -> None:
                if tok in _DECISION_WORDS:
                    self._count()
                self.getter = tok if self.prev == "get" and _is_name(tok) else None
                if tok not in _BODY_MODIFIERS:
                    self.closed_head = None
                self.prev = tok
        
            def _on_op(self, tok: str, ws_before: bool, start: int) -> None:
                if tok in _DECISION_OPS or (tok == "?" and ws_before):
                    self._count()
                if tok == ")":
                    head = self.parens.pop() if self.parens else ""
                    self._close_arrows()
                    self._reset(")")
                    self.closed_head = head
                    return
                if tok == "}" and self.interps and self.interps[-1][2] == 0:
                    quote, raw, _ = self.interps.pop()
                    self.string = (quote, raw)  # the interpolation ended
                    self._reset("'")
                    return
                handler = _OP_HANDLERS.get(tok)
                if handler is not None:
                    handler(self, start)
                keep = self.closed_head if tok in _BODY_MODIFIERS else None
                self._reset(tok)
                self.closed_head = keep
        
            def _on_lt(self, _start: int) -> None:
                self.generic_name = self.prev if _is_name(self.prev) else ""
        
            def _on_open_paren(self, _start: int) -> None:
                self.parens.append(self.generic_name if self.prev == ">" else self.prev)
        
            def _on_open_brace(self, start: int) -> None:
                if self.interps:
                    self.interps[-1][2] += 1
                name = self._body_name()
                self.braces.append(self._open(name, start) if name else None)
        
            def _on_close_brace(self, _start: int) -> None:
                if self.interps:
                    self.interps[-1][2] -= 1
                opened = self.braces.pop() if self.braces else None
                if opened is not None:
                    self._close(opened)
                self._close_arrows()
        
            def _on_arrow(self, start: int) -> None:
                name = self._body_name()
                if name is not None:
                    fn = self._open(name, start)
                    self.arrows.append((fn, len(self.braces), len(self.parens)))
        
            def _on_semicolon(self, _start: int) -> None:
                self._close_arrows(at_semicolon=True)
        
            # -- function frames ---------------------------------------------------
        
            def _count(self) -> None:
                if self.fns:
                    self.fns[-1].count += 1
        
            def _reset(self, prev: str) -> None:
                self.prev, self.closed_head, self.getter = prev, None, None
        
            def _body_name(self) -> str | None:
                """The function name a `{` or `=>` body opened now would take, or None
                for a block or a closure folded into its enclosing function."""
                if self.getter is not None:
                    return self.getter
                head = self.closed_head
                if head is not None and _is_name(head):
                    return head
                if head is not None and not self.fns and head not in _CONTROL_HEADS:
                    # A closure, a constructor body after an initializer list or an
                    # operator overload, outside any function.
                    return "<anonymous>"
                return None
        
            def _open(self, name: str, start: int) -> _Fn:
                fn = _Fn(name, start)
                self.fns.append(fn)
                return fn
        
            def _close(self, fn: _Fn) -> None:
                if self.fns and self.fns[-1] is fn:  # the usual case; O(1) when deep
                    self.fns.pop()
                elif fn in self.fns:
                    self.fns.remove(fn)
                self.done.append(fn)
        
            def _close_arrows(self, at_semicolon: bool = False) -> None:
                """Close named `=>` bodies whose expression ended: a `;` at the
                arrow's own depth, or a `)` / `}` that leaves that depth."""
                while self.arrows:
                    _fn, b, p = self.arrows[-1]
                    braces, parens = len(self.braces), len(self.parens)
                    if not (braces < b or (braces == b and (
                            parens < p or (parens == p and at_semicolon)))):
                        return
                    self._close(self.arrows.pop()[0])
        
        
        _OP_HANDLERS = {
            "<": _Scanner._on_lt,
            "(": _Scanner._on_open_paren,
            "{": _Scanner._on_open_brace,
            "}": _Scanner._on_close_brace,
            "=>": _Scanner._on_arrow,
            ";": _Scanner._on_semicolon,
        }
        
        
        def _is_name(tok: str) -> bool:
            return bool(tok) and (tok[0].isalpha() or tok[0] in "_$") \
                and tok not in _NOT_A_NAME
        
        
        def dart_function_scores(path: Path) -> tuple[list[float], str | None]:
            """Score one Dart file: ``(per-function ccns, worst function's name)``.
        
            The name is the first function with the highest complexity, the rule
            ``lizard_scores`` uses; it is None when the file has no function. An
            unreadable file scores as having none.
            """
            try:
                with path.open("rb") as fh:
                    data = fh.read(_READ_BYTES)
            except OSError:
                return [], None
            fns = scan_dart_functions(data.decode("utf-8", errors="replace"))
            if not fns:
                return [], None
            ccns = [c for _n, c in fns]
            return ccns, fns[ccns.index(max(ccns))][0]
        
      • decline_markers.py 7.5 KB
        """Read decline markers (``.assess/.no-<tool>``) with provenance.
        
        A decline marker records that a user permanently declined an optional tool for
        this repo - ``scc`` (coverage-extension for the treemap), a per-language
        dead-code linter (``vulture`` / ``ts-prune`` / ``staticcheck``), or the bounded
        mutation pass (``mutmut`` / ``stryker``). Historically each marker was an empty
        ``touch``-ed file: present-or-absent was the entire signal. That made the
        decline a silent, provenance-free fact - the report could not disclose *who*
        declined *what* and *when*, and a decline made against an old major version
        outlived the tool changes that might have made it worth re-offering.
        
        Markers are now JSON with provenance::
        
            {"declined_by": "ben", "declined_at": "2026-07-07",
             "plugin_version": "1.54.4", "reason": "pure-docs repo"}
        
        ``reason`` is optional. Legacy empty / non-JSON markers are still honoured as a
        decline - they just carry no provenance (``declined_by`` / ``declined_at`` /
        ``version`` are ``None``), so they read as "declined by an unknown user on an
        unknown date" rather than crashing the scan.
        
        **Re-offer on a major bump.** A marker written under an older *major* plugin
        version is stale enough that the tool's behaviour may have changed materially
        since the user declined. Such a marker carries ``reoffer: True`` so the
        orchestrator can re-ask once. When the user declines again permanently, the new
        marker is stamped with the *current* version - its major now matches, so
        ``reoffer`` drops back to ``False`` and the re-offer does not repeat every run
        within the same major (re-offer once per major). Legacy markers carry no
        version, so they are never auto-re-offered (there is no major to compare).
        
        The ``reoffer`` field is a pure staleness signal, set for *any* tool declined
        under an older major - but only mutation tools are actually re-offered (SKILL.md
        Step 2d); Step 2b never re-asks a linter decline. So the report's
        "re-offer eligible" disclosure suffix is gated on ``MUTATION_TOOLS`` (see
        :func:`_disclosure`), and ``reoffer_mutation`` keys off the same set - a stale
        linter marker keeps ``reoffer: True`` for its own record without claiming a
        re-offer that never comes.
        
        Pure stdlib. Never raises out of :func:`read_decline_markers`.
        """
        from __future__ import annotations
        
        import json
        from dataclasses import dataclass
        from pathlib import Path
        from typing import Any
        
        MARKER_PREFIX = ".no-"
        
        # Tools whose decline gates the consent-heavy bounded mutation pass. A stale
        # decline for one of these is what ``reoffer_mutation`` keys off.
        MUTATION_TOOLS = frozenset({"mutmut", "stryker"})
        
        
        @dataclass
        class DeclineMarker:
            """One parsed ``.no-<tool>`` marker."""
        
            path: str  # repo-root-relative, e.g. ".assess/.no-mutmut"
            tool: str  # e.g. "mutmut"
            declined_by: str | None
            declined_at: str | None
            version: str | None  # plugin_version recorded in the marker
            reason: str | None
            reoffer: bool  # marker major < current major (never True for legacy markers)
        
            def to_dict(self) -> dict[str, Any]:
                return {
                    "path": self.path,
                    "tool": self.tool,
                    "declined_by": self.declined_by,
                    "declined_at": self.declined_at,
                    "version": self.version,
                    "reason": self.reason,
                    "reoffer": self.reoffer,
                }
        
        
        def _major(version: str | None) -> int | None:
            """Extract the integer major component of a semver string, else None."""
            if not version:
                return None
            head = str(version).strip().lstrip("vV").split(".", 1)[0]
            try:
                return int(head)
            except ValueError:
                return None
        
        
        def _parse_marker(path: Path, current_version: str) -> DeclineMarker:
            tool = path.name[len(MARKER_PREFIX):]
            declined_by: str | None = None
            declined_at: str | None = None
            version: str | None = None
            reason: str | None = None
            try:
                raw = path.read_text(encoding="utf-8").strip()
            except OSError:
                raw = ""
            if raw:
                try:
                    data = json.loads(raw)
                except (ValueError, TypeError):
                    data = None
                if isinstance(data, dict):
                    declined_by = data.get("declined_by") or None
                    declined_at = data.get("declined_at") or None
                    version = data.get("plugin_version") or None
                    reason = data.get("reason") or None
        
            marker_major = _major(version)
            current_major = _major(current_version)
            # Re-offer only when both majors are known and the marker's is older. A
            # legacy marker (no version) has no major, so it is never auto-re-offered.
            reoffer = (
                marker_major is not None
                and current_major is not None
                and marker_major < current_major
            )
            return DeclineMarker(
                path=path.name,
                tool=tool,
                declined_by=declined_by,
                declined_at=declined_at,
                version=version,
                reason=reason,
                reoffer=reoffer,
            )
        
        
        def read_decline_markers(
            assess_dir: Path, current_version: str
        ) -> list[DeclineMarker]:
            """Scan ``assess_dir`` for ``.no-<tool>`` markers and parse each.
        
            Returns markers sorted by tool for a stable, byte-reproducible run-context.
            A missing or unreadable ``.assess`` directory yields an empty list; never
            raises.
            """
            try:
                if not assess_dir.is_dir():
                    return []
                candidates = sorted(
                    p
                    for p in assess_dir.iterdir()
                    if p.is_file()
                    and p.name.startswith(MARKER_PREFIX)
                    and len(p.name) > len(MARKER_PREFIX)
                )
            except OSError:
                return []
            markers = [_parse_marker(p, current_version) for p in candidates]
            markers.sort(key=lambda m: m.tool)
            return markers
        
        
        def build_decline_block(
            assess_dir: Path, current_version: str
        ) -> dict[str, Any]:
            """Build the run-context ``decline_markers`` block plus derived flags.
        
            Returns a dict with:
        
            - ``markers``: list of per-marker provenance dicts.
            - ``reoffer_mutation``: True when a mutation-tool decline (``mutmut`` /
              ``stryker``) was written under an older major - the SKILL.md Step 2d
              re-offer flag.
            - ``disclosures``: human-readable one-liners the report surfaces so an
              active permanent decline is never invisible.
            """
            markers = read_decline_markers(assess_dir, current_version)
            reoffer_mutation = any(
                m.tool in MUTATION_TOOLS and m.reoffer for m in markers
            )
            disclosures = [_disclosure(m) for m in markers]
            return {
                "markers": [m.to_dict() for m in markers],
                "reoffer_mutation": reoffer_mutation,
                "disclosures": disclosures,
            }
        
        
        def _disclosure(m: DeclineMarker) -> str:
            """One-line report disclosure of an active decline marker."""
            who = m.declined_by or "an unknown user"
            when = m.declined_at or "an unknown date"
            label = _tool_label(m.tool)
            line = f"{label} permanently declined by {who} on {when}"
            if m.version:
                line += f" (plugin v{m.version})"
            # Only mutation tools are actually re-offered (SKILL.md Step 2d). A linter
            # decline (scc, vulture, ...) carries reoffer=True too when it predates a
            # major bump, but Step 2b never consults it - so gate the suffix on
            # MUTATION_TOOLS, else a stale linter marker claims a re-offer that never
            # comes.
            if m.reoffer and m.tool in MUTATION_TOOLS:
                line += " - re-offer eligible (declined under an older major)"
            return line
        
        
        def _tool_label(tool: str) -> str:
            if tool in MUTATION_TOOLS:
                return "Mutation testing"
            if tool == "scc":
                return "scc coverage extension"
            return f"Dead-code analysis (`{tool}`)"
        
      • doc_complexity_join.py 15.9 KB
        """Signal C: complexity x doc-state join (joins two artifacts the skill already has).
        
        The treemap tells us *where the complexity is*; the doc-staleness metric tells
        us *whether the map beside it still tracks the territory*. Crossing them turns
        each into something neither has alone -- a judgement about whether a complex
        unit is safely *legible through a keyhole*:
        
          - complex + **fresh doc**  -> the doc is the contract (good; footprint relieved).
          - complex + **no doc**     -> high load, no summary an agent can read first.
          - complex + **stale doc**  -> a *lying map* over dangerous territory. The doc
                                        tells the agent the wrong thing; worse than none.
        
        Quantified as a single signed number per unit::
        
            doc_value = complexity_summarised x freshness
        
        where ``complexity_summarised`` is the **max cyclomatic complexity** of the code
        the doc covers, and ``freshness`` is **signed** in ``[-1, +1]`` -- positive when
        the doc keeps pace with its code, crossing to **negative** once the code has
        churned past the doc by more than a staleness threshold. Two consequences fall
        straight out of the multiplication, exactly as the PRD requires:
        
          - trivial code  -> ``complexity_summarised`` is small  -> ``doc_value`` ~= 0
            regardless of the doc's state (an out-of-date note on a one-liner is noise,
            not a finding).
          - stale doc over complex code -> ``freshness < 0`` -> ``doc_value < 0``: a doc
            in this quadrant is *worth less than no doc*, so the recommendation is
            fix-or-DELETE, never "preserve".
        
        **Slop-doc guard (hard constraint).** Nothing here ever recommends
        auto-generating a doc to clear a flag -- that manufactures lying maps at scale.
        An honest *undocumented* unit (``unexplained_complexity``, ``doc_value == 0``)
        must score **strictly safer** than a hollow stale summary (``lying_map``,
        ``doc_value < 0``), and it does: ``0 > negative``. Recommendations are advice
        for a human, never an instruction to synthesise prose.
        
        **Doc->code mapping is deliberately fuzzy** -- nearest-ancestor by path
        proximity over the paths already present in the two input artifacts. It is a
        *candidate* signal pointing a human at a unit, not a verdict, so no filesystem
        read or symbol resolution is required (and the function stays trivially
        testable from mocked dicts).
        
        This module is **standalone**: it consumes the JSON-serialisable outputs of the
        complexity treemap (``complexity-stats.json``) and ``analyze_doc_staleness`` and
        returns a JSON-serialisable dict. Integration into ``assess_core`` /
        ``run-context.json`` is a separate task's job -- nothing here imports or edits
        the core.
        """
        from __future__ import annotations
        
        from pathlib import Path, PurePosixPath
        
        # Code that churns more than this multiple of its doc's own churn is treated as
        # having outrun the map: freshness crosses zero here and reaches -1 at twice
        # this ratio. 2.0 = "the code changed twice as often as anyone touched the doc".
        # The doc-staleness metric already computes this ratio (code_churn / doc_churn)
        # as its core decaying-map signal; we only map it onto a signed scale.
        STALENESS_RATIO_THRESHOLD = 2.0
        
        # McCabe's classic "moderate risk" line. We gate findings on the *higher* of
        # this floor and the repo's own 95th-percentile CCN, so a genuinely simple repo
        # never sprouts "high complexity" findings, while a complex repo self-calibrates
        # to flag only its worst ~5%.
        MIN_HIGH_CCN = 10.0
        
        # Per-finding advice. Every string is guidance for a human; none instructs
        # anyone (or any tool) to synthesise documentation -- see the slop-doc guard.
        _RECOMMENDATIONS = {
            "lying_map": (
                "Fix or DELETE this doc. A stale summary over complex code is worse "
                "than none -- it lies to the next agent. Do not auto-generate a "
                "replacement; a hollow summary is still a lying map."
            ),
            "unexplained_complexity": (
                "Complex code with no doc and no intent source. A human should write "
                "the missing contract. Do NOT auto-generate it -- an honest gap is "
                "safer than a synthetic summary."
            ),
            "good_contract": (
                "Keep. The doc is fresh and carries real complexity -- this is the "
                "contract relieving the comprehension footprint."
            ),
        }
        
        
        def _clamp(value: float, low: float, high: float) -> float:
            return max(low, min(high, value))
        
        
        def _extract_file_ccn(complexity_stats: dict) -> dict[str, float]:
            """Build path -> max-CCN from whatever per-file lists the stats expose.
        
            ``complexity-stats.json`` carries per-file CCN only in its ranked lists
            (``top_complex`` / ``top_hotspots`` / ``top_large``); we union them (and an
            optional full ``files`` list, for forward-compatibility) and keep the
            highest CCN seen per path. Percentiles live elsewhere in the dict and are
            read separately for the threshold.
            """
            ccn: dict[str, float] = {}
            for key in ("files", "top_complex", "top_hotspots", "top_large"):
                for entry in complexity_stats.get(key) or []:
                    path = entry.get("path")
                    if path is None or entry.get("ccn") is None:
                        continue
                    value = float(entry["ccn"])
                    if value > ccn.get(path, float("-inf")):
                        ccn[path] = value
            return ccn
        
        
        def _high_ccn_threshold(complexity_stats: dict) -> float:
            """The CCN at or above which a unit counts as 'high complexity'."""
            p95 = float((complexity_stats.get("ccn") or {}).get("p95", 0.0) or 0.0)
            return max(p95, MIN_HIGH_CCN)
        
        
        def _signed_freshness(doc: dict) -> float:
            """Map the doc's staleness state onto a signed freshness in [-1, +1].
        
            **Generated docs with declared provenance** (issue #178) bypass the churn
            ratio entirely: a generated doc's freshness is determined by whether its
            declared source has moved on, not by how busy the surrounding code is. So
            when the doc-staleness block carries a ``provenance.source_newer`` verdict::
        
                source_newer == False -> +1.0  (doc still matches its source -> fresh,
                                                 never a lying_map)
                source_newer == True  -> -1.0  (source has outrun the doc -> stale)
        
            A ``source_newer`` of ``None`` means provenance was declared but the
            comparison was indeterminate (no usable timestamps), so we fall back to the
            churn ratio below.
        
            Otherwise (the ordinary hand-written doc), ``ratio`` (code churn per unit of
            doc maintenance) is the decaying-map signal: 0 when the code is as quiet as
            the doc, large when the code churns while the doc sits frozen.
            Piecewise-linear::
        
                ratio = 0                       -> +1.0  (doc fully keeps pace)
                ratio = THRESHOLD               ->  0.0  (doc starts lagging)
                ratio >= 2 * THRESHOLD          -> -1.0  (doc has outrun, clamped)
            """
            provenance = doc.get("provenance")
            if isinstance(provenance, dict):
                source_newer = provenance.get("source_newer")
                if source_newer is True:
                    return -1.0
                if source_newer is False:
                    return 1.0
            ratio = float(doc.get("ratio", 0.0) or 0.0)
            return round(_clamp((STALENESS_RATIO_THRESHOLD - ratio) / STALENESS_RATIO_THRESHOLD,
                                -1.0, 1.0), 3)
        
        
        def _doc_dir_parts(doc_path: str) -> tuple[str, ...]:
            """Directory the doc lives in, as path parts (() for a repo-root doc)."""
            return PurePosixPath(doc_path).parent.parts
        
        
        def _covers(doc_dir: tuple[str, ...], code_parts: tuple[str, ...]) -> bool:
            """True if ``code`` sits in the doc's directory or any subdirectory."""
            return code_parts[: len(doc_dir)] == doc_dir
        
        
        def _assign_code_to_docs(
            code_paths: list[str], doc_paths: list[str],
        ) -> dict[str, list[str]]:
            """Assign each code file to its *nearest-ancestor* doc by path proximity.
        
            A doc covers code in its own directory and below; when several docs cover
            the same file the deepest (longest directory prefix) wins -- the same
            nearest-match rule ``CODEOWNERS`` and the doc-staleness metric use. Ties
            break on doc path for determinism. Files no doc covers are simply absent
            from the result (they become ``unexplained_complexity`` candidates).
            """
            doc_dirs = {d: _doc_dir_parts(d) for d in doc_paths}
            assignment: dict[str, list[str]] = {d: [] for d in doc_paths}
            for code in code_paths:
                code_parts = PurePosixPath(code).parts
                candidates = [
                    (len(dir_parts), doc)
                    for doc, dir_parts in doc_dirs.items()
                    if _covers(dir_parts, code_parts)
                ]
                if not candidates:
                    continue
                _, nearest = max(candidates, key=lambda t: (t[0], t[1]))
                assignment[nearest].append(code)
            return assignment
        
        
        def analyze_doc_complexity_join(
            complexity_stats: dict, doc_staleness: dict, repo_root: Path,
        ) -> dict:
            """Join complexity hotspots with doc freshness into Signal C findings.
        
            Args:
                complexity_stats: the ``complexity-stats.json`` sidecar (per-file CCN in
                    its ranked lists; CCN percentiles under ``ccn``).
                doc_staleness: the dict returned by ``analyze_doc_staleness`` (per-doc
                    ``ratio`` / ``confidence`` under ``docs``).
                repo_root: repository root. Accepted for signature parity with the rest
                    of the pipeline; the join itself needs no filesystem access (the
                    doc->code mapping is path-proximity over the input artifacts), which
                    keeps it deterministic and testable from mocked dicts.
        
            Returns a JSON-serialisable dict::
        
                {
                  "available": bool,
                  "high_ccn_threshold": float,
                  "docs": [ {path, complexity_summarised, freshness, doc_value,
                             finding, confidence, subject_code_count, recommendation} ],
                  "findings": {"lying_maps": [...], "unexplained_complexity": [...],
                               "good_contracts": [...]},
                }
        
            The ``docs`` list mixes two unit kinds: real docs (a freshness-signed
            ``doc_value``) and undocumented high-complexity code surfaced as
            ``unexplained_complexity`` (``freshness == 0`` -> ``doc_value == 0``, per the
            PRD's "missing -> 0" rule). Suitable as-is for ``run-context.json``'s
            ``documentation`` block (task #5's job to place it there).
            """
            repo_root = Path(repo_root)  # parity only; unused by the deterministic join
        
            file_ccn = _extract_file_ccn(complexity_stats)
            threshold = _high_ccn_threshold(complexity_stats)
        
            docs_in = (
                doc_staleness.get("docs", [])
                if doc_staleness.get("available", False)
                else []
            )
            # Churn-measurement reliability (set once in lib.git_churn, carried on the
            # doc-staleness block). When the history is degenerate - every file ~1 commit
            # (shallow clone, fresh import, squashed/extracted tree) - the `ratio` that
            # drives `freshness` is built on a churn count that means nothing, even
            # though the doc->code association may be perfectly precise. `confidence`
            # encodes association precision, not measurement reliability, so a precise
            # map over a meaningless churn signal would otherwise stamp a high-confidence
            # lying_map. Cap every doc's confidence to "low" so the existing
            # low-confidence guard below suppresses the lying_map classification.
            churn_degenerate = bool(doc_staleness.get("churn_degenerate", False))
            doc_paths = [d["path"] for d in docs_in]
            assignment = _assign_code_to_docs(list(file_ccn), doc_paths)
            covered_code = {c for codes in assignment.values() for c in codes}
        
            units: list[dict] = []
            lying_maps: list[dict] = []
            unexplained: list[dict] = []
            good_contracts: list[dict] = []
        
            # --- Real docs: classify by (complexity it covers) x (its freshness) ---
            for doc in docs_in:
                path = doc["path"]
                subject = assignment.get(path, [])
                complexity_summarised = round(
                    max((file_ccn[c] for c in subject), default=0.0), 2
                )
                freshness = _signed_freshness(doc)
                doc_value = round(complexity_summarised * freshness, 3)
                # Provenance (issue #178): when the freshness came from a definite
                # source-vs-doc comparison (a generated doc declaring its source), it is
                # a direct, high-confidence verdict - NOT the coarse churn ratio. The
                # churn-based confidence caps below (repo-baseline, degenerate history)
                # exist to discount the ratio; they must not suppress a provenance
                # verdict. So a provenance doc reports "high" confidence and bypasses the
                # low-confidence guard.
                prov = doc.get("provenance")
                has_provenance_verdict = (
                    isinstance(prov, dict) and prov.get("source_newer") in (True, False)
                )
                confidence: str | None
                if has_provenance_verdict:
                    confidence = "high"
                else:
                    # Degenerate churn caps measurement confidence to "low" regardless of
                    # how precise the association is (see churn_degenerate above). The
                    # reported confidence reflects the cap so a downstream reader sees it.
                    confidence = "low" if churn_degenerate else doc.get("confidence")
        
                finding: str | None = None
                # Trivial code never produces a finding: complexity_summarised below the
                # high bar means doc_value is already near zero, so the doc's state is
                # noise either way.
                if complexity_summarised >= threshold:
                    # A low-confidence staleness signal (subject_method ==
                    # "repo-baseline") measures the doc against repo-wide churn, not the
                    # specific code it describes, so a doc edited today can still read as
                    # "stale" purely because the repo is busy elsewhere. That is too
                    # coarse to call a lying map - the same confidence guard the Layer 0
                    # stale-hub reporting applies. Leave such a doc unclassified. A
                    # provenance verdict is exempt (see has_provenance_verdict).
                    low_confidence = confidence == "low" and not has_provenance_verdict
                    if freshness < 0 and not low_confidence:
                        finding = "lying_map"
                    elif freshness > 0:
                        finding = "good_contract"
        
                unit = {
                    "path": path,
                    "complexity_summarised": complexity_summarised,
                    "freshness": freshness,
                    "doc_value": doc_value,
                    "finding": finding,
                    "confidence": confidence,
                    "subject_code_count": len(subject),
                    "recommendation": _RECOMMENDATIONS.get(finding) if finding else None,
                }
                units.append(unit)
                if finding == "lying_map":
                    lying_maps.append(unit)
                elif finding == "good_contract":
                    good_contracts.append(unit)
        
            # --- Undocumented high-complexity code: unexplained_complexity ---
            # No covering doc => no intent source within this signal's reach. doc_value
            # is 0 (the "missing -> 0" branch), so an honest gap scores strictly safer
            # than a lying map -- the slop-doc guard in numbers.
            for code, ccn in file_ccn.items():
                if code in covered_code or round(ccn, 2) < threshold:
                    continue
                unit = {
                    "path": code,
                    "complexity_summarised": round(ccn, 2),
                    "freshness": 0.0,
                    "doc_value": 0.0,
                    "finding": "unexplained_complexity",
                    "confidence": None,
                    "subject_code_count": 0,
                    "recommendation": _RECOMMENDATIONS["unexplained_complexity"],
                }
                units.append(unit)
                unexplained.append(unit)
        
            # Deterministic ordering, most-actionable first within each bucket.
            units.sort(key=lambda u: (u["doc_value"], u["path"]))
            lying_maps.sort(key=lambda u: (u["doc_value"], u["path"]))
            unexplained.sort(key=lambda u: (-u["complexity_summarised"], u["path"]))
            good_contracts.sort(key=lambda u: (-u["doc_value"], u["path"]))
        
            return {
                "available": True,
                "high_ccn_threshold": round(threshold, 2),
                "docs": units,
                "findings": {
                    "lying_maps": lying_maps,
                    "unexplained_complexity": unexplained,
                    "good_contracts": good_contracts,
                },
            }
        
      • doc_graph.py 55.6 KB
        """Doc link-graph for Layer 0 navigability scoring.
        
        Navigability is a graph property, so we measure it as one rather than checking
        for the presence of a README. We parse every doc for both link forms an LLM
        wiki uses -- ``[[wikilinks]]`` (Obsidian / Karpathy-pattern) and
        ``[text](relative/path)`` (CommonMark) -- resolve them to real files, and build
        a directed graph. From that graph we derive:
        
          - **PageRank / centrality** -> the load-bearing docs (hubs / MOCs) surface
            automatically, no filename guessing.
          - **Orphans** -> docs with no inbound links are unreachable by traversal;
            a navigability gap.
          - **Connectivity / reachability** -> a navigable doc set is one connected
            island, fully reachable from the entry points (README / AGENTS.md / top
            MOC). We report orphan-rate, island-count and reachability-%.
          - **MOC validation** -> a *declared* MOC (``index.md``, a note named "MOC")
            is only real if the graph shows it as a structural hub. Declared-but-not-
            wired is a finding: a named map that doesn't actually link its cluster.
          - **Doc->code edges** -> links pointing at source files, a first-class
            doc->code association source for the staleness heatmap.
        
        Core dependency is ``networkx``. The parser is native (handles both link forms,
        resolves relative targets, strips ``#anchors``, handles name collisions) so the
        module needs no Obsidian-specific package; ``obsidiantools`` is detected and
        noted as an optional accelerator when an Obsidian vault is present, but is never
        required. If ``networkx`` is unavailable the module degrades to an
        ``available=False`` result rather than crashing -- the assessment never blocks.
        """
        from __future__ import annotations
        
        import os
        import posixpath
        import re
        from dataclasses import dataclass, field
        from functools import partial
        from pathlib import Path
        
        try:  # networkx is the core dep; degrade rather than crash if it is missing.
            import networkx as nx
        
            _NETWORKX_AVAILABLE = True
        except ImportError:  # pragma: no cover - exercised only on a broken env
            nx = None  # type: ignore[assignment]
            _NETWORKX_AVAILABLE = False
        
        from lib.git_churn import tracked_files  # noqa: E402
        
        
        DOC_EXTENSIONS = {".md", ".mdx", ".markdown"}
        
        # Obsidian Bases view files. Not markdown - they are query hubs that surface
        # notes dynamically - so they are discovered separately and added as hub nodes
        # (issue #176), never counted as prose docs by the markdown walk.
        BASE_EXTENSIONS = {".base"}
        
        # Extensions we treat as "code" when a doc link points at one (doc->code edge).
        CODE_EXTENSIONS = {
            ".py", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".go", ".java",
            ".kt", ".kts", ".rs", ".rb", ".cs", ".swift", ".dart", ".cpp", ".cc",
            ".cxx", ".c", ".h", ".hpp", ".hh", ".php", ".scala", ".m", ".mm",
            ".sh", ".bash", ".sql", ".vue", ".svelte",
        }
        
        # Directories never worth walking for docs. Mirrors the treemap's EXCLUDE_DIRS
        # (kept local so this module pulls in no heavy deps) plus .assess itself, since
        # /assess writes its own wiki there and we must not analyse our own output.
        EXCLUDE_DIRS = {
            ".git", "node_modules", "dist", "build", "target", "vendor",
            ".venv", "venv", "__pycache__", ".gradle", ".idea", ".mvn",
            "worktree", ".understand-anything", ".obsidian", ".taskmaster",
            ".claude", ".next", ".nuxt", ".output", ".svelte-kit", ".astro",
            "out", "coverage", "htmlcov", "Pods", "DerivedData", "flutter_assets",
            ".assess",
        }
        
        # Path-segment *sequences* (not single dir names) that mark non-navigational
        # trees. Test fixtures live at `**/tests/fixtures/**`: they are inputs to the
        # scanners (sample CLAUDE.md / monolithic-instruction files that exist only to
        # exercise the detectors), never repo docs. Counting them inflates the orphan
        # rate and depresses the Layer 0 navigability read (issue #83). Matching the
        # consecutive sequence - rather than the bare `fixtures` dir name - avoids
        # over-excluding an unrelated top-level `fixtures/` of real content.
        EXCLUDE_PATH_SEQUENCES: tuple[tuple[str, ...], ...] = (
            ("tests", "fixtures"),
        )
        
        
        def _contains_sequence(parts: tuple[str, ...], seq: tuple[str, ...]) -> bool:
            """True if `seq` appears as consecutive elements anywhere in `parts`."""
            n = len(seq)
            if n == 0 or n > len(parts):
                return False
            return any(parts[i:i + n] == seq for i in range(len(parts) - n + 1))
        
        
        def is_excluded_path(rel: Path) -> bool:
            """True if `rel` lies under a built-in non-navigational tree.
        
            Combines the single-segment `EXCLUDE_DIRS` match (`.assess`, `node_modules`,
            ...) with the multi-segment `EXCLUDE_PATH_SEQUENCES` match (`tests/fixtures`).
            This is the built-in default, applied by every scan; user-supplied
            `config.toml` / `--exclude` excludes layer on top via `is_user_excluded`.
            """
            parts = rel.parts
            if any(part in EXCLUDE_DIRS for part in parts):
                return True
            return any(_contains_sequence(parts, seq) for seq in EXCLUDE_PATH_SEQUENCES)
        
        # Entry-doc basenames: legitimately have no inbound links (they are where a
        # reader starts), so they are excluded from the orphan count and used as the
        # roots for reachability.
        ENTRY_BASENAMES = {"readme.md", "agents.md", "claude.md", "index.md", "home.md"}
        
        # Declared-MOC conventions (filename signals a map-of-content). Cross-checked
        # against the graph: a real MOC is a structural hub.
        MOC_BASENAMES = {"index.md", "_index.md", "home.md", "moc.md", "_moc.md"}
        _MOC_STEM_RE = re.compile(r"(^|[ _-])moc([ _-]|$)|map[ _-]?of[ _-]?content",
                                  re.IGNORECASE)
        
        # A declared MOC counts as "wired" (a real structural hub) once it links out to
        # at least this many other docs. Below it, the map is named but not built.
        HUB_MIN_OUTDEGREE = 3
        
        # Link parsers. Wikilinks: [[target]], [[target|alias]], [[target#anchor]].
        _WIKILINK_RE = re.compile(r"\[\[([^\[\]]+?)\]\]")
        # Markdown inline links: [text](target). Excludes images handled below.
        _MDLINK_RE = re.compile(r"(?<!\!)\[(?:[^\]]*)\]\(([^)]+)\)")
        # Schemes / forms that are not intra-repo file links.  Any token matching
        # the RFC 3986 URI-scheme pattern (`[a-z][a-z0-9+.-]*:`) is non-navigational:
        # `http://`, `https://`, `ftp://` (the scheme-plus-`://` form), but also bare
        # schemes such as `tel:`, `mailto:`, `sms:`, `callto:`, `javascript:`, etc.
        # Using the generic scheme pattern rather than an allowlist keeps the regex
        # stable as new schemes appear and avoids the specific-scheme gap that caused
        # `sms:` and `skype:` to be misclassified as broken file references (issue #227).
        _EXTERNAL_RE = re.compile(r"^[a-z][a-z0-9+.-]*:", re.IGNORECASE)
        # Inline-code spans: backtick-delimited segments on a single logical line. A
        # link target inside `[[foo]]` or `[text](./foo.md)` is documentation syntax
        # (an Obsidian skill teaching wikilinks, a FORMAT-spec showing a sample), not
        # a real navigation edge - the writer formatted it as code on purpose. Caps
        # match-length to avoid spanning paragraphs when stray backticks appear.
        _INLINE_CODE_RE = re.compile(r"`[^`\n]{1,200}`")
        
        
        def _strip_code_spans(text: str) -> str:
            """Remove fenced code blocks and inline-code spans before link extraction.
        
            Without this, a markdown doc that *teaches* link syntax (a FORMAT spec, an
            Obsidian-skill how-to) contributes phantom edges to the navigation graph
            and inflates `dangling_links`. The writer formatted those targets as code
            precisely because they are samples, not navigation.
            """
            # Strip fenced blocks first so an inline-code regex can't snag content
            # inside a fence that legitimately contains backticks of its own.
            return _INLINE_CODE_RE.sub("", _strip_fenced_lines(text))
        
        # Caps so a pathological repo can't bloat run-context.json.
        MAX_BROKEN_LINKS = 60
        MAX_MISSING_XREFS = 60
        # directory_breakdown keeps the rows with the largest gaps; directory_count
        # carries the full total so a truncated list still says how many there were.
        MAX_DIRECTORY_BREAKDOWN = 30
        # Conventional filenames that get mentioned all the time and don't need a
        # cross-reference every time they're named - excluded from the missing-xref scan.
        _XREF_SKIP_NAMES = {
            "readme.md", "index.md", "_index.md", "license.md", "changelog.md",
            "contributing.md", "code_of_conduct.md", "security.md", "agents.md",
            "claude.md", "gemini.md", "home.md", "notes.md",
        }
        
        
        @dataclass
        class DocGraphResult:
            available: bool = True
            reason: str = ""
            doc_count: int = 0
            edge_count: int = 0
            hubs: list[dict] = field(default_factory=list)        # [{path, pagerank, out_degree, in_degree}]
            orphans: list[str] = field(default_factory=list)      # in_degree == 0 and not an entry
            orphan_rate: float = 0.0
            island_count: int = 0
            reachability_pct: float = 0.0
            entry_points: list[str] = field(default_factory=list)
            unreachable: list[str] = field(default_factory=list)
            declared_mocs: list[dict] = field(default_factory=list)  # [{path, out_degree, is_structural_hub}]
            moc_named_but_not_wired: list[str] = field(default_factory=list)
            doc_to_code_edges: list[dict] = field(default_factory=list)  # [{doc, code}]
            dangling_links: int = 0
            # Broken links: a link whose target file doesn't exist (a "ghost"). The
            # renderer draws these as ghost nodes - the missing name is the suggested fix.
            broken_links: list[dict] = field(default_factory=list)  # [{from, target, kind}]
            # Raw-source-tree exclusion (issue #225). The headline read-side metrics
            # above (orphan_rate, reachability_pct, orphans, unreachable, island_count,
            # broken_links, dangling_links) describe the *curated* wiki layer: subtrees
            # of raw, machine-extracted source documents (a disclosure/SAR export of
            # converted .msg/.pdf files) are detected and excluded so they don't inflate
            # the figures. Each excluded tree is named with its file count so the
            # exclusion stays legible; the raw layer's own figures are reported alongside.
            excluded_raw_trees: list[dict] = field(default_factory=list)  # [{path, file_count}]
            raw_source_doc_count: int = 0       # total docs across all excluded raw trees
            curated_doc_count: int = 0          # docs in the curated layer (== doc_count)
            raw_source_orphan_rate: float = 0.0  # orphan rate within the raw layer
            raw_source_broken_links: int = 0     # broken links originating in the raw layer
            # Working-notes exclusion (issue #366): pattern-named notes hung off one or
            # two index files (plans, session logs, tickets) leave the headline the
            # same way, named with a file count, with the notes layer's own figures.
            excluded_working_notes_trees: list[dict] = field(default_factory=list)  # [{path, file_count}]
            working_notes_doc_count: int = 0
            working_notes_orphan_rate: float = 0.0
            working_notes_broken_links: int = 0
            # Link-only figures (issue #353). The headline orphan_rate and
            # reachability_pct count reference edges (a backticked doc path) as well as
            # links; these two are the same figures over link edges alone.
            link_only_orphan_rate: float = 0.0
            link_only_reachability_pct: float = 0.0
            # Per-top-level-directory counts (issue #365) over the same curated layer
            # as the headline. While len(directory_breakdown) == directory_count the
            # rows sum to doc_count, len(unreachable) and dangling_links; a list cut
            # at MAX_DIRECTORY_BREAKDOWN sums to less.
            # [{path, doc_count, unreachable_count, broken_link_count}]
            directory_breakdown: list[dict] = field(default_factory=list)
            directory_count: int = 0
            # Missing cross-references: a doc names another doc but never links to it
            # (Karpathy Lint). [{from, to}].
            missing_xrefs: list[dict] = field(default_factory=list)
            ambiguous_wikilinks: int = 0
            vault_detected: bool = False
            obsidiantools_available: bool = False
            # Full per-doc PageRank, keyed by rel path. Sizes the docs-staleness
            # heatmap (a stale hub must dominate). Kept off as_dict() so run-context
            # stays lean on doc-heavy repos -- the top-10 hubs are serialised instead.
            pagerank: dict[str, float] = field(default_factory=dict)
            # The underlying networkx DiGraph (nodes = doc rel-paths, edges = doc->doc).
            # Kept off as_dict(); the connectivity-graph SVG renderer needs the full
            # edge list that the serialised signals don't carry.
            graph: object = None
        
            def as_dict(self) -> dict:
                return {
                    "available": self.available,
                    "reason": self.reason,
                    "doc_count": self.doc_count,
                    "edge_count": self.edge_count,
                    "hubs": self.hubs,
                    "orphans": self.orphans,
                    "orphan_rate": round(self.orphan_rate, 3),
                    "island_count": self.island_count,
                    "reachability_pct": round(self.reachability_pct, 3),
                    "entry_points": self.entry_points,
                    "unreachable": self.unreachable,
                    "declared_mocs": self.declared_mocs,
                    "moc_named_but_not_wired": self.moc_named_but_not_wired,
                    "doc_to_code_edges": self.doc_to_code_edges,
                    "dangling_links": self.dangling_links,
                    "broken_links": self.broken_links,
                    "missing_xrefs": self.missing_xrefs,
                    "ambiguous_wikilinks": self.ambiguous_wikilinks,
                    "vault_detected": self.vault_detected,
                    "obsidiantools_available": self.obsidiantools_available,
                    "excluded_raw_trees": self.excluded_raw_trees,
                    "raw_source_doc_count": self.raw_source_doc_count,
                    "curated_doc_count": self.curated_doc_count,
                    "raw_source_orphan_rate": round(self.raw_source_orphan_rate, 3),
                    "raw_source_broken_links": self.raw_source_broken_links,
                    "excluded_working_notes_trees": self.excluded_working_notes_trees,
                    "working_notes_doc_count": self.working_notes_doc_count,
                    "working_notes_orphan_rate": round(self.working_notes_orphan_rate, 3),
                    "working_notes_broken_links": self.working_notes_broken_links,
                    "link_only_orphan_rate": round(self.link_only_orphan_rate, 3),
                    "link_only_reachability_pct": round(self.link_only_reachability_pct, 3),
                    "directory_breakdown": self.directory_breakdown,
                    "directory_count": self.directory_count,
                }
        
        
        def is_repo_file(path: Path, repo_root: Path, tracked: set[Path] | frozenset[Path] | None) -> bool:
            """True if `path` is genuinely part of the repo.
        
            Excludes two classes of non-repo file the scan must ignore:
              - symlinks (or rglob escapes) whose *resolved* path lands outside the
                repo - e.g. a CLAUDE.md symlinked to the user's home;
              - untracked / git-ignored files when the repo is under git (e.g. a
                contributor's personal notes left in the working tree). `tracked` is
                None for non-git trees, in which case only the symlink guard applies.
            """
            try:
                real = path.resolve()
            except OSError:
                return False
            if not real.is_relative_to(repo_root):
                return False
            if tracked is not None and real not in tracked:
                return False
            return True
        
        
        def _discover_files(
            repo_root: Path,
            extensions: set[str],
            extra_exclude_dirs: set[str] | None = None,
            extra_exclude_patterns: list[str] | None = None,
            scope: Path | None = None,
        ) -> list[Path]:
            """Return all in-repo files of the given extensions, skipping excluded dirs.
        
            The single walk shared by the markdown-doc and `.base`-hub discovery so both
            honour the identical exclude resolution (built-in defaults + user excludes).
        
            `scope` (an absolute path under `repo_root`) restricts discovery to a
            subtree for `/assess <path>` monorepo scoping; omit it (the default) for a
            whole-repo run, in which case the result is unchanged.
            """
            from lib.assess_config import is_user_excluded
            repo_root = repo_root.resolve()
            scope_abs = scope.resolve() if scope is not None else None
            tracked = tracked_files(repo_root)
            extra_dirs = extra_exclude_dirs or set()
            extra_pats = extra_exclude_patterns or []
            found: list[Path] = []
            for path in repo_root.rglob("*"):
                if not path.is_file() or path.suffix.lower() not in extensions:
                    continue
                if scope_abs is not None and not path.resolve().is_relative_to(scope_abs):
                    continue
                try:
                    rel = path.relative_to(repo_root)
                except ValueError:
                    continue
                if is_excluded_path(rel):
                    continue
                if is_user_excluded(rel, extra_dirs, extra_pats):
                    continue
                if not is_repo_file(path, repo_root, tracked):
                    continue
                found.append(path)
            return sorted(found)
        
        
        def discover_doc_files(
            repo_root: Path,
            extra_exclude_dirs: set[str] | None = None,
            extra_exclude_patterns: list[str] | None = None,
            scope: Path | None = None,
        ) -> list[Path]:
            """Return all in-repo markdown docs under repo_root, skipping excluded dirs."""
            return _discover_files(
                repo_root, DOC_EXTENSIONS, extra_exclude_dirs, extra_exclude_patterns,
                scope=scope,
            )
        
        
        def discover_base_files(
            repo_root: Path,
            extra_exclude_dirs: set[str] | None = None,
            extra_exclude_patterns: list[str] | None = None,
            scope: Path | None = None,
        ) -> list[Path]:
            """Return all in-repo Obsidian Bases (`.base`) files, skipping excluded dirs."""
            return _discover_files(
                repo_root, BASE_EXTENSIONS, extra_exclude_dirs, extra_exclude_patterns,
                scope=scope,
            )
        
        
        def _strip_anchor_and_alias(target: str) -> str:
            """Drop a `|alias` (wikilink) and `#anchor` / `?query` from a link target."""
            target = target.split("|", 1)[0]
            target = target.split("#", 1)[0]
            target = target.split("?", 1)[0]
            return target.strip()
        
        
        def _vault_detected(repo_root: Path) -> bool:
            """True if the repo is, or contains, an Obsidian vault.
        
            A vault is rooted at the directory holding `.obsidian/`, but that root is
            not always the scan target: `/assess` scans from `git rev-parse
            --show-toplevel`, so a vault kept as a subdirectory of a git repo
            (`repo/notes/.obsidian/`) puts `.obsidian/` *below* repo_root. The old
            `repo_root/.obsidian` check only saw a vault rooted exactly at the scan
            target and reported `false` for the nested case - a false negative that
            silently disabled every downstream vault accommodation (#179).
        
            We therefore check repo_root and walk its subtree, pruning the same
            non-navigational trees the doc scan skips (`EXCLUDE_DIRS` - `node_modules`,
            `vendor`, ...) so a vendored or build-artifact `.obsidian/` can't trip a
            false positive, while a real vault is found wherever it sits in the repo.
            """
            repo_root = repo_root.resolve()
            if (repo_root / ".obsidian").is_dir():
                return True
            for _dirpath, dirnames, _filenames in os.walk(repo_root):
                if ".obsidian" in dirnames:
                    return True
                # Prune heavy / non-navigational subtrees from the descent. `.obsidian`
                # is itself in EXCLUDE_DIRS, but we've already matched it above before
                # pruning, so it never gets removed out from under the search.
                dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
            return False
        
        
        def _obsidiantools_available() -> bool:
            try:  # optional accelerator; never required.
                import obsidiantools  # noqa: F401
        
                return True
            except ImportError:
                return False
        
        
        def _build_name_index(
            docs: list[Path], repo_root: Path,
        ) -> tuple[dict[str, Path], dict[str, list[Path]], dict[str, list[Path]]]:
            """Indexes for resolving wikilinks: by relative-path, by basename, by stem.
        
            Name collisions (two `setup.md` files) are why `Path(link).stem` alone is
            too naive -- by_stem maps a stem to *every* candidate so the resolver can
            disambiguate (prefer same-directory) instead of silently picking one.
            """
            by_relpath: dict[str, Path] = {}
            by_name: dict[str, list[Path]] = {}
            by_stem: dict[str, list[Path]] = {}
            for d in docs:
                rel = d.relative_to(repo_root)
                by_relpath[str(rel).lower()] = d
                by_relpath[str(rel.with_suffix("")).lower()] = d
                by_name.setdefault(d.name.lower(), []).append(d)
                by_stem.setdefault(d.stem.lower(), []).append(d)
            return by_relpath, by_name, by_stem
        
        
        def _resolve_wikilink(
            raw: str,
            source: Path,
            repo_root: Path,
            by_relpath: dict[str, Path],
            by_name: dict[str, list[Path]],
            by_stem: dict[str, list[Path]],
        ) -> tuple[Path | None, bool]:
            """Resolve a wikilink target to a doc path. Returns (path, ambiguous)."""
            target = _strip_anchor_and_alias(raw)
            if not target:
                return None, False
            key = target.lower()
            # Path-qualified wikilink (`[[folder/note]]`): try the relative-path index,
            # which is keyed by both the suffixed and suffix-stripped relpath, so
            # `[[folder/note]]` and `[[folder/note.md]]` both resolve here.
            if "/" in target or "\\" in target:
                norm = key.replace("\\", "/")
                if norm in by_relpath:
                    return by_relpath[norm], False
            # Bare note name: try basename (with and without .md), then stem.
            candidates: list[Path] = []
            if key in by_name:
                candidates = by_name[key]
            elif f"{key}.md" in by_name:
                candidates = by_name[f"{key}.md"]
            elif key in by_stem:
                candidates = by_stem[key]
            if not candidates:
                return None, False
            if len(candidates) == 1:
                return candidates[0], False
            # Collision: prefer a candidate in the same directory as the source.
            same_dir = [c for c in candidates if c.parent == source.parent]
            if len(same_dir) == 1:
                return same_dir[0], True
            return sorted(candidates)[0], True  # deterministic fallback
        
        
        def _resolve_mdlink(
            raw: str, source: Path, repo_root: Path,
        ) -> Path | None:
            """Resolve a CommonMark relative link target to a real file path."""
            target = raw.strip()
            if not target or target.startswith("#"):
                return None
            if _EXTERNAL_RE.match(target):
                return None
            target = _strip_anchor_and_alias(target)
            if not target:
                return None
            # Absolute-from-repo-root ("/docs/x.md") vs relative-to-this-doc.
            if target.startswith("/"):
                candidate = (repo_root / target.lstrip("/"))
            else:
                candidate = (source.parent / target)
            try:
                resolved = candidate.resolve()
            except (OSError, RuntimeError):
                return None
            if not resolved.is_file():
                return None
            try:
                resolved.relative_to(repo_root.resolve())
            except ValueError:
                return None
            return resolved
        
        
        def _target_exists(raw: str, source: Path, repo_root: Path) -> bool:
            """True if a relative link resolves to an existing path (file OR directory)
            within the repo. Used so a link to a folder (`docs/guides/`) isn't mistaken
            for a broken link just because it isn't a file."""
            target = _strip_anchor_and_alias(raw)
            if not target or target.startswith("#") or _EXTERNAL_RE.match(target):
                return False
            candidate = (repo_root / target.lstrip("/")) if target.startswith("/") else (source.parent / target)
            try:
                resolved = candidate.resolve()
            except (OSError, RuntimeError):
                return False
            if not resolved.exists():
                return False
            try:
                resolved.relative_to(repo_root.resolve())
            except ValueError:
                return False
            return True
        
        
        def _cited_excluded_doc(
            rel_path: str, repo_root: Path, tracked, scope: Path | None,
            extra_dirs: set[str], extra_pats: list[str],
        ) -> Path | None:
            """The `.claude/` doc at `rel_path`, if a reference may bring it in.
        
            `.claude` stays in `EXCLUDE_DIRS` for the walk, so an uncited agent file is
            never a node; a cited one is navigation an agent follows and joins the
            graph. Every other exclusion (built-in, user, untracked, out of scope)
            still applies.
            """
            from lib.assess_config import is_user_excluded
            parts = Path(rel_path).parts
            if ".claude" not in parts or ".." in parts:
                return None
            if Path(rel_path).suffix.lower() not in DOC_EXTENSIONS:
                return None
            if is_excluded_path(Path(*[x for x in parts if x != ".claude"])):
                return None
            if is_user_excluded(Path(rel_path), extra_dirs, extra_pats):
                return None
            path = repo_root / rel_path
            if not path.is_file() or not is_repo_file(path, repo_root, tracked):
                return None
            if scope is not None and not path.resolve().is_relative_to(scope.resolve()):
                return None
            return path.resolve()
        
        
        # Any indentation (a fence nested under a list item sits four or more spaces
        # in), behind any CommonMark container prefix: blockquote `>` markers and a
        # list-item marker (`- ~~~`, `1. ~~~`).
        _FENCE_OPEN_RE = re.compile(
            r"^[ \t]*(?:>[ \t]*)*(?:(?:[-*+]|\d+[.)])[ \t]+)?(`{3,}|~{3,})"
        )
        
        
        def _strip_fenced_lines(text: str) -> str:
            """Drop CommonMark fenced blocks line by line: backtick or tilde fences,
            closed only by the same marker at least as long as the opener. An
            unclosed fence runs to the end of the document."""
            out: list[str] = []
            fence = ""
            for line in text.splitlines():
                m = _FENCE_OPEN_RE.match(line)
                if not fence:
                    if m and not (m.group(1)[0] == "`" and "`" in line[m.end():]):
                        fence = m.group(1)
                    else:
                        out.append(line)
                elif m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) \
                        and not line[m.end():].strip():
                    fence = ""
            return "\n".join(out)
        
        
        def _reference_paths(text: str, source_rel: str) -> list[tuple[str, str]]:
            """Doc paths named by backticked tokens outside fences, as
            `(raw_ref, doc_relative_candidate)` pairs, in document order.
        
            Reuses the ownership parser's path-token rules. A span that holds link
            syntax (`[[x]]`, `[x](y)`) is a teaching sample, not a citation, so it is
            skipped here just as the link pass strips it.
            """
            from lib.ownership_parser import _extract_path_refs
            out: list[tuple[str, str]] = []
            for m in _INLINE_CODE_RE.finditer(_strip_fenced_lines(text)):
                span = m.group(0)
                if "[[" in span or "](" in span:
                    continue
                for ref in sorted(_extract_path_refs(span, tuple(DOC_EXTENSIONS))):
                    if Path(ref).suffix.lower() not in DOC_EXTENSIONS:
                        continue
                    local = ref.lstrip("/") if ref.startswith("/") else posixpath.normpath(
                        posixpath.join(posixpath.dirname(source_rel), ref))
                    out.append((ref, local))
            return out
        
        
        def _read_doc(path: Path) -> str | None:
            """A doc's text, or None when it cannot be read (Layer 0 is best-effort)."""
            try:
                return path.read_text(encoding="utf-8", errors="ignore")
            except OSError:
                return None
        
        
        def _basename_index(rels) -> dict[str, list[str]]:
            """Repo-relative doc paths grouped by basename, built once per graph."""
            out: dict[str, list[str]] = {}
            for r in rels:
                out.setdefault(posixpath.basename(r), []).append(r)
            return out
        
        
        def _resolve_references(
            text: str, source_rel: str, repo_root: Path,
            doc_by_rel: dict[str, Path], doc_rels: set[str], by_basename: dict[str, list[str]],
            cite,
        ) -> list[Path]:
            """Docs named by backticked paths in `text`, exact paths before guesses:
            the doc-relative path (walked doc or cited `.claude/` doc), then the
            ownership parser's resolver over the walked docs (repo-root path or a
            basename that names exactly one doc), then a cited `.claude/` doc at the
            literal path. `cite` is `_cited_excluded_doc` bound to the run's excludes.
        
            `doc_by_rel` / `doc_rels` hold the walked docs only and never grow, so a
            doc's references depend on its own text and the walk, not on read order.
            """
            from lib.ownership_parser import _resolve_ref
            found: list[Path] = []
            for ref, local in _reference_paths(text, source_rel):
                hit = doc_by_rel.get(local) or cite(local)
                if hit is None:
                    # A bare basename reads the prebuilt index instead of the resolver's
                    # per-call sweep of every doc; a path (or a root-level exact name,
                    # which the resolver prefers over a basename match) goes through it.
                    hits = (set(by_basename.get(ref, [])) if "/" not in ref and ref not in doc_rels
                            else {str(x) for x in _resolve_ref(ref, repo_root, doc_rels)})
                    hit = doc_by_rel[next(iter(hits))] if len(hits) == 1 else cite(ref.lstrip("/"))
                if hit is not None:
                    found.append(hit)
            return found
        
        
        def _settle_references(
            docs: list[Path], texts: dict[Path, str], rel, resolve,
        ) -> list[tuple[Path, Path]]:
            """First pass: read every doc into `texts` and resolve its reference edges.
        
            A cited `.claude/` doc is appended to `docs` and read in turn, so the link
            pass that follows sees the final doc set (and name index) whatever order
            the walk produced. Returns `(source, target)` pairs, self-citations dropped.
            """
            seen = set(docs)
            pairs: list[tuple[Path, Path]] = []
            for d in docs:  # grows while iterating: cited .claude docs join the queue
                text = _read_doc(d)
                if text is None:
                    continue
                texts[d] = text
                for tgt in resolve(text, rel(d)):
                    if tgt not in seen:
                        seen.add(tgt)
                        docs.append(tgt)
                    if tgt != d:
                        pairs.append((d, tgt))
            return pairs
        
        
        def _missing_xrefs(docs, texts: dict, graph, repo_root: Path, rel) -> list[dict]:
            """Docs that name another doc's filename in prose but never link to it
            (Karpathy Lint: "missing cross-references").
        
            High-precision: matches the exact filename (e.g. `payments.md`) outside
            fenced code, only for non-conventional target docs, and only when no link
            to that target already exists.
            """
            name_to_doc: dict[str, Path] = {
                d.name.lower(): d for d in docs if d.name.lower() not in _XREF_SKIP_NAMES
            }
            if not name_to_doc:
                return []
            alt = "|".join(re.escape(n) for n in sorted(name_to_doc, key=len, reverse=True))
            pattern = re.compile(r"(?<![\w./-])(" + alt + r")\b", re.IGNORECASE)
            edges = set(graph.edges())
            out: list[dict] = []
            for d in docs:
                text = texts.get(d)
                if not text:
                    continue
                body = _strip_fenced_lines(text)
                seen: set[Path] = set()
                for m in pattern.finditer(body):
                    t = name_to_doc.get(m.group(1).lower())
                    if t is None or t == d or t in seen:
                        continue
                    seen.add(t)
                    if (rel(d), rel(t)) not in edges:  # already linked -> not missing
                        out.append({"from": rel(d), "to": rel(t)})
            return out
        
        
        def radial_shells(graph, entries, ring: int = 24) -> list[list[str]]:
            """Order nodes into concentric shells by link-distance from the entry points.
        
            Shell 0 = the entry points; shell k = docs k hops away (following links);
            then the unreachable docs, chunked into progressively larger outer rings.
            Pure graph traversal - no layout - so it's unit-testable without numpy.
            """
            dist: dict[str, int] = {e: 0 for e in entries if e in graph}
            frontier = list(dist)
            while frontier:
                nxt = []
                for u in frontier:
                    for v in graph.successors(u):
                        if v not in dist:
                            dist[v] = dist[u] + 1
                            nxt.append(v)
                frontier = nxt
            all_nodes = list(graph.nodes())
            max_d = max(dist.values(), default=0)
            shells = [sorted(n for n in all_nodes if dist.get(n) == d) for d in range(max_d + 1)]
            unreachable = sorted(n for n in all_nodes if n not in dist)
            i, cap = 0, ring
            while i < len(unreachable):
                shells.append(unreachable[i:i + cap])
                i += cap
                cap += 12
            return [s for s in shells if s]
        
        
        def classify_node(node: str, entries: set, unreachable: set, orphans: set) -> str:
            """Navigability status of a node: entry / reachable / orphan / island."""
            if node in entries:
                return "entry"
            if node not in unreachable:
                return "reachable"
            if node in orphans:
                return "orphan"
            return "island"
        
        
        def build_doc_graph(  # noqa: C901  # graph assembly + link resolution; ccn 21, ratchet target
            repo_root: Path, doc_files: list[Path] | None = None,
            extra_exclude_dirs: set[str] | None = None,
            extra_exclude_patterns: list[str] | None = None,
            scope: Path | None = None,
            working_notes_dirs: list[str] | None = None,
            working_notes_ignore: list[str] | None = None,
        ) -> DocGraphResult:
            """Parse docs, build the link graph, and derive navigability signals.
        
            `scope` (an absolute path under `repo_root`) restricts the graph to docs
            within a subtree for `/assess <path>` monorepo scoping; omit it for a
            whole-repo run. `.base` hub discovery honours the same scope so a scoped
            graph carries no navigation signal from a sibling directory.
            `working_notes_dirs` / `working_notes_ignore` are the `.assess/config.toml`
            overrides (`lib.assess_config.load_working_notes_config`) that force or
            suppress working-notes classification for repo-relative directories.
            """
            repo_root = repo_root.resolve()
            vault = _vault_detected(repo_root)
            obs = _obsidiantools_available()
        
            if not _NETWORKX_AVAILABLE:
                return DocGraphResult(
                    available=False,
                    reason="networkx not installed; doc link-graph not assessed",
                    vault_detected=vault,
                    obsidiantools_available=obs,
                )
        
            docs = (
                doc_files if doc_files is not None
                else discover_doc_files(
                    repo_root,
                    extra_exclude_dirs=extra_exclude_dirs,
                    extra_exclude_patterns=extra_exclude_patterns,
                    scope=scope,
                )
            )
            docs = [d.resolve() for d in docs]
            if not docs:
                return DocGraphResult(
                    available=True, reason="no markdown docs found", doc_count=0,
                    vault_detected=vault, obsidiantools_available=obs,
                )
        
            def rel(p: Path) -> str:
                return str(p.relative_to(repo_root))
        
            # Reference edges (issue #353) settle first: a backticked token naming an
            # existing doc. A cited `.claude/` doc joins `docs` here, before the name
            # index and the link pass, so links and wikilinks reach it from any doc.
            texts: dict[Path, str] = {}
            discovered = list(docs)  # the walked set; `docs` grows with cited .claude docs
            doc_by_rel = {rel(x): x for x in discovered}
            cite = partial(
                _cited_excluded_doc, repo_root=repo_root, tracked=tracked_files(repo_root),
                scope=scope, extra_dirs=extra_exclude_dirs or set(),
                extra_pats=extra_exclude_patterns or [],
            )
            ref_pairs = _settle_references(docs, texts, rel, partial(
                _resolve_references, repo_root=repo_root, doc_by_rel=doc_by_rel,
                doc_rels=set(doc_by_rel), by_basename=_basename_index(doc_by_rel), cite=cite,
            ))
        
            by_relpath, by_name, by_stem = _build_name_index(docs, repo_root)
            doc_set = set(docs)
        
            graph = nx.DiGraph()
            for d in docs:
                graph.add_node(rel(d))
        
            doc_to_code: list[dict] = []
            ambiguous = 0
            broken: list[dict] = []
            _broken_seen: set[tuple[str, str]] = set()
            # Per-doc count of non-navigational URI-scheme links (mailto:/tel:/external
            # http) - the machine-extraction fingerprint a converted document carries.
            # Feeds raw-source-tree detection (issue #225).
            machine_links: dict[str, int] = {}
        
            def _add_broken(src: Path, target: str, kind: str) -> None:
                key = (rel(src), target)
                if target and key not in _broken_seen:
                    _broken_seen.add(key)
                    broken.append({"from": rel(src), "target": target, "kind": kind})
        
            for d in docs:
                text = texts.get(d)
                if text is None:  # unreadable: skipped, Layer 0 stays best-effort
                    continue
                # Strip code spans before harvesting links: a link target inside a
                # fence or backtick span is a documentation sample (FORMAT specs,
                # wikilink-syntax demos), not a navigation edge.
                link_text = _strip_code_spans(text)
                # Wikilinks resolve by note name across the vault.
                for m in _WIKILINK_RE.finditer(link_text):
                    # Strip alias/anchor first so the scheme check sees the bare target
                    # (e.g. `tel:+1-555-1234` from `[[tel:+1-555-1234|Call us]]`).
                    wikilink_target = _strip_anchor_and_alias(m.group(1))
                    if _EXTERNAL_RE.match(wikilink_target):
                        # Non-navigational URI (`tel:`, `mailto:`, etc.) -- not a note
                        # reference; skip without counting as a broken link (issue #227).
                        # Count it as a machine-extraction fingerprint (issue #225).
                        machine_links[rel(d)] = machine_links.get(rel(d), 0) + 1
                        continue
                    tgt, amb = _resolve_wikilink(
                        m.group(1), d, repo_root, by_relpath, by_name, by_stem,
                    )
                    if amb:
                        ambiguous += 1
                    if tgt is None:
                        _add_broken(d, wikilink_target, "wikilink")
                        continue
                    if tgt in doc_set and tgt != d:
                        graph.add_edge(rel(d), rel(tgt), kind="link")
                # CommonMark links resolve relative to the doc's directory.
                for m in _MDLINK_RE.finditer(link_text):
                    raw = m.group(1)
                    tgt = _resolve_mdlink(raw, d, repo_root)
                    if tgt is None:
                        # A relative-looking link that resolves to nothing is broken
                        # (a "ghost"). External URLs, pure #anchors, and links to an
                        # existing directory are not broken.
                        cleaned = _strip_anchor_and_alias(raw)
                        if cleaned and _EXTERNAL_RE.match(cleaned):
                            # Non-navigational URI (mailto:/tel:/external http): the
                            # machine-extraction fingerprint, not a broken link (#225).
                            machine_links[rel(d)] = machine_links.get(rel(d), 0) + 1
                        elif (cleaned and not raw.strip().startswith("#")
                                and not _target_exists(raw, d, repo_root)):
                            _add_broken(d, cleaned, "mdlink")
                        continue
                    if tgt.suffix.lower() in DOC_EXTENSIONS and tgt in doc_set:
                        if tgt != d:
                            graph.add_edge(rel(d), rel(tgt), kind="link")
                    elif tgt.suffix.lower() in CODE_EXTENSIONS:
                        doc_to_code.append({"doc": rel(d), "code": rel(tgt)})
            # A link between the same pair keeps kind link.
            graph.add_edges_from([
                (rel(src), rel(tgt)) for src, tgt in ref_pairs
                if not graph.has_edge(rel(src), rel(tgt))
            ], kind="reference")
        
            missing = _missing_xrefs(discovered, texts, graph, repo_root, rel)
        
            # Vault-native navigation: `.base` view hubs + ```dataview``` query blocks
            # surface notes dynamically, so a static-link-only graph scores a navigable
            # vault as orphaned (issue #176). Recognise those query hubs as edge sources.
            base_hubs = _apply_vault_edges(
                graph, repo_root, docs, texts, rel,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
                scope=scope,
            )
        
            # Raw-source-tree exclusion (issue #225). Detect subtrees of raw,
            # machine-extracted source documents - link-isolated and carrying the
            # machine-extraction fingerprint - and exclude them from the headline
            # read-side metrics so the curated-wiki signal isn't drowned. Detection runs
            # on the *final* graph (after vault edges), so a doc made navigable by a
            # `.base` hub or dataview query is not misread as raw.
            raw_docs, raw_trees = _detect_raw_trees(
                graph, docs, repo_root, rel, base_hubs, machine_links,
            )
            # Working-notes trees (issue #366) are the second fingerprint, detected on
            # what the raw pass leaves so no doc belongs to both layers.
            notes_docs, notes_trees = _detect_working_notes_trees(
                graph, {rel(d) for d in docs} - raw_docs,
                force=working_notes_dirs or [], ignore=working_notes_ignore or [],
            )
            excluded_docs = raw_docs | notes_docs
            curated_docs = [d for d in docs if rel(d) not in excluded_docs]
            curated_nodes = [n for n in graph.nodes() if n not in excluded_docs]
            curated_graph = graph.subgraph(curated_nodes).copy()
            curated_broken = [b for b in broken if b.get("from") not in excluded_docs]
            curated_missing = [
                mx for mx in missing
                if mx.get("from") not in excluded_docs and mx.get("to") not in excluded_docs
            ]
        
            result = _derive_signals(
                graph=curated_graph, docs=curated_docs, repo_root=repo_root, rel=rel,
                doc_to_code=doc_to_code, dangling=len(curated_broken), ambiguous=ambiguous,
                vault=vault, obs=obs, base_hubs=base_hubs,
            )
            link_graph = nx.DiGraph()
            link_graph.add_nodes_from(curated_graph)
            link_graph.add_edges_from(
                (u, v) for u, v, k in curated_graph.edges(data="kind") if k != "reference"
            )
            link_only = _derive_signals(
                graph=link_graph, docs=curated_docs, repo_root=repo_root, rel=rel,
                doc_to_code=doc_to_code, dangling=0, ambiguous=0,
                vault=vault, obs=obs, base_hubs=base_hubs, entries=result.entry_points,
            )
            result.link_only_orphan_rate = link_only.orphan_rate
            result.link_only_reachability_pct = link_only.reachability_pct
            result.broken_links = curated_broken[:MAX_BROKEN_LINKS]
            result.missing_xrefs = curated_missing[:MAX_MISSING_XREFS]
            rows = _directory_breakdown(curated_nodes, result.unreachable, curated_broken)
            result.directory_breakdown = rows[:MAX_DIRECTORY_BREAKDOWN]
            result.directory_count = len(rows)
        
            # Excluded-layer figures, reported separately so the exclusion stays legible.
            result.curated_doc_count = result.doc_count
            (result.excluded_raw_trees, result.raw_source_doc_count,
             result.raw_source_orphan_rate, result.raw_source_broken_links,
             ) = _layer_figures(graph, broken, raw_docs, raw_trees)
            (result.excluded_working_notes_trees, result.working_notes_doc_count,
             result.working_notes_orphan_rate, result.working_notes_broken_links,
             ) = _layer_figures(graph, broken, notes_docs, notes_trees)
            return result
        
        
        def _top_dir(rel_path: str) -> str:
            """First path segment of a doc's rel path; root-level docs key as ``.``."""
            head, sep, _ = rel_path.partition("/")
            return head if sep else "."
        
        
        def _directory_breakdown(
            nodes, unreachable: list[str], broken: list[dict],
        ) -> list[dict]:
            """Doc, unreachable and broken-link counts per top-level directory, largest
            gap first. A broken link counts toward the directory of the doc it is
            written in (``from``)."""
            counts: dict[str, list[int]] = {}
            for n in nodes:
                counts.setdefault(_top_dir(n), [0, 0, 0])[0] += 1
            for n in unreachable:
                counts[_top_dir(n)][1] += 1
            for b in broken:
                counts.setdefault(_top_dir(b.get("from", "")), [0, 0, 0])[2] += 1
            ranked = sorted(counts.items(), key=lambda kv: (-kv[1][1], -kv[1][2], -kv[1][0], kv[0]))
            return [
                {"path": d, "doc_count": c[0], "unreachable_count": c[1], "broken_link_count": c[2]}
                for d, c in ranked
            ]
        
        
        def _layer_figures(
            graph, broken: list[dict], layer_docs: set[str], trees: list[dict],
        ) -> tuple[list[dict], int, float, int]:
            """An excluded layer's own figures: its trees as ``{path, file_count}``,
            doc count, orphan rate over the full graph, and broken links it holds."""
            in_deg = dict(graph.in_degree())
            n = len(layer_docs)
            orphans = sum(1 for r in layer_docs if in_deg.get(r, 0) == 0)
            return (
                [{"path": t["path"], "file_count": t["file_count"]} for t in trees],
                n,
                (orphans / n) if n else 0.0,
                sum(1 for b in broken if b.get("from") in layer_docs),
            )
        
        
        def _detect_working_notes_trees(
            graph, doc_rels: set[str], *, force: list[str], ignore: list[str],
        ) -> tuple[set[str], list[dict]]:
            """Detect working-notes subtrees and return (excluded_doc_rels, trees).
        
            ``doc_rels`` is the doc set minus raw-source docs; like the raw pass it
            never classifies a non-doc node (a ``.base`` hub). Signals come from the
            headline graph (link and reference edges) restricted to those docs: each doc's in-degree and the docs its inbound
            edges come from, so the classifier can tell one index holding the links
            from a wiki whose links are spread out. The verdict is
            ``lib.raw_source.classify_working_notes_trees``; ``force`` / ``ignore``
            are the config overrides, passed through.
            """
            from lib.raw_source import classify_working_notes_trees
        
            signals: dict[str, dict] = {}
            for r in sorted(doc_rels):
                sources = [u for u in graph.predecessors(r) if u in doc_rels]
                signals[r] = {"in_degree": len(sources), "inbound_sources": sources}
            trees = classify_working_notes_trees(signals, force=force, ignore=ignore)
            return {r for t in trees for r in t["docs"]}, trees
        
        
        def _detect_raw_trees(
            graph, docs: list[Path], repo_root: Path, rel, base_hubs: list[str],
            machine_links: dict[str, int],
        ) -> tuple[set[str], list[dict]]:
            """Detect raw-source subtrees and return (excluded_doc_rels, raw_trees).
        
            Assembles the per-doc graph signals (in/out degree from the final graph plus
            the machine-extraction fingerprint count) and delegates the threshold-based
            verdict to ``lib.raw_source.classify_raw_trees``. ``base_hubs`` and the
            README/MOC entries are passed as entry points so a doc reachable through a
            dynamic navigation surface is never counted toward a subtree's isolation.
            """
            from lib.raw_source import classify_raw_trees
        
            in_deg = dict(graph.in_degree())
            out_deg = dict(graph.out_degree())
            doc_rels = {rel(d) for d in docs}
            doc_signals = {
                r: {
                    "in_degree": in_deg.get(r, 0),
                    "out_degree": out_deg.get(r, 0),
                    "machine_links": machine_links.get(r, 0),
                }
                for r in doc_rels
            }
            pagerank = _pagerank(graph)
            entries = set(_pick_entry_points(docs, repo_root, pagerank, rel, base_hubs))
            raw_trees = classify_raw_trees(doc_signals, entries=entries)
            excluded: set[str] = set()
            for tree in raw_trees:
                excluded.update(tree["docs"])
            return excluded, raw_trees
        
        
        def _apply_vault_edges(
            graph, repo_root: Path, docs: list[Path], texts: dict[Path, str], rel,
            *, extra_exclude_dirs: set[str] | None = None,
            extra_exclude_patterns: list[str] | None = None,
            scope: Path | None = None,
        ) -> list[str]:
            """Add Obsidian Bases (`.base`) and Dataview query edges to `graph`.
        
            Mutates `graph` in place and returns the rel-paths of the `.base` hub nodes
            it added. A `.base` hub is a dynamic navigation surface (you open it to see
            its notes), so it is treated as an entry point for reachability. Dataview
            query blocks live inside existing notes, so their edges originate from the
            note that declares them - no new node.
            """
            from lib.vault_queries import (
                parse_base_queries,
                parse_dataview_queries,
                parse_frontmatter,
                select_notes,
            )
        
            doc_rels: list[tuple[Path, Path]] = [(d, d.relative_to(repo_root)) for d in docs]
            _fm_cache: dict[Path, dict[str, object]] = {}
        
            def frontmatter_of(d: Path) -> dict[str, object]:
                if d not in _fm_cache:
                    _fm_cache[d] = parse_frontmatter(texts.get(d, ""))
                return _fm_cache[d]
        
            # Dataview blocks: edges from the declaring note to the notes it selects.
            for d in docs:
                text = texts.get(d)
                if not text:
                    continue
                for query in parse_dataview_queries(text):
                    for tgt in select_notes(query, doc_rels, frontmatter_of):
                        if tgt != d:
                            graph.add_edge(rel(d), rel(tgt), kind="link")
        
            # `.base` hubs: a new hub node with edges to every note its query selects.
            base_hubs: list[str] = []
            for bf in discover_base_files(
                repo_root, extra_exclude_dirs, extra_exclude_patterns, scope=scope
            ):
                try:
                    btext = bf.read_text(encoding="utf-8", errors="ignore")
                except OSError:
                    continue
                targets: set[Path] = set()
                for query in parse_base_queries(btext):
                    targets |= select_notes(query, doc_rels, frontmatter_of)
                if not targets:  # a base that selects nothing is not a hub edge source
                    continue
                hub = rel(bf)
                if hub not in graph:
                    graph.add_node(hub)
                base_hubs.append(hub)
                for tgt in targets:
                    graph.add_edge(hub, rel(tgt), kind="link")
            return base_hubs
        
        
        def _pagerank(graph, alpha: float = 0.85, max_iter: int = 100,
                      tol: float = 1e-9) -> dict[str, float]:
            """PageRank by pure-Python power iteration (with dangling-node handling).
        
            networkx's own ``pagerank`` routes through a scipy/numpy backend, which the
            deterministic core deliberately does not depend on. This keeps centrality
            working with networkx alone — the graph structure is networkx's; only the
            iteration is local. Semantics match ``nx.pagerank`` (dangling rank is
            redistributed uniformly each step).
            """
            nodes = list(graph.nodes())
            n = len(nodes)
            if n == 0:
                return {}
            if graph.number_of_edges() == 0:
                return {x: 1.0 / n for x in nodes}
            out_deg = dict(graph.out_degree())
            pr = {x: 1.0 / n for x in nodes}
            for _ in range(max_iter):
                prev = pr
                dangling = sum(prev[x] for x in nodes if out_deg[x] == 0)
                base = (1.0 - alpha) / n + alpha * dangling / n
                nxt = {x: base for x in nodes}
                for src in nodes:
                    d = out_deg[src]
                    if d == 0:
                        continue
                    share = alpha * prev[src] / d
                    for dst in graph.successors(src):
                        nxt[dst] += share
                err = sum(abs(nxt[x] - prev[x]) for x in nodes)
                pr = nxt
                if err < tol:
                    break
            return pr
        
        
        def _is_declared_moc(path: Path) -> bool:
            name = path.name.lower()
            if name in MOC_BASENAMES:
                return True
            return bool(_MOC_STEM_RE.search(path.stem))
        
        
        def _pick_entry_points(
            docs: list[Path], repo_root: Path, pagerank: dict[str, float], rel,
            base_hubs: list[str] | None = None,
        ) -> list[str]:
            """Entry roots for reachability: root-level README/AGENTS/CLAUDE/index, the
            single highest-PageRank declared MOC, plus any `.base` view hub (a dynamic
            navigation surface). Falls back to the top doc overall so reachability is
            always computable."""
            entries: list[str] = []
            for d in docs:
                r = d.relative_to(repo_root)
                if len(r.parts) == 1 and r.name.lower() in ENTRY_BASENAMES:
                    entries.append(rel(d))
            mocs = [(rel(d), pagerank.get(rel(d), 0.0)) for d in docs if _is_declared_moc(d)]
            if mocs:
                top_moc = max(mocs, key=lambda x: x[1])[0]
                if top_moc not in entries:
                    entries.append(top_moc)
            # `.base` hubs are real entries, so add them before the fallback - otherwise
            # the "no entries" fallback fires spuriously and crowns a random sink node.
            for hub in base_hubs or []:
                if hub not in entries:
                    entries.append(hub)
            if not entries and pagerank:
                entries.append(max(pagerank, key=lambda k: pagerank[k]))
            return entries
        
        
        def _derive_signals(
            *, graph, docs: list[Path], repo_root: Path, rel,
            doc_to_code: list[dict], dangling: int, ambiguous: int,
            vault: bool, obs: bool, base_hubs: list[str] | None = None,
            entries: list[str] | None = None,
        ) -> DocGraphResult:
            nodes = list(graph.nodes())
            n = len(nodes)
        
            pagerank = _pagerank(graph)
        
            in_deg = dict(graph.in_degree())
            out_deg = dict(graph.out_degree())
        
            # `.base` hubs are dynamic navigation surfaces, so they seed reachability
            # alongside the README/AGENTS/MOC entry points (issue #176).
            # `entries` pins the roots (the link-only pass reuses the headline's, so the
            # two figures differ only in their edge set).
            entry_set = set(entries if entries is not None
                            else _pick_entry_points(docs, repo_root, pagerank, rel, base_hubs))
        
            orphans = sorted(
                x for x in nodes if in_deg.get(x, 0) == 0 and x not in entry_set
            )
            orphan_rate = len(orphans) / n if n else 0.0
        
            island_count = nx.number_weakly_connected_components(graph) if n else 0
        
            reachable: set[str] = set()
            for entry in entry_set:
                if entry in graph:
                    reachable.add(entry)
                    reachable |= nx.descendants(graph, entry)
            reachability_pct = len(reachable) / n if n else 0.0
            unreachable = sorted(set(nodes) - reachable)
        
            hubs = sorted(
                ({"path": x, "pagerank": round(pagerank.get(x, 0.0), 4),
                  "out_degree": out_deg.get(x, 0), "in_degree": in_deg.get(x, 0)}
                 for x in nodes),
                key=lambda h: (-h["pagerank"], -h["out_degree"], h["path"]),
            )[:10]
        
            declared: list[dict] = []
            not_wired: list[str] = []
            for d in docs:
                if not _is_declared_moc(d):
                    continue
                r = rel(d)
                od = out_deg.get(r, 0)
                is_hub = od >= HUB_MIN_OUTDEGREE
                declared.append({"path": r, "out_degree": od, "is_structural_hub": is_hub})
                if not is_hub:
                    not_wired.append(r)
        
            return DocGraphResult(
                available=True,
                doc_count=n,
                edge_count=graph.number_of_edges(),
                hubs=hubs,
                orphans=orphans,
                orphan_rate=orphan_rate,
                island_count=island_count,
                reachability_pct=reachability_pct,
                entry_points=sorted(entry_set),
                unreachable=unreachable,
                declared_mocs=declared,
                moc_named_but_not_wired=sorted(not_wired),
                doc_to_code_edges=doc_to_code,
                dangling_links=dangling,
                ambiguous_wikilinks=ambiguous,
                vault_detected=vault,
                obsidiantools_available=obs,
                pagerank={k: round(v, 6) for k, v in pagerank.items()},
                graph=graph,
            )
        
        
        def _broken_link_key(src: str, target: str, kind: str | None) -> str:
            """Canonical grouping key for a broken link's missing target.
        
            Mirrors ``_resolve_mdlink``'s path arithmetic so links that point at the same
            absent file share a key whatever way they're spelt:
        
            - A markdown link starting ``/`` is root-absolute — resolved from the repo
              root (``/CLAUDE.md`` -> ``CLAUDE.md``), matching ``_resolve_mdlink``'s
              ``repo_root / target.lstrip("/")`` branch. Without this, ``/CLAUDE.md`` and
              ``CLAUDE.md`` would key apart and the duplicate ghost this function exists
              to kill would survive for the root-absolute spelling.
            - Any other markdown link resolves relative to the source file's directory
              (``../CLAUDE.md`` from a subdir collapses onto the root ``CLAUDE.md``).
            - A wikilink resolves by note name globally, so it keys on the bare name.
        
            Known limit (intentional, not fixed): wikilinks and markdown links live in
            different resolution domains, so ``[[CLAUDE]]`` (key ``CLAUDE``) and
            ``[x](CLAUDE.md)`` (key ``CLAUDE.md``) at the same missing file do not merge.
            """
            if kind == "wikilink":
                return target  # wikilinks resolve by note name, not by directory
            if not target:
                return target
            if target.startswith("/"):
                return posixpath.normpath(target.lstrip("/"))
            return posixpath.normpath(posixpath.join(posixpath.dirname(src), target))
        
        
        def group_broken_links(broken_links: list[dict]) -> list[dict]:
            """Collapse broken links by the missing file they point at.
        
            Several links can name the same non-existent target — `README.md` and
            `CONTRIBUTING.md` both linking a missing `CLAUDE.md`, say. They describe one
            absent file, so the renderer should draw one ghost they both tether to, not a
            separate ghost per link.
        
            Targets are normalised to a canonical key (see ``_broken_link_key``) before
            grouping. Returns ``[{"target", "sources"}]`` ordered by descending source
            count then key, so the most-referenced ghost is rendered first.
            """
            groups: dict[str, list[str]] = {}
            for bl in broken_links:
                src = bl.get("from") or ""
                target = bl.get("target") or "?"
                key = _broken_link_key(src, target, bl.get("kind"))
                sources = groups.setdefault(key, [])
                if src not in sources:
                    sources.append(src)
            return [
                {"target": key, "sources": sources}
                for key, sources in sorted(
                    groups.items(), key=lambda kv: (-len(kv[1]), kv[0])
                )
            ]
        
      • doc_provenance.py 9.2 KB
        """Provenance-aware staleness for *generated* docs.
        
        For a hand-written doc, staleness is "the code moved while the doc sat still" -
        the churn-ratio signal in ``lib.doc_staleness``. For a **generated** doc (a Jira
        note dump, an API reference, codegen output) that measure is the wrong one:
        
          - A generated doc is *fresh* when it matches the data it was derived from and
            *stale* when its **source** has moved on - regardless of the doc file's
            wall-clock age.
          - A freshly regenerated dump of ~1,200 notes shares one recent mtime (so it
            looks fresh) even when its source changed afterwards.
          - An old-but-still-accurate generated doc reads as a "lying map" under the age
            model when it is not one.
        
        The meaningful signal is simply: **a generated doc is stale iff its source is
        newer than the doc.**
        
        A doc declares provenance two ways; frontmatter wins over config when both name
        a source for the same doc:
        
        1. **YAML frontmatter** ``source:`` (a string or a list) - the file(s) the doc
           derives from, resolved relative to the repo root first, then to the doc's own
           directory. An optional ``generated_by:`` names the generator command/script
           for humans; it is recorded but does not affect staleness.
        
               ---
               source: data/jira.tsv
               generated_by: scripts/dump-jira-notes.py
               ---
        
        2. **`.assess/config.toml`** ``[[generated]]`` array-of-tables mapping a folder
           to its source(s), for bulk-generated trees that cannot each carry frontmatter:
        
               [[generated]]
               path = "notes"
               source = "data/jira.tsv"
        
           Every doc whose path is under ``notes/`` inherits that source. ``source`` may
           be a string or a list of strings, resolved relative to the repo root.
        
        When provenance resolves, ``lib.doc_staleness`` computes ``source_newer`` (is any
        source's last change more recent than the doc's?) and ``lib.doc_complexity_join``
        reads that flag to sign freshness directly (+1 when the source has not moved,
        -1 when it has) instead of the churn ratio - so a generated doc whose source is
        quiet is never classified as a ``lying_map``.
        
        This module is **standalone**: it reads frontmatter and last-change timestamps,
        and never imports an orchestrator (the inward-only contract). Timestamps come
        from ``lib.git_churn`` (git commit time when tracked) and fall back to the
        filesystem mtime, so both the doc and its source are compared on the same axis
        (epoch seconds).
        """
        from __future__ import annotations
        
        from pathlib import Path
        
        from lib.git_churn import file_last_commit_epoch
        
        # A YAML frontmatter block is fenced by `---` lines at the very top of the file.
        _FENCE = "---"
        # Keys we read from frontmatter. Everything else is ignored.
        _SOURCE_KEY = "source"
        _GENERATED_BY_KEY = "generated_by"
        
        
        def _read_head(doc: Path, max_bytes: int = 8192) -> str:
            """Read the first chunk of a doc - enough to hold any frontmatter block."""
            try:
                with doc.open("r", encoding="utf-8", errors="ignore") as fh:
                    return fh.read(max_bytes)
            except OSError:
                return ""
        
        
        def _strip_inline_comment(value: str) -> str:
            """Drop a trailing `# comment` and surrounding quotes from a scalar value."""
            # Only strip a comment that is clearly spaced off the value, to avoid eating
            # a `#fragment` that is part of a path/URL.
            if " #" in value:
                value = value.split(" #", 1)[0]
            value = value.strip()
            if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
                value = value[1:-1]
            return value.strip()
        
        
        def _parse_scalar_or_flow_list(value: str) -> list[str]:
            """Parse a frontmatter scalar or inline `[a, b]` flow list into a string list."""
            value = value.strip()
            if value.startswith("[") and value.endswith("]"):
                inner = value[1:-1]
                return [s for raw in inner.split(",") if (s := _strip_inline_comment(raw))]
            scalar = _strip_inline_comment(value)
            return [scalar] if scalar else []
        
        
        def parse_frontmatter_provenance(doc: Path) -> tuple[list[str], str | None]:
            """Return ``(source_values, generated_by)`` declared in the doc's frontmatter.
        
            Supports the three YAML shapes a generator is likely to emit without needing
            a YAML dependency (the deterministic core ships none)::
        
                source: data/jira.tsv          # scalar
                source: [a.tsv, b.tsv]         # inline flow list
                source:                        # block list
                  - a.tsv
                  - b.tsv
        
            ``source_values`` are the raw declared strings (not yet resolved to paths);
            an empty list means "no provenance declared". A malformed or absent block
            degrades to ``([], None)`` - provenance is optional, never an error.
            """
            head = _read_head(doc)
            lines = head.splitlines()
            if not lines or lines[0].strip() != _FENCE:
                return [], None
            # Find the closing fence.
            end = None
            for i in range(1, len(lines)):
                if lines[i].strip() == _FENCE:
                    end = i
                    break
            if end is None:
                return [], None
        
            block = lines[1:end]
            sources: list[str] = []
            generated_by: str | None = None
            i = 0
            while i < len(block):
                line = block[i]
                stripped = line.strip()
                if not stripped or stripped.startswith("#") or ":" not in stripped:
                    i += 1
                    continue
                key, _, rest = stripped.partition(":")
                key = key.strip().lower()
                rest = rest.strip()
                if key == _SOURCE_KEY:
                    if rest:
                        sources.extend(_parse_scalar_or_flow_list(rest))
                        i += 1
                    else:
                        # Block list: consume following `- item` lines (any indentation).
                        i += 1
                        while i < len(block):
                            item = block[i].strip()
                            if item.startswith("- "):
                                val = _strip_inline_comment(item[2:])
                                if val:
                                    sources.append(val)
                                i += 1
                            else:
                                break
                elif key == _GENERATED_BY_KEY:
                    generated_by = _strip_inline_comment(rest) or None
                    i += 1
                else:
                    i += 1
            return sources, generated_by
        
        
        def _resolve_source(raw: str, doc: Path, repo_root: Path) -> Path | None:
            """Resolve a declared source string to an existing file path.
        
            Tries repo-root-relative first (the documented primary rule), then relative
            to the doc's own directory. Returns ``None`` when neither exists, so a typo'd
            or moved source simply yields no provenance signal rather than a false one.
            """
            raw = raw.strip().lstrip("/")
            if not raw:
                return None
            for base in (repo_root, doc.parent):
                candidate = (base / raw).resolve()
                if candidate.is_file():
                    return candidate
            return None
        
        
        def resolve_doc_sources(
            doc: Path,
            repo_root: Path,
            config_map: list[tuple[str, list[str]]] | None = None,
        ) -> tuple[list[Path], str | None, str]:
            """Resolve a doc's provenance sources.
        
            Returns ``(resolved_source_paths, generated_by, method)`` where ``method`` is
            ``"frontmatter"``, ``"config"``, or ``""`` (no provenance). Frontmatter wins
            over the config mapping. ``config_map`` is a list of ``(path_prefix, sources)``
            from ``lib.assess_config.load_generated_sources``.
            """
            fm_sources, generated_by = parse_frontmatter_provenance(doc)
            if fm_sources:
                resolved = [p for raw in fm_sources if (p := _resolve_source(raw, doc, repo_root))]
                if resolved:
                    return resolved, generated_by, "frontmatter"
        
            if config_map:
                try:
                    rel = doc.resolve().relative_to(repo_root.resolve())
                except ValueError:
                    rel = None
                if rel is not None:
                    rel_posix = rel.as_posix()
                    for prefix, sources in config_map:
                        norm = prefix.strip("/")
                        if rel_posix == norm or rel_posix.startswith(norm + "/"):
                            resolved = [
                                p for raw in sources
                                if (p := _resolve_source(raw, doc, repo_root))
                            ]
                            if resolved:
                                return resolved, generated_by, "config"
            return [], generated_by, ""
        
        
        def _change_ts(path: Path) -> float | None:
            """Last-change time in epoch seconds: git commit time if tracked, else mtime.
        
            Both a doc and its source resolve through this one function, so they are
            always compared on the same axis even when one is committed and the other is
            a working-tree-only data file.
            """
            epoch = file_last_commit_epoch(path)
            if epoch is not None:
                return float(epoch)
            try:
                return path.stat().st_mtime
            except OSError:
                return None
        
        
        def source_is_newer(doc: Path, sources: list[Path]) -> bool | None:
            """True if any source last changed more recently than the doc.
        
            ``None`` when the comparison cannot be made (the doc's or every source's
            timestamp is unavailable) - the caller then keeps the age/churn signal rather
            than inventing a verdict.
            """
            if not sources:
                return None
            doc_ts = _change_ts(doc)
            if doc_ts is None:
                return None
            source_times = [t for s in sources if (t := _change_ts(s)) is not None]
            if not source_times:
                return None
            return max(source_times) > doc_ts
        
      • doc_staleness.py 22.4 KB
        """Doc-staleness metric for Layer 0 (the decaying-map signal).
        
        Absolute doc age is not the signal -- a two-year-old doc beside two-year-old
        code is fine. The signal is a doc that has *frozen while its subject moves*: a
        stale map of a churning module. So for every doc we compute three things:
        
          - ``last_commit_days``     -- days since the doc's last content change: its
                                        newest commit that is not a bulk mechanical
                                        commit (see ``git_churn.content_commit_clock``)
          - ``code_churn_in_window`` -- commits to the *code the doc describes*
          - ``ratio``                -- code churn per unit of doc maintenance
                                        (``code_churn / max(doc_churn, 1)``); high = decaying map
        
        Associating a doc with the code it describes uses the **nearest-ancestor
        base-doc rule** (same nearest-match logic as ``CODEOWNERS`` / ``.gitignore``):
        each code file is owned by the nearest base doc walking up its directory
        ancestry, and a base doc's subject is its subtree down to the next base doc.
        When co-location is absent we fall back, in order, to a parallel ``docs/`` tree,
        the doc's explicit code links, then repo-wide churn. The method used is reported
        per doc so the limits are auditable.
        
        Churn comes from ``lib.git_churn`` -- the same machinery the complexity treemap
        uses, so churn is computed one way across the whole skill.
        """
        from __future__ import annotations
        
        from dataclasses import dataclass
        from functools import lru_cache
        from pathlib import Path
        
        from lib.doc_graph import (
            CODE_EXTENSIONS,
            DOC_EXTENSIONS,
            is_excluded_path,
            is_repo_file,
        )
        from lib.doc_provenance import resolve_doc_sources, source_is_newer
        from lib.git_churn import (
            GIT_TIMEOUT_SECONDS,
            ContentClock,
            churn_is_degenerate,
            content_commit_clock,
            pick_churn_window,
            tracked_files,
        )
        
        
        # Docs that describe the directory they live in. Precedence (best first) when a
        # directory holds more than one candidate.
        BASE_DOC_PRECEDENCE = ["readme.md", "index.md", "_index.md", "agents.md", "claude.md"]
        # Boilerplate that names a directory but does not *describe* its code.
        BOILERPLATE_BASENAMES = {
            "license.md", "license", "changelog.md", "contributing.md",
            "code_of_conduct.md", "security.md", "notice.md", "authors.md",
        }
        # A repo at or above this many hand-written code files is "large" enough that
        # missing modular base docs is a navigability gap rather than needless overhead.
        LARGE_REPO_CODE_FILES = 40
        
        
        @dataclass
        class DocStaleness:
            path: str
            last_commit_days: int | None
            doc_churn_in_window: int
            code_churn_in_window: int
            subject_code_count: int
            subject_method: str
            ratio: float
            # Provenance (generated docs only; see lib.doc_provenance). When a doc
            # declares a source, staleness is measured against that source instead of
            # the doc's own age/churn: `provenance_method` names how it was declared
            # ("frontmatter"/"config"), `provenance_sources` are the resolved source rel
            # paths, and `source_newer` is True iff a source has changed more recently
            # than the doc. All None/empty for an ordinary hand-written doc.
            provenance_method: str = ""
            provenance_sources: tuple[str, ...] = ()
            provenance_generated_by: str | None = None
            source_newer: bool | None = None
            # "creation" when every commit touching the doc is a bulk commit, so
            # `last_commit_days` is the doc's creation date, not a content age (a doc
            # regenerated in bulk on every release). "content" otherwise.
            last_change_basis: str = "content"
        
            @property
            def confidence(self) -> str:
                # repo-baseline uses repo-wide churn (no derivable subject), so a
                # stale-ratio computed against it is a coarse proxy. Mark it low so a
                # reader knows to discount before acting on the ranking. A creation-date
                # fallback is low for the same reason: its age is not a content age.
                if self.subject_method == "repo-baseline" or self.last_change_basis == "creation":
                    return "low"
                return "high"
        
            def as_dict(self) -> dict:
                d: dict = {
                    "path": self.path,
                    "last_commit_days": self.last_commit_days,
                    "doc_churn_in_window": self.doc_churn_in_window,
                    "code_churn_in_window": self.code_churn_in_window,
                    "subject_code_count": self.subject_code_count,
                    "subject_method": self.subject_method,
                    "ratio": round(self.ratio, 2),
                    "confidence": self.confidence,
                }
                if self.last_change_basis != "content":
                    d["last_change_basis"] = self.last_change_basis
                if self.provenance_method:
                    d["provenance"] = {
                        "method": self.provenance_method,
                        "sources": list(self.provenance_sources),
                        "generated_by": self.provenance_generated_by,
                        "source_newer": self.source_newer,
                    }
                return d
        
        
        def _discover(repo_root: Path, exts: set[str],
                      extra_exclude_dirs: set[str] | None = None,
                      extra_exclude_patterns: list[str] | None = None,
                      scope: Path | None = None) -> list[Path]:
            """In-repo files with the given extensions (tracked + within repo only).
        
            `scope` (an absolute path under `repo_root`) restricts discovery to a
            subtree for `/assess <path>` monorepo scoping; omit it for a whole-repo run.
            """
            from lib.assess_config import is_user_excluded
            repo_root = repo_root.resolve()
            scope_abs = scope.resolve() if scope is not None else None
            tracked = tracked_files(repo_root)
            extra_dirs = extra_exclude_dirs or set()
            extra_pats = extra_exclude_patterns or []
            files: list[Path] = []
            for path in repo_root.rglob("*"):
                if not path.is_file() or path.suffix.lower() not in exts:
                    continue
                if scope_abs is not None and not path.resolve().is_relative_to(scope_abs):
                    continue
                try:
                    rel = path.relative_to(repo_root)
                except ValueError:
                    continue
                if is_excluded_path(rel):
                    continue
                if is_user_excluded(rel, extra_dirs, extra_pats):
                    continue
                if not is_repo_file(path, repo_root, tracked):
                    continue
                files.append(path)
            return sorted(files)
        
        
        def discover_code_files(repo_root: Path,
                                extra_exclude_dirs: set[str] | None = None,
                                extra_exclude_patterns: list[str] | None = None,
                                scope: Path | None = None,
                                ) -> list[Path]:
            return _discover(
                repo_root, CODE_EXTENSIONS,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
                scope=scope,
            )
        
        
        def discover_doc_files(repo_root: Path,
                               extra_exclude_dirs: set[str] | None = None,
                               extra_exclude_patterns: list[str] | None = None,
                               scope: Path | None = None,
                               ) -> list[Path]:
            return _discover(
                repo_root, DOC_EXTENSIONS,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
                scope=scope,
            )
        
        
        def content_clock(repo_root: Path) -> ContentClock:
            """The repo's bulk-commit-aware last-change clock (issue #333).
        
            The bulk-share denominator is every doc under the built-in exclusions for
            the whole repo, independent of `/assess <path>` scope and user excludes, so
            the doc-staleness metric and the instruction grader agree on which commits
            are bulk. Both calls share one build per HEAD (see `_clock_at`).
            """
            import subprocess
        
            repo_root = repo_root.resolve()
            try:
                head = subprocess.run(
                    ["git", "-C", str(repo_root), "rev-parse", "HEAD"],
                    capture_output=True, text=True, check=False, timeout=GIT_TIMEOUT_SECONDS,
                ).stdout.strip() or None
            except (FileNotFoundError, subprocess.TimeoutExpired):
                head = None
            return _clock_at(repo_root, head)
        
        
        @lru_cache(maxsize=4)
        def _clock_at(repo_root: Path, head: str | None) -> ContentClock:
            """Build the clock once per (repo, HEAD): doc discovery, rename map, git pass."""
            from lib.change_coupling import build_rename_map
        
            # A rename map that could not be read leaves renames unfollowed, so the scan
            # is not complete: a renamed doc whose only visible commit is a bulk rename
            # falls back to a creation date.
            rename_map = build_rename_map(repo_root)
            renames = tuple(sorted(rename_map.paths.items()))
            clock = content_commit_clock(
                repo_root, frozenset(discover_doc_files(repo_root)), head, renames
            )
            return clock if rename_map.complete else clock._replace(complete=False)
        
        
        def _safe_rel(path: Path, repo_root: Path) -> str:
            """Repo-relative path string, falling back to the absolute path when the
            target lies outside the repo root (a provenance source can resolve via the
            doc's own directory to a sibling tree)."""
            try:
                return str(path.relative_to(repo_root))
            except ValueError:
                return str(path)
        
        
        def _is_base_doc(doc: Path) -> bool:
            """True if `doc` describes the directory it lives in (a base doc)."""
            name = doc.name.lower()
            if name in BOILERPLATE_BASENAMES:
                return False
            if name in BASE_DOC_PRECEDENCE:
                return True
            # `<dir>.md` convention: a doc named after its own parent directory.
            if doc.stem.lower() == doc.parent.name.lower():
                return True
            # MOC notes describe a cluster, so they act as base docs too.
            from lib.doc_graph import _is_declared_moc
        
            return _is_declared_moc(doc)
        
        
        def _base_doc_for_dir(directory: Path, docs_in_dir: list[Path]) -> Path | None:
            """Pick the single base doc representing `directory` by precedence."""
            base = [d for d in docs_in_dir if _is_base_doc(d)]
            if not base:
                return None
            def rank(d: Path) -> int:
                name = d.name.lower()
                return BASE_DOC_PRECEDENCE.index(name) if name in BASE_DOC_PRECEDENCE else len(BASE_DOC_PRECEDENCE)
            return sorted(base, key=lambda d: (rank(d), str(d)))[0]
        
        
        def _build_base_doc_dirs(repo_root: Path, docs: list[Path]) -> dict[Path, Path]:
            """Map directory -> its base doc, for every directory that has one."""
            by_dir: dict[Path, list[Path]] = {}
            for d in docs:
                by_dir.setdefault(d.parent, []).append(d)
            result: dict[Path, Path] = {}
            for directory, dir_docs in by_dir.items():
                base = _base_doc_for_dir(directory, dir_docs)
                if base is not None:
                    result[directory] = base
            return result
        
        
        def _nearest_base_doc(code_file: Path, base_doc_dirs: dict[Path, Path], repo_root: Path) -> Path | None:
            """Walk up from the code file's directory; return the nearest base doc."""
            current = code_file.parent
            while True:
                if current in base_doc_dirs:
                    return base_doc_dirs[current]
                if current == repo_root or current.parent == current:
                    return None
                if repo_root not in current.parents and current != repo_root:
                    return None
                current = current.parent
        
        
        def _parallel_docs_subject(
            doc: Path, repo_root: Path, code_dirs: set[Path],
        ) -> list[Path] | None:
            """Fallback (b): a doc under a `docs/` tree mapping to a code dir by name.
        
            `docs/payments.md` (or `docs/payments/index.md`) -> the `payments` code dir.
            """
            rel = doc.relative_to(repo_root)
            if "docs" not in {p.lower() for p in rel.parts}:
                return None
            candidates = {doc.stem.lower(), doc.parent.name.lower()}
            matches = [d for d in code_dirs if d.name.lower() in candidates]
            if not matches:
                return None
            # Prefer the shallowest matching code dir for determinism.
            return sorted(matches, key=lambda d: (len(d.parts), str(d)))[:1]
        
        
        def analyze_doc_staleness(
            repo_root: Path,
            doc_files: list[Path] | None = None,
            doc_to_code_edges: list[dict] | None = None,
            extra_exclude_dirs: set[str] | None = None,
            extra_exclude_patterns: list[str] | None = None,
            generated_sources: list[tuple[str, list[str]]] | None = None,
            scope: Path | None = None,
        ) -> dict:
            """Compute the doc-staleness metric and doc->code association summary.
        
            ``generated_sources`` is the ``[[generated]]`` folder->source map (issue
            #178). When None it is read from ``.assess/config.toml``; pass an explicit
            list to override (tests, or a caller that already loaded the config).
        
            ``scope`` (an absolute path under ``repo_root``) restricts both the doc and
            code discovery to a subtree for ``/assess <path>`` monorepo scoping; omit it
            for a whole-repo run.
            """
            repo_root = repo_root.resolve()
            if generated_sources is None:
                from lib.assess_config import load_generated_sources
                generated_sources = load_generated_sources(repo_root)
            docs = [
                d.resolve() for d in (
                    doc_files if doc_files is not None
                    else discover_doc_files(
                        repo_root,
                        extra_exclude_dirs=extra_exclude_dirs,
                        extra_exclude_patterns=extra_exclude_patterns,
                        scope=scope,
                    )
                )
            ]
            code_files = discover_code_files(
                repo_root,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
                scope=scope,
            )
        
            def rel(p: Path) -> str:
                return str(p.relative_to(repo_root))
        
            # Churn: pick a window over the code files (the subject we care about), then
            # score both docs and code in that window for the ratio.
            all_paths = code_files + docs
            churn_map, churn_label = pick_churn_window(repo_root, all_paths)
            if churn_map is None:
                churn_map = {}
                churn_label = None
        
            # Is the churn measurement itself trustworthy? A degenerate history (shallow
            # clone, fresh import, squashed/extracted tree) shows ~1 commit per file, so
            # `code_churn_in_window` swells to the file count and inflates every ratio
            # below. We measure degeneracy over the *code* distribution (the subject the
            # ratio's numerator sums) and surface it as the single source of truth other
            # consumers read - the doc->complexity join caps confidence, the keyhole
            # summary drops churn-derived findings, the report carries a snapshot caveat.
            churn_degenerate = churn_is_degenerate(
                churn_map.get(c, 0) for c in code_files
            )
        
            # Last content change per doc, skipping bulk mechanical commits (#333).
            clock = content_clock(repo_root)
        
            base_doc_dirs = _build_base_doc_dirs(repo_root, docs)
            code_dirs = {c.parent for c in code_files}
        
            # Explicit doc->code links (fallback c): doc rel -> [code abs paths].
            explicit: dict[str, list[Path]] = {}
            for edge in (doc_to_code_edges or []):
                code_abs = (repo_root / edge["code"]).resolve()
                explicit.setdefault(edge["doc"], []).append(code_abs)
        
            # Nearest-ancestor ownership: code file -> owning base doc.
            code_owner: dict[Path, Path] = {}
            for c in code_files:
                owner = _nearest_base_doc(c, base_doc_dirs, repo_root)
                if owner is not None:
                    code_owner[c] = owner
            # Invert: base doc -> the code subtree it owns (down to the next base doc).
            owned_by_doc: dict[Path, list[Path]] = {}
            for code, owner in code_owner.items():
                owned_by_doc.setdefault(owner, []).append(code)
        
            repo_wide_code_churn = sum(churn_map.get(c, 0) for c in code_files)
        
            results: list[DocStaleness] = []
            method_counts: dict[str, int] = {}
            docs_mapping_to_code = 0
        
            # Association precedence (per the PRD's ordered fallbacks): co-located base
            # doc (nearest-ancestor) -> a parallel docs/ tree -> the doc's explicit code
            # links -> repo-wide churn baseline.
            for d in docs:
                subject: list[Path]
                method: str
                if d in owned_by_doc:
                    subject = owned_by_doc[d]
                    method = "nearest-ancestor"
                elif (par := _parallel_docs_subject(d, repo_root, code_dirs)) is not None:
                    subject = [c for c in code_files if any(sd in c.parents for sd in par)]
                    method = "parallel-docs-tree"
                elif explicit.get(rel(d)):
                    subject = explicit[rel(d)]
                    method = "explicit-links"
                else:
                    subject = []
                    method = "repo-baseline"
        
                if method != "repo-baseline":
                    docs_mapping_to_code += 1
                    code_churn = sum(churn_map.get(c, 0) for c in subject)
                    subject_count = len(subject)
                else:
                    # repo-baseline has no derivable subject, so the ratio uses
                    # repo-wide churn - a coarse proxy. In an active repo this can be
                    # high even for a freshly-written floating doc, so `ratio` alone
                    # over-flags here. `last_commit_days` is the corrective signal (the
                    # heatmap colours by staleness, and a floating doc won't be a graph
                    # hub, so its stale_hubs priority stays low). Read ratio together
                    # with subject_method and last_commit_days, not on its own.
                    code_churn = repo_wide_code_churn
                    subject_count = len(code_files)
        
                method_counts[method] = method_counts.get(method, 0) + 1
                doc_churn = churn_map.get(d, 0)
                ratio = code_churn / max(doc_churn, 1)
        
                # Provenance (issue #178): a *generated* doc that declares a source is
                # measured against that source, not its own age/churn. When the source
                # has NOT moved on, the doc provably matches its source, so its
                # decaying-map ratio is zero by construction - this is what keeps a
                # freshly-accurate generated doc out of the lying_map bucket regardless
                # of how busy the surrounding code is. When the source HAS moved on,
                # `source_newer` carries the staleness verdict for the join to sign
                # freshness directly; the churn ratio is left untouched as a secondary
                # signal.
                prov_sources, generated_by, prov_method = resolve_doc_sources(
                    d, repo_root, generated_sources
                )
                src_newer: bool | None = None
                prov_source_rels: tuple[str, ...] = ()
                if prov_method:
                    src_newer = source_is_newer(d, prov_sources)
                    prov_source_rels = tuple(
                        _safe_rel(s, repo_root) for s in prov_sources
                    )
                    if src_newer is False:
                        ratio = 0.0
        
                results.append(DocStaleness(
                    path=rel(d),
                    last_commit_days=clock.days(d),
                    last_change_basis="creation" if d in clock.creation_fallback else "content",
                    doc_churn_in_window=doc_churn,
                    code_churn_in_window=code_churn,
                    subject_code_count=subject_count,
                    subject_method=method,
                    ratio=ratio,
                    provenance_method=prov_method,
                    provenance_sources=prov_source_rels,
                    provenance_generated_by=generated_by,
                    source_newer=src_newer,
                ))
        
            # Association-derivability is itself a Layer 0 signal.
            code_under_base = sum(1 for c in code_files if c in code_owner)
            pct_code_under_base = code_under_base / len(code_files) if code_files else 0.0
            pct_docs_mapping = docs_mapping_to_code / len(docs) if docs else 0.0
        
            # Modularity coverage is *size-weighted*: a 200-file service without a base
            # doc is a real navigability gap, a 3-file utility dir without one isn't.
            # Counting every code-containing directory equally (the un-weighted ratio
            # below) penalises nested internal dirs (`services/<x>/internal/`,
            # `adapters/persistence/`) the same as top-level service roots and pushes
            # the headline to near-zero on any non-trivial repo. The weighted ratio is
            # the fraction of *code* (by file count) sitting under a base doc - identical
            # to `pct_code_under_base_doc`. Both are reported so the denominator stays
            # auditable.
            module_dirs_with_base = len(base_doc_dirs)
            module_dir_count = len(code_dirs)
            base_doc_dir_ratio = module_dirs_with_base / module_dir_count if module_dir_count else 0.0
            # `pct_code_under_base` reaches 1.0 whenever a single root-level base doc
            # (a top README) is an ancestor of every code file - it does NOT mean every
            # module is documented. Reported as `base_doc_coverage_when_present` so it
            # is never misread as headline coverage; `base_doc_dir_ratio` (fraction of
            # code-containing dirs that actually hold a base doc) is the headline.
            base_doc_coverage_when_present = pct_code_under_base
        
            return {
                "available": True,
                "churn_window": churn_label,
                # Churn-measurement reliability, independent of doc->code association
                # precision. True = the window has no usable churn signal (every file ~1
                # commit), so any finding built on `code_churn_in_window` / `ratio` must
                # be discounted - see `lib.git_churn.churn_is_degenerate`.
                "churn_degenerate": churn_degenerate,
                "docs": [r.as_dict() for r in sorted(results, key=lambda r: -r.ratio)],
                # Bulk mechanical commits (a commit touching more than half of the
                # repo's docs, and at least ten) that `last_commit_days` skipped:
                # newest first, capped; the total sits beside it. `complete` False means
                # the full history was not read: a git failure (the plain newest-commit
                # clock ran) or a shallow clone (only the visible history was read).
                "bulk_commits_skipped": list(clock.skipped),
                "bulk_commits_skipped_total": clock.skipped_total,
                "bulk_commit_scan_complete": clock.complete,
                # Docs whose every commit is bulk: `last_commit_days` is a creation
                # date, marked `last_change_basis: "creation"` and confidence "low".
                "creation_date_fallback_count": sum(
                    1 for d in docs if d in clock.creation_fallback
                ),
                "association": {
                    "code_file_count": len(code_files),
                    "doc_count": len(docs),
                    "code_under_base_doc": code_under_base,
                    "pct_code_under_base_doc": round(pct_code_under_base, 3),
                    "docs_mapping_to_code": docs_mapping_to_code,
                    "pct_docs_mapping_to_code": round(pct_docs_mapping, 3),
                    "methods": method_counts,
                },
                "modularity": {
                    "module_dir_count": module_dir_count,
                    "module_dirs_with_base_doc": module_dirs_with_base,
                    # Headline first: fraction of code-containing dirs with a base doc.
                    "base_doc_dir_ratio": round(base_doc_dir_ratio, 3),
                    "base_doc_coverage_when_present": round(base_doc_coverage_when_present, 3),
                    "code_file_count": len(code_files),
                    "large_repo": len(code_files) >= LARGE_REPO_CODE_FILES,
                },
            }
        
      • evidence_check.py 14.4 KB
        """Deterministic re-check of the evidence a layer verdict cites.
        
        The layer scorer is a model; the claims it cites as evidence ("CLAUDE.md
        exists", "no workflow calls scripts/check-x.sh") are facts about the filesystem
        that a model can get wrong. This module re-checks each claim with ``exists()``
        or a literal substring search - no model, no heuristics - and splits the list
        into the entries that hold (``evidence``) and the entries that do not
        (``evidence_rejected``).
        
        Evidence entry format (a flat JSON array of objects):
        
        - ``layer``: integer 0-8, the layer whose verdict cites the entry. Carried
          through unchecked: this module checks facts about the filesystem, and the
          caller owns the rest of the schema.
        - ``kind``: one of ``path_exists``, ``path_absent``, ``referenced_in``,
          ``not_referenced_in``, ``file_contains``.
        - ``path``: relative to the repository root under check. For the two reference
          kinds it names one file or a directory searched recursively; for
          ``file_contains`` it names one file.
        - ``needle``: the literal searched for (reference kinds and ``file_contains``).
        
        Keys the checker does not know pass through unchanged. A rejected entry keeps
        its kind and arguments and gains a ``reason`` string.
        
        Every check fails closed: a malformed entry (including a needle that is not
        encodable text) is rejected, not raised on, and a claim whose check could not
        read everything it needed - ``referenced_in``, ``not_referenced_in`` or
        ``file_contains`` - is rejected as incomplete rather than decided on the part
        that was read.
        
        CLI (run from ``skills/assess/scripts``)::
        
            uv run python -m lib.evidence_check <repo_root> <evidence.json> --json <out.json>
        
        Exit 0 when every entry verifies, 1 when any is rejected, 2 when the evidence
        file cannot be read, is not UTF-8 JSON, or is not a JSON array, or ``repo_root`` is not
        a directory (no output is written then), or the ``--json`` file cannot be written.
        """
        from __future__ import annotations
        
        import json
        import os
        from pathlib import Path
        from typing import Any
        
        KINDS = ("path_exists", "path_absent", "referenced_in", "not_referenced_in", "file_contains")
        _NEEDLE_KINDS = frozenset({"referenced_in", "not_referenced_in", "file_contains"})
        
        # VCS metadata is not repository content: a needle found only in .git/ (a
        # commit message, a reflog) is not a reference an agent or CI would follow.
        # This applies to the two reference kinds only: file_contains is a claim about
        # one named file, so it may read a path inside .git/ (e.g. .git/config).
        _GIT_DIR = ".git"
        # Directories the recursive walk does not enter. .assess/ holds this tool's own
        # previous output, which quotes repository paths in prose; reading it as
        # evidence would let one run's report decide the next (lib/doc_graph.py and
        # lib/structure_graph.py exclude it for the same reason). Only the walk skips
        # these: a path that names .assess/ directly is still searched.
        _SKIP_DIRS = frozenset({_GIT_DIR, ".assess"})
        
        
        def _resolve(repo_root: Path, rel: str) -> Path | None:
            """Resolve ``rel`` under ``repo_root``; None when it escapes the root or
            cannot be resolved (an embedded NUL, a symlink loop)."""
            if "\0" in rel:
                return None
            try:
                root = Path(repo_root).resolve()
                target = (root / rel).resolve()
            except (OSError, RuntimeError, ValueError):
                return None
            if target != root and not target.is_relative_to(root):
                return None
            return target
        
        
        def _under(path: Path, top: Path) -> bool:
            return path == top or path.is_relative_to(top)
        
        
        def _in_git_metadata(rel: str, repo_root: Path | str) -> bool:
            """True when ``rel`` names ``.git/`` or anything in it, literally or through
            a symlink that resolves there."""
            if _GIT_DIR in Path(rel).parts:
                return True
            target = _resolve(Path(repo_root), rel)
            return target is not None and _under(target, Path(repo_root).resolve() / _GIT_DIR)
        
        
        def _encode(needle: str) -> bytes | None:
            """UTF-8 bytes of ``needle``; None when it holds a lone surrogate (which
            ``json.loads`` accepts from a ``\\udXXX`` escape)."""
            try:
                return needle.encode("utf-8")
            except UnicodeEncodeError:
                return None
        
        
        _CHUNK = 1 << 20  # read files in 1 MiB chunks so one large asset cannot set peak memory
        
        
        def _file_has(path: Path, needle: bytes) -> bool | None:
            """True/False for a completed read; None when the file cannot be read.
        
            Reads in chunks, keeping the last ``len(needle) - 1`` bytes of each chunk
            so a needle that spans a chunk boundary is still found.
            """
            keep = len(needle) - 1
            tail = b""
            try:
                with path.open("rb") as fh:
                    while chunk := fh.read(_CHUNK):
                        window = tail + chunk
                        if needle in window:
                            return True
                        tail = window[-keep:] if keep else b""
            except OSError:
                return None
            return False
        
        
        def _link_target(root: Path, link: Path) -> Path | None:
            """Where a symlink met in the walk leads, when that is repository content;
            None for a link out of the root, a dangling one, or one into a directory the
            walk does not enter (``.git/``, ``.assess/``) - none of those is content the
            walk would read. A link that cannot be resolved (a loop) is raised as OSError."""
            try:
                dest = link.resolve(strict=True)
            except FileNotFoundError:
                return None
            except RuntimeError as exc:  # symlink loop on older Pythons
                raise OSError(str(link)) from exc
            if not _under(dest, root) or any(_under(dest, root / d) for d in _SKIP_DIRS):
                return None
            return dest
        
        
        def _walk(root: Path, target: Path, raw: bytes) -> bool | None:
            """Search the directory ``target`` recursively; see ``_search``.
        
            Nothing met in the walk is skipped silently unless it is not repository
            content. A symlink out of the root, or a dangling one, is skipped. A symlink
            to a file inside the root is read at its target. A symlink to a directory
            inside the root, unless that directory is already under ``target``, and any
            FIFO, socket or device, is content this search did not read, so it marks the
            result incomplete.
            """
            errors: list[OSError] = []
            for dirpath, dirnames, filenames in os.walk(target, onerror=errors.append):
                dirnames[:] = sorted(d for d in dirnames if d not in _SKIP_DIRS)
                for name in list(dirnames):
                    child = Path(dirpath) / name
                    if not child.is_symlink():
                        continue  # os.walk descends into it
                    dirnames.remove(name)  # os.walk does not follow it; decide here
                    try:
                        dest = _link_target(root, child)
                    except OSError as exc:
                        errors.append(exc)
                        continue
                    if dest is not None and not (dest == target or dest.is_relative_to(target)):
                        errors.append(OSError(str(child)))
                for name in sorted(filenames):
                    child = Path(dirpath) / name
                    read = child
                    if child.is_symlink():
                        try:
                            dest = _link_target(root, child)
                        except OSError as exc:
                            errors.append(exc)
                            continue
                        if dest is None:
                            continue
                        read = dest
                    # Regular files only: a FIFO would block the read forever.
                    if not read.is_file():
                        errors.append(OSError(str(child)))
                        continue
                    hit = _file_has(read, raw)
                    if hit:
                        return True
                    if hit is None:
                        errors.append(OSError(name))
            return None if errors else False
        
        
        def _search(repo_root: Path | str, needle: str, path: str) -> bool | None:
            """True when found; False when a complete search found nothing; None when
            nothing was found but some file or directory could not be searched."""
            if not needle or _in_git_metadata(path, repo_root):
                return False
            raw = _encode(needle)
            target = _resolve(Path(repo_root), path)
            if raw is None or target is None:
                return False
            if target.is_file():
                return _file_has(target, raw)
            if not target.exists():
                return False
            if not target.is_dir():
                # A FIFO, socket or device named directly: never opened (a FIFO would
                # block the read), so the search is incomplete, not empty.
                return None
            return _walk(Path(repo_root).resolve(), target, raw)
        
        
        def is_referenced_in(repo_root: Path | str, needle: str, path: str) -> bool:
            """True when the literal ``needle`` occurs in the file at ``path``, or in
            any file under the directory ``path`` (searched recursively).
        
            ``path`` is relative to ``repo_root``. A path that does not exist, that
            resolves outside ``repo_root``, or that lies inside ``.git/`` holds no
            reference and returns False, as does a search that found nothing because
            something under ``path`` could not be read. The directory walk does not
            enter ``.git/`` or ``.assess/``, skips symlinks that lead out of the root,
            reads a symlinked file inside the root at its target, and treats a FIFO,
            socket, device or symlinked directory it did not search as unread.
            """
            return _search(repo_root, needle, path) is True
        
        
        def _malformed(repo_root: Path | str, entry: Any) -> str | None:
            """Reason an entry cannot be checked at all, or None when it is well formed."""
            if not isinstance(entry, dict):
                return "entry is not an object"
            if not Path(repo_root).is_dir():
                return "repository root is not a directory"
            kind = entry.get("kind")
            if kind not in KINDS:
                return f"unknown kind {kind!r}"
            rel = entry.get("path")
            if not isinstance(rel, str) or not rel:
                return "missing path"
            needle = entry.get("needle")
            if kind in _NEEDLE_KINDS:
                if not isinstance(needle, str) or not needle:
                    return "missing needle"
                if _encode(needle) is None:
                    return "needle is not valid text (a lone surrogate cannot be encoded)"
            return None
        
        
        def _check_reference(repo_root: Path | str, kind: str, needle: str, rel: str, target: Path) -> str | None:
            # The place searched must exist, or the claim is about nothing (use
            # path_absent to claim the place is missing).
            if _in_git_metadata(rel, repo_root):
                return "path is inside .git/, which the reference search does not enter"
            if not target.exists():
                return "path does not exist"
            found = _search(repo_root, needle, rel)
            if found is True:
                return None if kind == "referenced_in" else "needle found under path"
            if found is None:
                return "part of path could not be searched, so the result is incomplete"
            return "needle not found under path" if kind == "referenced_in" else None
        
        
        def check_entry(repo_root: Path | str, entry: Any) -> str | None:
            """Return None when ``entry`` holds, else the reason it is rejected."""
            reason = _malformed(repo_root, entry)
            if reason is not None:
                return reason
            kind: str = entry["kind"]
            rel: str = entry["path"]
            needle: str = entry.get("needle") or ""
            target = _resolve(Path(repo_root), rel)
            if target is None:
                return "path resolves outside the repository root or cannot be resolved"
        
            if kind == "path_exists":
                return None if target.exists() else "path does not exist"
            if kind == "path_absent":
                return "path exists" if target.exists() else None
            if kind == "file_contains":
                if not target.is_file():
                    return "path is not a file"
                hit = _file_has(target, needle.encode("utf-8"))  # encodable: _malformed checked
                if hit is None:
                    return "file could not be read"
                return None if hit else "needle not found in file"
            return _check_reference(repo_root, kind, needle, rel, target)
        
        
        def check_evidence(repo_root: Path | str, entries: list[Any]) -> dict[str, list[Any]]:
            """Split ``entries`` into ``evidence`` (verified) and ``evidence_rejected``.
        
            Input order is kept in both lists. Verified entries are returned as given;
            rejected entries are copies with a ``reason`` added. The input is not
            mutated.
            """
            verified: list[Any] = []
            rejected: list[Any] = []
            for entry in entries:
                reason = check_entry(repo_root, entry)
                if reason is None:
                    verified.append(entry)
                elif isinstance(entry, dict):
                    rejected.append({**entry, "reason": reason})
                else:
                    rejected.append({"entry": entry, "reason": reason})
            return {"evidence": verified, "evidence_rejected": rejected}
        
        
        def describe(entry: dict[str, Any]) -> str:
            """One-line name for an entry: kind, path, and needle where it has one."""
            if "kind" not in entry:
                return repr(entry.get("entry", entry))
            parts = [str(entry.get("kind")), str(entry.get("path"))]
            if "needle" in entry:
                parts.append(repr(entry["needle"]))
            return " ".join(parts)
        
        
        def main() -> int:
            import argparse
            import sys
        
            ap = argparse.ArgumentParser(description="Re-check /assess evidence entries.")
            ap.add_argument("repo_root", type=Path)
            ap.add_argument("evidence", type=Path, help="JSON file holding a flat array of entries")
            ap.add_argument("--json", type=Path, help="write {evidence, evidence_rejected} here")
            args = ap.parse_args()
        
            try:
                entries = json.loads(args.evidence.read_text(encoding="utf-8"))
            except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
                print(f"evidence_check: cannot read {args.evidence}: {exc}", file=sys.stderr)
                return 2
            if not isinstance(entries, list):
                print("evidence_check: input must be a flat JSON array of entries", file=sys.stderr)
                return 2
        
            if not args.repo_root.is_dir():
                print(f"evidence_check: repo_root {args.repo_root} is not a directory", file=sys.stderr)
                return 2
        
            result = check_evidence(args.repo_root, entries)
            if args.json:
                try:
                    args.json.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
                except OSError as exc:
                    # Exit 1 means "an entry was rejected"; a failed write must not read as that.
                    print(f"evidence_check: cannot write {args.json}: {exc}", file=sys.stderr)
                    return 2
        
            print(f"verified {len(result['evidence'])}, rejected {len(result['evidence_rejected'])}")
            for entry in result["evidence_rejected"]:
                print(f"  rejected: {describe(entry)} ({entry['reason']})")
            return 1 if result["evidence_rejected"] else 0
        
        
        if __name__ == "__main__":
            raise SystemExit(main())
        
      • gap_actions.py 4.2 KB
        """Deterministic gap actions: Top 3 candidates read from signals the core holds.
        
        ``prescribed_actions`` fills the Top 3 from the attention ranking. When that
        ranking is low signal (or empty) it leaves slots free, and the report writer
        used to fill them by judgement alone. ``build_gap_actions`` turns two measured
        gaps into ready-made actions for those slots, so a free slot is filled from a
        signal before it is filled from judgement:
        
        - ``coverage_report``: no line-coverage report was found in a software repo,
          so test depth on the hotspots is unmeasured. Names the top non-archive
          hotspots to measure first; silent when none remain, and silent on a
          knowledge base, whose test layers are N/A.
        - ``doc_graph``: fewer than ``REACHABILITY_FLOOR`` of the docs are reachable
          from the entry points. Names the unreachable docs.
        
        Each entry is ``{signal, action, paths}``, where ``signal`` is the run-context
        block the gap was read from. The coverage entry, when present, comes first.
        A lint complexity-rule gap is deliberately absent: the core has no detector for
        it, and the layer scorer owns that check.
        """
        from __future__ import annotations
        
        from lib.keyhole_signals import is_archive_path
        
        # Reachability floor for the doc_graph gap. Below half, most of the docs cannot
        # be reached by following links from README / AGENTS.md / an index page, so an
        # agent that starts at the entry points misses the larger part of the written
        # context: the map no longer covers the territory. At or above half the
        # unreachable docs are a minority, a tidy-up rather than a Top 3 action.
        REACHABILITY_FLOOR = 0.5
        
        # How many top hotspots the coverage action names: the three riskiest files,
        # enough to start measuring in one action without turning it into a file list.
        MAX_COVERAGE_PATHS = 3
        # How many unreachable docs the reachability action names; the rest are in
        # doc_graph.unreachable.
        MAX_UNREACHABLE_PATHS = 10
        
        
        def _coverage_gap(
            coverage_report: dict, top_hotspots: list[dict], archetype: dict,
        ) -> dict | None:
            # A knowledge base marks the test layers N/A, so no coverage remediation is
            # proposed there; an unknown archetype is not assumed to be software.
            if archetype.get("archetype") != "software":
                return None
            if coverage_report.get("available") is not False:
                return None
            paths = [
                h["path"] for h in top_hotspots
                if h.get("path") and not is_archive_path(h["path"])
            ][:MAX_COVERAGE_PATHS]
            # Top 3 actions name files; with no live hotspot there is nothing to name.
            if not paths:
                return None
            return {
                "signal": "coverage_report",
                "action": (
                    "Generate a line-coverage report (coverage.xml or lcov.info) in CI "
                    "and read it for the top hotspots first: no report was found, so how "
                    "well the tests cover the riskiest files is unmeasured."
                ),
                "paths": paths,
            }
        
        
        def _reachability_gap(doc_graph: dict) -> dict | None:
            # A repo with no markdown reports reachability 0.0 with available: true.
            # Nothing is unreachable there, so no link-the-docs action applies; a
            # missing README or instruction file is the layer scorer's finding.
            if not doc_graph.get("available") or not doc_graph.get("doc_count"):
                return None
            pct = doc_graph.get("reachability_pct")
            if not isinstance(pct, (int, float)) or pct >= REACHABILITY_FLOOR:
                return None
            unreachable = sorted(doc_graph.get("unreachable") or [])
            return {
                "signal": "doc_graph",
                "action": (
                    f"Link the unreachable docs from README or an index page, or delete "
                    f"them: only {pct:.0%} of the docs are reachable from the entry "
                    f"points, below the {REACHABILITY_FLOOR:.0%} floor."
                ),
                "paths": unreachable[:MAX_UNREACHABLE_PATHS],
            }
        
        
        def build_gap_actions(
            coverage_report: dict | None,
            doc_graph: dict | None,
            top_hotspots: list[dict] | None,
            archetype: dict | None,
        ) -> list[dict]:
            """Return the gap actions that fire, coverage first; ``[]`` when none do."""
            gaps = [
                _coverage_gap(coverage_report or {}, top_hotspots or [], archetype or {}),
                _reachability_gap(doc_graph or {}),
            ]
            return [g for g in gaps if g is not None]
        
      • gate_cost.py 3.8 KB
        """GitHub Actions cost estimate for the /assess CI gate.
        
        The CI-gate offer asks a user to add a workflow that runs on every pull request.
        The cost of saying yes is Actions minutes, which private repositories pay for,
        so the offer states it: merged pull requests in the last ``WINDOW_DAYS`` days
        (one gate run each, a floor: re-pushes add runs) times ``MINUTES_PER_RUN``.
        
        Merged pull requests come from ``gh pr list --state merged`` through
        ``gh_cli``, not ``git log --merges``: a squash-merging repository has no merge
        commits, so git history reads zero there.
        
        Block on success: ``{"available": True, "runs_per_month", "minutes_per_run",
        "minutes_per_month", "assumption", "capped", "private"}``. ``capped`` is True
        when the listing hit ``PR_LIMIT``, so the counts are lower bounds. ``private``
        is ``None`` when ``gh`` cannot say. No remote, no ``gh``, no auth, a failed read or zero merged
        pull requests degrade to ``{"available": False, "reason"}``.
        """
        from __future__ import annotations
        
        from datetime import datetime, timedelta, timezone
        from pathlib import Path
        from typing import Any
        
        from lib.gh_cli import GhUnavailable, gh_json, open_github, unavailable
        
        # Assumed wall-clock minutes per gate run. One measured run took 4m51s on a
        # 3,009-file repository; the figure is an assumption, not a measurement of the
        # repository being assessed, and the block says so.
        MINUTES_PER_RUN = 5
        
        WINDOW_DAYS = 30
        
        # gh pr list returns 30 by default; ask for more than any realistic month.
        PR_LIMIT = 1000
        
        
        def _parse_time(value: object) -> datetime | None:
            if not isinstance(value, str) or not value:
                return None
            try:
                return datetime.fromisoformat(value.replace("Z", "+00:00"))
            except ValueError:
                return None
        
        
        def _is_private(slug: str) -> bool | None:
            """The repository's visibility, or None when gh cannot tell."""
            try:
                info = gh_json(["repo", "view", slug, "--json", "isPrivate"])
            except GhUnavailable:
                return None
            value = info.get("isPrivate") if isinstance(info, dict) else None
            return value if isinstance(value, bool) else None
        
        
        def estimate_gate_cost(repo_root: Path, now: datetime | None = None) -> dict[str, Any]:
            """Estimate the gate's monthly Actions runs and minutes from merged PRs."""
            now = now or datetime.now(timezone.utc)
            since = now - timedelta(days=WINDOW_DAYS)
            try:
                repo = open_github(repo_root)
                prs = gh_json([
                    "pr", "list", "--repo", repo.slug, "--state", "merged",
                    "--search", f"merged:>={since.date().isoformat()}",
                    "--limit", str(PR_LIMIT), "--json", "number,mergedAt",
                ])
            except GhUnavailable as e:
                return unavailable(e.reason)
            if not isinstance(prs, list):
                return unavailable("gh_bad_json: `gh pr list` did not return a list")
        
            # The search qualifier is day-granular; the exact window is applied here.
            runs = 0
            for pr in prs:
                merged = _parse_time(pr.get("mergedAt")) if isinstance(pr, dict) else None
                if merged is not None and merged >= since:
                    runs += 1
            if runs == 0:
                return unavailable(
                    f"no_merge_history: no pull requests merged in the last {WINDOW_DAYS} days"
                )
        
            capped = len(prs) >= PR_LIMIT
            return {
                "available": True,
                "runs_per_month": runs,
                "minutes_per_run": MINUTES_PER_RUN,
                "minutes_per_month": runs * MINUTES_PER_RUN,
                "assumption": (
                    f"Assumes {MINUTES_PER_RUN} minutes per gate run (a fixed figure, not "
                    f"measured on this repository) and one run per merged pull request: "
                    f"{'at least ' if capped else ''}{runs} merged in the last {WINDOW_DAYS} days. Re-pushes to an "
                    f"open pull request add runs, so the run count is a floor."
                ),
                "capped": capped,
                "private": _is_private(repo.slug),
            }
        
      • generated_files.py 6.6 KB
        """Content-based detection of files that are not hand-written source.
        
        Filename globs (``EXCLUDE_FILE_PATTERNS`` in ``complexity-treemap.py``) miss
        machine-written files with ordinary names, such as a schema dump or a font
        embedded as a base64 string. Two content checks catch them:
        
        - **Header sniff** (reason ``generated-header``): one of the first
          ``HEADER_SNIFF_LINES`` lines is a comment carrying a conventional generator
          marker (the Go / protobuf convention most generators follow). A marker
          further down the file is ignored, so a hand-written comment on line 200 never
          excludes it; so is a marker in prose (a docstring continuation line about
          codegen), because the line does not open with a comment leader.
        - **Long lines** (reason ``long-lines``): the average line length over the
          first 1 MB exceeds ``LONG_LINE_THRESHOLD`` characters, the shape of a base64
          or minified payload.
        
        Both are pure stdlib and read a bounded head of the file. An unreadable file is
        never excluded.
        """
        from __future__ import annotations
        
        import fnmatch
        import re
        from pathlib import Path
        
        HEADER_SNIFF_LINES = 5
        
        # Upper bound on bytes the header sniff reads while collecting its first lines,
        # the same bound the long-line check uses.
        _HEADER_READ_BYTES = 1024 * 1024
        
        # Case-insensitive markers. "Code generated by ... DO NOT EDIT" (the Go
        # convention) is covered by the bare "do not edit" alternative, and
        # "This file is auto-generated" by "auto-generated" (also spaced or unhyphenated).
        _HEADER_MARKERS = re.compile(
            r"do not edit|@generated|auto[-\s]?generated",
            re.IGNORECASE,
        )
        # "GENERATED FILE" is common in prose ("writes the generated file"), so it
        # counts only when it opens the comment body: `-- GENERATED FILE - ...`,
        # `/* Generated file */`, `# === GENERATED FILE ===`.
        _BANNER_PUNCTUATION = " \t-=*#/!<>"
        _BANNER_MARKER = "generated file"
        
        # A generator declaration is always a comment, so the marker line must open
        # with a comment leader: # // -- /* <!-- ; % {- (* or a docstring quote. A bare
        # `*` counts only when indented (a JSDoc / block-comment continuation), so a
        # Markdown bullet at column 0 is not read as a comment. In Markdown, `#` opens
        # a heading (prose), so only `<!--` counts there.
        _COMMENT_LEADER = re.compile(
            r"""^(\s*(#|//|--|/\*|<!--|;|%|\{-|\(\*|"{3}|'{3})|\s+\*)"""
        )
        _MARKDOWN_COMMENT_LEADER = re.compile(r"^\s*<!--")
        _MARKDOWN_SUFFIXES = {".md", ".mdx", ".markdown"}
        
        # Bytes the long-line check averages over. The cap bounds IO and memory on
        # large files; the average only has to separate payloads (~20,000 characters
        # per line) from hand-written code, which a 1 MB sample does.
        _LONG_LINE_READ_BYTES = 1024 * 1024
        
        # Average characters per line above which a file is a payload, not source.
        # Calibrated against real files: a Playwright HTML report averages ~16,000 and
        # a base64 font module ~20,000; a JSONL fixture averages 296 and stays scored.
        # A hand-built HTML explainer page with inline data (this repository's
        # docs/huddle-explainer/visualization.html, ~3,800) is excluded on purpose:
        # its bulk is the embedded payload, not code a reader maintains line by line.
        # The threshold is kept above 296 and below that page's average.
        LONG_LINE_THRESHOLD = 1000
        
        # Filename globs for generated code, matched on the basename with no header
        # needed. The treemap adds them to its EXCLUDE_FILE_PATTERNS; assess_core uses
        # them to keep a file these globs newly exclude from being recorded as a
        # graduated hotspot.
        GENERATED_NAME_PATTERNS = ("*.generated.*", "*.gen.ts", "database.types.ts")
        
        REASON_HEADER = "generated-header"
        REASON_LONG_LINES = "long-lines"
        
        
        def has_generated_header(path: Path, lines: int = HEADER_SNIFF_LINES) -> bool:
            """True when one of the first ``lines`` lines is a comment carrying a
            generator marker."""
            # Read line by line so a long early line cannot push lines 2-5 out of the
            # sample; the total read stays under _HEADER_READ_BYTES.
            head: list[bytes] = []
            remaining = _HEADER_READ_BYTES
            try:
                with path.open("rb") as fh:
                    while len(head) < lines and remaining > 0:
                        raw = fh.readline(remaining)
                        if not raw:
                            break
                        head.append(raw)
                        remaining -= len(raw)
            except OSError:
                return False
            # A UTF-8 BOM (routine in .NET / PowerShell codegen) would defeat the
            # line-start anchor, so strip it before matching.
            text = b"".join(head).decode("utf-8", errors="ignore").removeprefix("\ufeff")
            leader = (_MARKDOWN_COMMENT_LEADER
                      if path.suffix.lower() in _MARKDOWN_SUFFIXES else _COMMENT_LEADER)
            for line in text.splitlines()[:lines]:
                m = leader.match(line)
                if m is None:
                    continue
                if _HEADER_MARKERS.search(line) is not None:
                    return True
                body = line[m.end():].lstrip(_BANNER_PUNCTUATION).lower()
                if body.startswith(_BANNER_MARKER):
                    return True
            return False
        
        
        def average_line_length(path: Path) -> float:
            """Mean characters per line over the first 1 MB; 0.0 for an empty or
            unreadable file.
        
            A final line without a trailing newline still counts as a line.
            """
            try:
                with path.open("rb") as fh:
                    data = fh.read(_LONG_LINE_READ_BYTES)
            except OSError:
                return 0.0
            if not data:
                return 0.0
            line_count = data.count(b"\n") + (0 if data.endswith(b"\n") else 1)
            # Characters, not bytes: 600 CJK characters are ~1,800 UTF-8 bytes.
            return len(data.decode("utf-8", errors="ignore")) / line_count
        
        
        def is_long_line_artifact(path: Path,
                                  threshold: float = LONG_LINE_THRESHOLD) -> bool:
            """True when the file's average line length exceeds ``threshold``.
        
            A file no larger than ``threshold`` bytes cannot qualify (characters <=
            bytes and there is at least one line), so it is rejected without a read.
            """
            try:
                if path.stat().st_size <= threshold:
                    return False
            except OSError:
                return False
            return average_line_length(path) > threshold
        
        
        def matches_generated_name(path: str | Path) -> bool:
            """True when the basename matches one of ``GENERATED_NAME_PATTERNS``."""
            name = Path(path).name
            return any(fnmatch.fnmatch(name, pat) for pat in GENERATED_NAME_PATTERNS)
        
        
        def generated_reason(path: Path) -> str | None:
            """The exclusion reason for ``path``, or None when it reads as source.
        
            The header sniff runs first, so a generated file with long lines reports
            ``generated-header``.
            """
            if has_generated_header(path):
                return REASON_HEADER
            if is_long_line_artifact(path):
                return REASON_LONG_LINES
            return None
        
      • gh_cli.py 6.1 KB
        """Shared, optional GitHub reads for the /assess deterministic core.
        
        Some signals need the live platform state a repository's files only describe:
        the live ruleset a committed snapshot claims to mirror, the merged pull requests
        a review policy governs. This module is the one way the core reaches GitHub, so
        every scan that does degrades the same way.
        
        Contract (every caller relies on it):
        
        - GitHub is reached only through the ``gh`` binary on ``PATH``, run as a
          subprocess. No direct HTTP, no token read from the environment: ``gh`` owns
          authentication. JSON is parsed here in Python; ``--jq`` and ``--template``
          are never passed.
        - Order of work: resolve the GitHub remote from git first, and give up without
          invoking ``gh`` when there is none; then the auth probe; then the calls.
        - Every failure raises :class:`GhUnavailable` carrying a non-empty reason, which
          the caller turns into ``{"available": False, "reason": ...}`` via
          :func:`unavailable`. An API refusal (HTTP 403) gives a reason starting
          ``no_access`` - never a clean result.
        
        Typical use::
        
            try:
                repo = open_github(repo_root)            # remote, then auth
                rulesets = gh_api(f"repos/{repo.slug}/rulesets")
            except GhUnavailable as e:
                return unavailable(e.reason)
        
        Pure subprocess + stdlib; imports no orchestrator.
        """
        from __future__ import annotations
        
        import json
        import re
        import subprocess
        from dataclasses import dataclass
        from pathlib import Path
        from typing import Any
        
        # Cap every gh call so a hung network read or credential prompt degrades the
        # signal to unavailable instead of stalling the assessment.
        GH_TIMEOUT_SECONDS = 20
        
        # github.com remotes in the three URL forms git accepts: https, scp-like ssh,
        # and ssh:// (with an optional user and port).
        _REMOTE_RE = re.compile(
            r"^(?:https?://(?:[^@/]+@)?github\.com/"
            r"|(?:[^@/]+@)?github\.com:"
            r"|ssh://(?:[^@/]+@)?github\.com(?::\d+)?/)"
            r"(?P<owner>[A-Za-z0-9_.-]+)/(?P<name>[A-Za-z0-9_.-]+?)(?:\.git)?/?$"
        )
        
        _HTTP_STATUS_RE = re.compile(r"\(HTTP (\d{3})\)")
        
        
        class GhUnavailable(Exception):
            """A GitHub read could not be made or was refused; ``reason`` says why."""
        
            def __init__(self, reason: str) -> None:
                super().__init__(reason)
                self.reason = reason
        
        
        @dataclass(frozen=True)
        class GithubRepo:
            owner: str
            name: str
        
            @property
            def slug(self) -> str:
                return f"{self.owner}/{self.name}"
        
        
        def unavailable(reason: str) -> dict[str, Any]:
            """The degraded block shape every gh-backed scan emits on failure."""
            return {"available": False, "reason": reason or "unavailable"}
        
        
        def parse_github_remote(url: str) -> GithubRepo | None:
            """``owner/name`` from a github.com remote URL, or None for any other host."""
            m = _REMOTE_RE.match(url.strip())
            if not m:
                return None
            return GithubRepo(owner=m.group("owner"), name=m.group("name"))
        
        
        def resolve_github_remote(repo_root: Path) -> GithubRepo | None:
            """The repository's github.com remote (``origin``, else the sole remote).
        
            Pure git; never invokes ``gh``. None when the directory is not a git
            repository, has no remote, or its remote is not on github.com.
            """
            def _git(*args: str) -> str | None:
                try:
                    proc = subprocess.run(
                        ["git", "-C", str(repo_root), *args],
                        capture_output=True, text=True, timeout=GH_TIMEOUT_SECONDS,
                    )
                except (FileNotFoundError, subprocess.TimeoutExpired):
                    return None
                return proc.stdout.strip() if proc.returncode == 0 else None
        
            url = _git("remote", "get-url", "origin")
            if not url:
                remotes = (_git("remote") or "").split()
                if len(remotes) != 1:
                    return None
                url = _git("remote", "get-url", remotes[0])
            return parse_github_remote(url) if url else None
        
        
        def _run_gh(args: list[str]) -> str:
            """Run ``gh`` and return stdout, raising GhUnavailable with a mapped reason."""
            cmd = ["gh", *args]
            shown = " ".join(cmd)
            try:
                proc = subprocess.run(
                    cmd, capture_output=True, text=True, timeout=GH_TIMEOUT_SECONDS,
                )
            except FileNotFoundError:
                raise GhUnavailable("gh_not_installed: the gh CLI is not on PATH") from None
            except subprocess.TimeoutExpired:
                raise GhUnavailable(
                    f"gh_timeout: `{shown}` gave no answer within {GH_TIMEOUT_SECONDS}s"
                ) from None
            if proc.returncode == 0:
                return proc.stdout
            stderr = (proc.stderr or "").strip()
            detail = stderr.splitlines()[-1] if stderr else f"exit {proc.returncode}"
            status = _HTTP_STATUS_RE.findall(stderr)
            if status and status[-1] == "403":
                raise GhUnavailable(f"no_access: `{shown}` was refused ({detail})")
            if status and status[-1] == "404":
                raise GhUnavailable(f"not_found: `{shown}` ({detail})")
            raise GhUnavailable(f"gh_error: `{shown}` failed ({detail})")
        
        
        def open_github(repo_root: Path) -> GithubRepo:
            """Resolve the remote, then confirm ``gh`` is authenticated.
        
            Raises GhUnavailable (without invoking ``gh``) when there is no github.com
            remote, and when ``gh`` is missing or not logged in.
            """
            repo = resolve_github_remote(repo_root)
            if repo is None:
                raise GhUnavailable("no_remote: no github.com remote to compare against")
            try:
                _run_gh(["auth", "status", "--hostname", "github.com"])
            except GhUnavailable as e:
                if e.reason.startswith("gh_not_installed"):
                    raise
                raise GhUnavailable(f"not_authenticated: gh is not logged in ({e.reason})") from None
            return repo
        
        
        def _parse(out: str, shown: str) -> Any:
            try:
                return json.loads(out)
            except json.JSONDecodeError:
                raise GhUnavailable(f"gh_bad_json: `{shown}` did not return JSON") from None
        
        
        def gh_api(path: str) -> Any:
            """``gh api <path>`` parsed as JSON (a REST GET)."""
            return _parse(_run_gh(["api", path]), f"gh api {path}")
        
        
        def gh_json(args: list[str]) -> Any:
            """Any other ``gh`` command whose output is JSON, e.g.
            ``["pr", "list", "--repo", slug, "--state", "merged", "--json", "number"]``."""
            return _parse(_run_gh(list(args)), "gh " + " ".join(args))
        
      • git_churn.py 23.4 KB
        """Shared git-churn machinery for the /assess deterministic core.
        
        `complexity-treemap.py` (code heatmap), `docs-staleness-treemap.py` (docs
        heatmap) and `doc_staleness.py` (Layer 0 staleness metric) all need the same
        two things: a per-file commit count over a window, and a way to pick a window
        that gives a visible gradient. This module is the single source for both so
        churn is computed one way, not three (PRD: "do not reinvent churn").
        
        Pure subprocess + git. No heavy dependencies, so it imports cleanly in the
        deterministic core (which runs with only networkx) as well as in the treemap
        scripts (which also pull in lizard/matplotlib/squarify).
        """
        from __future__ import annotations
        
        import math
        import subprocess
        import sys
        from collections.abc import Iterable
        from functools import lru_cache
        from pathlib import Path
        from typing import NamedTuple
        
        # Cap every git call so a stuck invocation (huge repo, lock contention, a hung
        # credential prompt) degrades to "no churn data" rather than blocking the run.
        GIT_TIMEOUT_SECONDS = 20
        
        
        def git_churn_scores(
            root: Path, since: str | None = None, scope: Path | None = None
        ) -> dict[Path, int]:
            """Return {abs_path: commit_count} for files under root tracked in git.
        
            Returns empty dict if root is not inside a git repo. Commit counts are
            over the full history reachable from HEAD by default. Pass `since` as
            a git-compatible date expression (e.g. "6 months ago") to window the
            count. Renames are not followed - a file gets credit only under its
            current name.
        
            `scope` (an absolute path under `root`) restricts the churn to a subtree:
            git only walks that pathspec and the results are filtered to files under
            it. Omit it (the default) for a whole-repo run - the output is then
            byte-identical to before. Used by `/assess <path>` monorepo scoping so a
            scoped assessment carries no churn signal from a sibling directory.
            """
            try:
                repo_top = subprocess.run(
                    ["git", "-C", str(root), "rev-parse", "--show-toplevel"],
                    capture_output=True, text=True, check=True,
                    timeout=GIT_TIMEOUT_SECONDS,
                ).stdout.strip()
            except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
                return {}
            repo = Path(repo_top).resolve()
            scope_abs = scope.resolve() if scope is not None else None
        
            cmd = ["git", "-C", repo_top, "log",
                   "--pretty=format:", "--name-only"]
            if since:
                cmd.append(f"--since={since}")
            if scope_abs is not None:
                cmd += ["--", str(scope_abs)]
            try:
                raw = subprocess.run(
                    cmd, capture_output=True, text=True, check=True,
                    timeout=GIT_TIMEOUT_SECONDS,
                ).stdout
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                return {}
        
            counts: dict[Path, int] = {}
            for line in raw.splitlines():
                line = line.strip()
                if not line:
                    continue
                path = (repo / line).resolve()
                try:
                    path.relative_to(root)
                except ValueError:
                    continue
                # A pathspec narrows which commits git walks, but a commit that touches
                # a scoped file can also touch a sibling one; drop those so the count is
                # strictly the subtree's.
                if scope_abs is not None and not path.is_relative_to(scope_abs):
                    continue
                counts[path] = counts.get(path, 0) + 1
            return counts
        
        
        def git_commit_info(root: Path) -> dict:
            """Capture the commit the scan measured, so the report can pin its absolute
            LOC/CCN figures to a snapshot and warn when that snapshot is stale.
        
            Issue #59 observed figures drifting 15-25% low because a run measured an
            older commit than the one a reader later compared against. Absolute numbers
            are only trustworthy against a named commit; this surfaces that commit plus
            two staleness signals.
        
            Returns a dict:
              - ``available``: False (with ``reason``) when ``root`` isn't a git repo or
                git is unreachable; the report then omits the snapshot line.
              - ``head_sha`` / ``head_short``: the commit HEAD pointed at during the scan.
              - ``committed_date``: ISO-8601 author date of HEAD.
              - ``subject``: HEAD's commit subject line.
              - ``dirty``: True when tracked files have uncommitted changes, so the
                measured numbers reflect the working tree, not any single commit.
              - ``upstream``: the upstream tracking ref (e.g. ``origin/main``) or None.
              - ``behind``: commits HEAD is behind ``upstream`` (0 = up to date,
                None = no upstream configured), i.e. how stale the snapshot is vs remote.
            """
            def _git(*args: str) -> str | None:
                try:
                    out = subprocess.run(
                        ["git", "-C", str(root), *args],
                        capture_output=True, text=True, check=True,
                        timeout=GIT_TIMEOUT_SECONDS,
                    )
                except (subprocess.CalledProcessError, FileNotFoundError,
                        subprocess.TimeoutExpired):
                    return None
                return out.stdout.strip()
        
            head_sha = _git("rev-parse", "HEAD")
            if not head_sha:
                return {"available": False,
                        "reason": "not a git repo or no commits on HEAD"}
        
            info: dict = {
                "available": True,
                "head_sha": head_sha,
                "head_short": head_sha[:12],
                "committed_date": _git("show", "-s", "--format=%cd", "--date=short",
                                       "HEAD"),
                "subject": _git("show", "-s", "--format=%s", "HEAD"),
                # `--porcelain` with untracked excluded: a non-empty result means the
                # scan saw uncommitted edits to tracked files, so its numbers don't
                # match the HEAD commit exactly.
                "dirty": bool(_git("status", "--porcelain", "--untracked-files=no")),
                "upstream": None,
                "behind": None,
            }
        
            upstream = _git("rev-parse", "--abbrev-ref", "--symbolic-full-name",
                            "@{upstream}")
            if upstream:
                info["upstream"] = upstream
                behind = _git("rev-list", "--count", "HEAD..@{upstream}")
                try:
                    info["behind"] = int(behind) if behind is not None else None
                except ValueError:
                    info["behind"] = None
            return info
        
        
        def file_last_commit_epoch(path: Path) -> int | None:
            """Unix timestamp (epoch seconds) of `path`'s last commit. None if untracked.
        
            Uses author time (``%at``), not committer time (``%ct``). Author time
            reflects when the change was originally made, not when it was
            rebased/cherry-picked - a rebase must not certify stale docs as fresh. A doc
            authored 90 days ago and rebased yesterday should still read 90 days stale;
            ``%ct`` would reset the clock and hide the decay. This matches the author-time
            (``%at``) axis `lib.accretion_ratchet` and `lib.promissory_markers` already use.
        
            The raw commit time, before the days-ago conversion `file_last_commit_days`
            applies. Provenance-aware staleness needs the absolute timestamp so it can
            compare a generated doc against its declared source on the same axis (epoch
            seconds), independent of "now" - see `lib.doc_provenance`.
            """
            try:
                out = subprocess.run(
                    ["git", "log", "-1", "--format=%at", "--", str(path)],
                    cwd=path.parent if path.parent.exists() else Path.cwd(),
                    capture_output=True, text=True, check=False,
                    timeout=GIT_TIMEOUT_SECONDS,
                )
            except (FileNotFoundError, subprocess.TimeoutExpired):
                return None
            raw = out.stdout.strip()
            if not raw:
                return None
            try:
                return int(raw)
            except ValueError:
                return None
        
        
        def file_last_commit_days(path: Path) -> int | None:
            """Days since `path` was last committed in git. None if not tracked.
        
            Distinct from churn count - this is the staleness axis (how long since
            the file last moved), used to colour the docs-staleness heatmap and to
            compute the doc-vs-code staleness ratio. None means "no git history for
            this file" so callers can degrade rather than treating untracked as fresh.
            """
            import datetime as _dt
        
            ts = file_last_commit_epoch(path)
            if ts is None:
                return None
            delta = _dt.datetime.now().timestamp() - ts
            return max(0, int(delta // 86400))
        
        
        # Bulk mechanical commits (issue #333). A licence-header sweep, formatter run or
        # mass rename touches most of a repository's docs without changing what any of
        # them says; counting it as each doc's last change resets every staleness clock
        # to the sweep's date and hides the lying maps the metric exists to find.
        # Precision first: a commit is bulk only when it touches MORE than this share of
        # the repository's docs...
        BULK_COMMIT_DOC_SHARE = 0.5
        # ...and at least this many of them. Below the floor, editing every doc at once
        # is ordinary work on a small doc set (a README and two guides), not a sweep.
        BULK_COMMIT_MIN_DOCS = 10
        # Bound on the skipped-commit list written to run-context.json; the total is
        # reported beside it.
        BULK_COMMITS_SKIPPED_CAP = 20
        
        
        class ContentClock(NamedTuple):
            """Last content-change time per doc, with bulk mechanical commits skipped.
        
            ``epochs`` maps a resolved doc path to the author time (``%at``) of its
            newest non-bulk commit, or of its oldest commit when every commit touching
            it is bulk (a bulk import that created it is its content creation).
            ``creation_fallback`` names the docs that took that oldest-commit fallback:
            for a doc regenerated in bulk on every release the value is its creation
            date, not a content age, so callers disclose it and discount it.
            ``bulk_shas`` holds every bulk commit, so :meth:`epoch` can apply the same
            skip to a non-doc file (``.cursorrules``). ``skipped`` lists, newest first
            and capped, the bulk commits that were newer than some doc's chosen commit.
            ``complete`` is False when the full history was not read. On a git
            failure or timeout the maps are empty and :meth:`epoch` falls back to the
            plain newest-commit read, the behaviour before this clock existed. In a
            shallow clone the maps are filled from the visible history, which can omit
            older content commits and bulk commits.
            """
        
            epochs: dict[Path, int]
            bulk_shas: frozenset[str]
            skipped: tuple[dict, ...]
            skipped_total: int
            doc_count: int
            complete: bool
            creation_fallback: frozenset[Path] = frozenset()
        
            def epoch(self, path: Path) -> int | None:
                path = path.resolve()
                if path in self.epochs:
                    return self.epochs[path]
                return _file_content_epoch(path, self.bulk_shas)
        
            def days(self, path: Path) -> int | None:
                import datetime as _dt
        
                ts = self.epoch(path)
                if ts is None:
                    return None
                return max(0, int((_dt.datetime.now().timestamp() - ts) // 86400))
        
        
        def _file_content_epoch(path: Path, bulk_shas: frozenset[str]) -> int | None:
            """Per-file fallback: newest commit to `path` not in `bulk_shas`, else its
            oldest commit. None if untracked or git fails."""
            if not bulk_shas:
                return file_last_commit_epoch(path)
            try:
                out = subprocess.run(
                    ["git", "log", "--format=%H %at", "--", str(path)],
                    cwd=path.parent if path.parent.exists() else Path.cwd(),
                    capture_output=True, text=True, check=False,
                    timeout=GIT_TIMEOUT_SECONDS,
                ).stdout
            except (FileNotFoundError, subprocess.TimeoutExpired):
                return None
            rows = [ln.split() for ln in out.splitlines() if len(ln.split()) == 2]
            for sha, at in rows:
                if sha not in bulk_shas:
                    return int(at)
            return int(rows[-1][1]) if rows else None
        
        
        @lru_cache(maxsize=4)
        def content_commit_clock(
            repo_root: Path,
            docs: frozenset[Path],
            head: str | None = None,
            renames: tuple[tuple[str, str], ...] = (),
        ) -> ContentClock:
            """One ``git log`` pass over the docs' history, skipping bulk commits.
        
            `docs` (resolved paths) is both the denominator for the bulk share and the
            set the clock covers. The log is restricted to the docs' extensions, so its
            cost scales with doc history, not the whole tree. Outside a git repo the
            clock is empty and complete; on a git failure it is empty and incomplete.
            Cached so the doc-staleness metric and the instruction grader share one pass;
            `head` (the HEAD sha) is only a cache key, so a new commit gets a fresh clock.
            `renames` maps historical repo-relative paths to current ones (as
            ``change_coupling.build_rename_map`` gives them), so a doc's commits under an
            old name still count after a mass rename.
            """
            empty = ContentClock({}, frozenset(), (), 0, len(docs), True)
            try:
                top = subprocess.run(
                    ["git", "-C", str(repo_root), "rev-parse", "--show-toplevel"],
                    capture_output=True, text=True, check=True, timeout=GIT_TIMEOUT_SECONDS,
                ).stdout.strip()
            except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
                return empty
            top_path = Path(top).resolve()
            exts = sorted({d.suffix.lower() for d in docs if d.suffix})
            if not exts:
                return empty
            cmd = ["git", "-C", top, "-c", "core.quotepath=off", "log",
                   "--format=%x01%H %at", "--name-only", "--no-renames", "--"]
            cmd += [f":(glob,icase)**/*{e}" for e in exts]
            try:
                raw = subprocess.run(
                    cmd, capture_output=True, text=True, check=True,
                    timeout=GIT_TIMEOUT_SECONDS, errors="replace",
                ).stdout
            except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
                return ContentClock({}, frozenset(), (), 0, len(docs), False)
            # Complete only when git confirms the history is not shallow; a failed or
            # timed-out probe cannot vouch for it.
            try:
                probe = subprocess.run(
                    ["git", "-C", top, "rev-parse", "--is-shallow-repository"],
                    capture_output=True, text=True, check=False, timeout=GIT_TIMEOUT_SECONDS,
                )
                full_history = probe.returncode == 0 and probe.stdout.strip() == "false"
            except (FileNotFoundError, subprocess.TimeoutExpired):
                full_history = False
            rename_to = dict(renames)
        
            # Parse newest first: (sha, epoch, docs in `docs` the commit touched).
            commits: list[tuple[str, int, set[Path]]] = []
            for ln in raw.splitlines():
                if ln.startswith("\x01"):
                    sha, _, at_raw = ln[1:].partition(" ")
                    commits.append((sha, int(at_raw), set()))
                elif ln.strip() and commits:
                    rel = ln.strip()
                    p = top_path / rename_to.get(rel, rel)
                    if p in docs:
                        commits[-1][2].add(p)
        
            need = max(BULK_COMMIT_MIN_DOCS, int(BULK_COMMIT_DOC_SHARE * len(docs)) + 1)
            bulk = {sha for sha, _, touched in commits if len(touched) >= need}
            epochs: dict[Path, int] = {}
            pending: dict[Path, list[str]] = {}  # bulk commits seen before a doc resolves
            oldest: dict[Path, tuple[str, int]] = {}
            skipped: set[str] = set()
            for sha, at, touched in commits:
                for p in touched:
                    oldest[p] = (sha, at)
                    if p in epochs:
                        continue
                    if sha in bulk:
                        pending.setdefault(p, []).append(sha)
                    else:
                        epochs[p] = at
                        skipped.update(pending.pop(p, []))
            # A doc whose every commit is bulk falls back to its oldest (its creation);
            # the bulk commits newer than that were still skipped.
            for p, shas in pending.items():
                epochs[p] = oldest[p][1]
                skipped.update(s for s in shas if s != oldest[p][0])
        
            records = [
                {"sha": sha,
                 "date": _dt_date(at),
                 "docs_touched": len(touched),
                 "doc_count": len(docs)}
                for sha, at, touched in commits if sha in skipped
            ]
            return ContentClock(
                epochs=epochs,
                bulk_shas=frozenset(bulk),
                skipped=tuple(records[:BULK_COMMITS_SKIPPED_CAP]),
                skipped_total=len(records),
                doc_count=len(docs),
                complete=full_history,
                creation_fallback=frozenset(pending),
            )
        
        
        def _dt_date(epoch: int) -> str:
            import datetime as _dt
        
            return _dt.datetime.fromtimestamp(epoch, tz=_dt.timezone.utc).strftime("%Y-%m-%d")
        
        
        @lru_cache(maxsize=8)
        def tracked_files(root: Path) -> frozenset[Path] | None:
            """Resolved absolute paths of git-tracked files under `root`.
        
            This is the precise definition of "files in the repo": it excludes
            untracked and ignored files (e.g. a contributor's personal CLAUDE.md left
            in the working tree). Returns None when `root` isn't a git repo, so callers
            fall back to a plain filesystem walk.
        
            Cached (read-only result) so the several callers in one assessment - doc
            graph, staleness (x2), instruction grading - don't each shell out to git.
            Pass an already-resolved `root` for cache hits.
            """
            try:
                repo_top = subprocess.run(
                    ["git", "-C", str(root), "rev-parse", "--show-toplevel"],
                    capture_output=True, text=True, check=True, timeout=GIT_TIMEOUT_SECONDS,
                ).stdout.strip()
                raw = subprocess.run(
                    ["git", "-C", str(root), "ls-files", "--full-name", "-z"],
                    capture_output=True, text=True, check=True, timeout=GIT_TIMEOUT_SECONDS,
                ).stdout
            except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
                return None
            repo = Path(repo_top)
            out: set[Path] = set()
            for rel in raw.split("\0"):
                if not rel:
                    continue
                try:
                    out.add((repo / rel).resolve())
                except OSError:
                    continue
            return frozenset(out)
        
        
        # Time windows tried in order from narrowest to widest. The first one
        # that gives both gradient depth (max >= MIN_MAX) AND visual coverage
        # (>= MIN_COVERAGE_PCT of files touched) wins. If nothing qualifies, we
        # fall back to the widest window that returned any data. since=None
        # means "full history".
        CHURN_WINDOWS: list[tuple[str, str | None]] = [
            ("last 12mo", "12 months ago"),
            ("last 24mo", "24 months ago"),
            ("last 5y",   "5 years ago"),
            ("all-time",  None),
        ]
        MIN_MAX = 3          # max commits per file must clear this for visible gradient
        MIN_COVERAGE_PCT = 10.0   # % of scored files with any activity in the window
        
        
        def pick_churn_window(
            root: Path, file_paths: list[Path],
        ) -> tuple[dict[Path, int] | None, str | None]:
            """Walk CHURN_WINDOWS narrowest-to-widest; return the first window
            that gives both gradient depth and visual coverage. Falls back to
            the widest non-empty window if none qualify.
        
            `file_paths` is the set of scored files; "touched" means touched AND
            scoreable, so coverage % is comparable across windows. Returns
            (per-file-count-dict, "commits (<label>)") or (None, None) when the
            path isn't inside a git repo.
            """
            total_files = max(len(file_paths), 1)
            widest_fallback: tuple[dict[Path, int], str] | None = None
        
            for label, since in CHURN_WINDOWS:
                data = git_churn_scores(root, since=since)
                if not data:
                    continue
                scored_hits = {p: data[p] for p in file_paths if p in data}
                if not scored_hits:
                    continue
                if widest_fallback is None or label == CHURN_WINDOWS[-1][0]:
                    widest_fallback = (data, label)
                coverage = 100.0 * len(scored_hits) / total_files
                max_commits = max(scored_hits.values())
                if max_commits >= MIN_MAX and coverage >= MIN_COVERAGE_PCT:
                    if label != CHURN_WINDOWS[0][0]:
                        print(f"note: activity sparse - widened window to "
                              f"{label} ({coverage:.0f}% of files touched, "
                              f"max {max_commits} commits).",
                              file=sys.stderr)
                    return ({p: data.get(p, 0) for p in file_paths},
                            f"commits ({label})")
        
            if widest_fallback is not None:
                data, label = widest_fallback
                scored_hits = {p: data[p] for p in file_paths if p in data}
                coverage = 100.0 * len(scored_hits) / total_files
                max_commits = max(scored_hits.values()) if scored_hits else 0
                print(f"note: activity below thresholds in every window; "
                      f"using {label} ({coverage:.0f}% coverage, "
                      f"max {max_commits}).", file=sys.stderr)
                return ({p: data.get(p, 0) for p in file_paths},
                        f"commits ({label})")
            return None, None
        
        
        # A churn window is "degenerate" when its commits-per-file distribution is
        # effectively flat: almost every file that moved in the window shows a single
        # commit. That is the fingerprint of a history with no usable churn signal -
        # a shallow clone, a fresh import, or a squashed/extracted source tree where
        # every file was created in one bulk commit. A count of "1 commit" there is an
        # extraction artifact, not a measure of how much the file actually churns.
        #
        # The danger is that downstream signals read the count as if it meant activity:
        # `code_churn_in_window` swells to ~= the file count, the doc-staleness ratio
        # inflates, and a *precise* doc->code association (high `subject_method`
        # confidence) then stamps a high-confidence `lying_map` onto pure noise. The two
        # properties are independent - association precision says *which* code a doc maps
        # to; measurement reliability says whether the churn count *means* anything - and
        # only this flag carries the second. Consumers that read it degrade: cap finding
        # confidence, drop churn-derived findings from the summary, flatten the treemap
        # saturation axis. This is the single source of truth; consumers thread the
        # boolean rather than recomputing the distribution.
        DEGENERATE_CHURN_P95 = 1
        # Below this many files with any activity the distribution is too small to judge.
        # A three-file utility repo where each file shows one commit is not the
        # shallow-clone / squashed-history artifact this guards against, so we never call
        # degeneracy on a handful of files - we'd risk flattening a genuinely tiny repo.
        MIN_ACTIVE_FILES_FOR_DEGENERACY = 5
        
        
        def churn_is_degenerate(
            commit_counts: Iterable[int],
            min_active_files: int = MIN_ACTIVE_FILES_FOR_DEGENERACY,
            p95_threshold: int = DEGENERATE_CHURN_P95,
        ) -> bool:
            """True when a per-file commit-count distribution carries no churn signal.
        
            `commit_counts` is any iterable of per-file commit counts - the values of a
            churn map (``pick_churn_window`` / ``git_churn_scores`` output). Files with
            zero commits in the window are ignored: degeneracy is a property of the
            *shape* of the activity among files that actually moved, not of how many sat
            idle. Among those active files we take the 95th percentile (nearest-rank, so
            a couple of genuinely-churned files can't mask an otherwise-flat import) and
            call the window degenerate when it is at or below ``p95_threshold`` (1 by
            default - "almost every touched file shows a single commit"). p95 rather than
            max so the detector reports on the bulk of the distribution, equivalent to
            "near-zero variance across files".
        
            Returns False when fewer than ``min_active_files`` files have any activity:
            too few data points to distinguish a degenerate history from a legitimately
            small or quiet repo, where flattening the churn signal would be the wrong
            call. Pure and side-effect-free so every consumer (doc-staleness join,
            keyhole summary, treemap) can read the same verdict off the same data.
            """
            active = sorted(c for c in commit_counts if c and c > 0)
            if len(active) < min_active_files:
                return False
            # Nearest-rank p95: index of the ceil(0.95 * n)-th value (1-based), so for an
            # all-ones distribution p95 == 1 and a single outlier among thousands of
            # ones still leaves p95 == 1.
            idx = max(0, math.ceil(0.95 * len(active)) - 1)
            return active[idx] <= p95_threshold
        
      • instruction_claims.py 19.3 KB
        """Verify the checkable claims an agent instruction file makes about the repo.
        
        An instruction file that says "`scripts/check-x.sh` is enforced in CI" or "Node
        20.11.0 is pinned in `.nvmrc`" is a map an agent trusts without checking. When
        the workflow stopped calling the script, or the pin moved, the sentence still
        reads as true. This module extracts those sentences and checks each against the
        repository, no model: a failed claim is a lying signal with a file and a line.
        
        Claim kinds:
        
        - ``enforcement``: a backticked script path in a sentence holding "enforced",
          "runs in", "checked by" or the word "CI". A script path is a shell script
          (``.sh``, ``.bash``, ``.zsh``, ``.ps1``) anywhere, or any script extension
          (``.py``, ``.js``, ``.ts`` ...) under a ``scripts/``, ``bin/``, ``tools/``,
          ``ci/`` or ``hack/`` directory; ordinary source files such as ``src/index.ts``
          are not something CI invokes by path. Verified when the path occurs in any
          CI configuration (``.github/workflows/``, ``.github/actions/``, GitLab,
          Jenkins, CircleCI, Azure, Buildkite, Bitbucket, Travis, Drone) or in a task
          runner CI commonly calls through (``Makefile``, ``package.json``,
          ``.pre-commit-config.yaml``, ``justfile``, ``Taskfile.yml``, ``tox.ini``,
          ``noxfile.py``), using the reference search of ``lib.evidence_check``. A repo
          with no CI configuration at all yields no enforcement claim: there is
          nothing to check against, so the claim is unverifiable, not false.
        - ``pin``: a sentence holding "pinned in" followed by a backticked file, plus
          exactly one dotted numeric version (``20.11.0``) on either side of the phrase.
          Verified when the file exists and contains the version as a substring (so
          ``3.11`` verifies against a file holding ``3.11.9``: the check under-reports
          rather than accuse). A missing file is a failed claim; a sentence with no
          version, or with two different versions, is skipped.
        - ``count``: an integer followed by a word ("43 pgTAP suites") in a sentence
          that also names one backticked glob pattern (``supabase/tests/*.sql``). The
          pattern is matched relative to the repository root and the matching files
          (not directories) are counted. Verified when the two numbers differ by no
          more than the larger of 10% (of the larger number) or 2. There is no noun
          table: the backticked pattern is the only thing that makes a number
          checkable, so a sentence with no pattern, a pattern with no wildcard (a
          directory may hold files or subdirectories), two integers or two patterns
          (which counts which is a guess), or a pattern that leaves the repository is
          skipped. The pattern must also look like a path (a ``/``, or a last
          segment ending in a plain extension such as ``*.md``), so ``**kwargs`` or a
          ``?`` placeholder is not a pattern, and a four-digit year is not a count.
          When the pattern's wildcard-free directory does not exist, or the glob
          cannot be evaluated, the claim is unverifiable and skipped; a directory
          that exists with no match fails with ``actual`` 0. Matching follows
          ``pathlib``, where ``*`` also matches dotfiles (a shell would not); matches
          that resolve outside the repository (through a symlink) are not counted,
          nor, below the pattern's fixed prefix, the trees every scan excludes
          (``.git``, ``.assess``, ``node_modules``, ``.venv`` ...). A pattern that
          matches only directories, or a subtree the walk cannot read, is also
          unverifiable rather than a wrong count, as is a non-recursive pattern whose
          matches mix files and directories; a Windows drive or UNC path is skipped.
          The sentence must have the frame "<integer> <noun> ... <link> `<pattern>`":
          the integer before the pattern and a word from ``COUNT_LINK_WORDS`` (in,
          under, matching, across, beneath, within, inside) between them. A second,
          closed-list filter then drops an integer followed by one of the unit words
          or preceded by one of the comparators in ``COUNT_NOT_A_COUNT`` ("at most 10
          files in", "500 lines"); a word outside those lists is not recognised.
        
        A sentence that fits no kind is skipped silently. Adding a kind means one
        extractor in ``_EXTRACTORS`` (sentence -> claims) and one verifier in
        ``_VERIFIERS`` (claim -> None when it holds, else extra fields for the failure;
        it raises ``Unverifiable`` when the repository cannot settle the claim).
        
        Sentences are read per paragraph, so a claim wrapped across lines is still
        found; its ``line`` is the 1-based line the sentence starts on. Fenced code is
        not prose and is not read.
        """
        from __future__ import annotations
        
        import os
        import re
        from collections.abc import Callable, Iterable
        from dataclasses import dataclass, field
        from pathlib import Path, PureWindowsPath
        from typing import Any, NamedTuple
        
        from lib.doc_graph import is_excluded_path
        from lib.evidence_check import is_referenced_in
        
        # Where CI is configured. The enforcement check needs at least one to exist.
        CI_CONFIG_PATHS = (
            ".github/workflows", ".github/actions", ".gitlab-ci.yml", ".gitlab-ci.yaml",
            "Jenkinsfile", ".circleci", "azure-pipelines.yml", ".azure-pipelines",
            ".buildkite", "bitbucket-pipelines.yml", ".travis.yml", ".drone.yml",
        )
        # Task runners CI calls through (`make lint`, `npm run lint`, `pre-commit run`):
        # a script referenced here counts as wired in, which fails open rather than
        # accusing a repo whose workflow invokes the script indirectly.
        TASK_RUNNER_PATHS = (
            "Makefile", "makefile", "GNUmakefile", "package.json", ".pre-commit-config.yaml",
            "justfile", "Justfile", "Taskfile.yml", "Taskfile.yaml", "tox.ini", "noxfile.py",
        )
        
        _SHELL_EXTENSIONS = "sh|bash|zsh|ps1"
        _SCRIPT_EXTENSIONS = "sh|bash|zsh|ps1|py|js|mjs|cjs|ts|rb|pl"
        _SCRIPT_DIRS = "scripts|bin|tools|ci|hack"
        # A script path inside a backticked span: `scripts/check-x.sh` or the path in
        # `bash scripts/check-x.sh --fix`. Shell scripts anywhere; other script
        # extensions only under a scripts-like directory.
        _SCRIPT_IN_SPAN = re.compile(
            rf"(?<![\w./-])((?:\./)?(?:[\w.-]+/)*[\w.-]*\w\.(?:{_SHELL_EXTENSIONS})"
            rf"|(?:\./)?(?:[\w.-]+/)*(?:{_SCRIPT_DIRS})/(?:[\w.-]+/)*[\w.-]*\w\.(?:{_SCRIPT_EXTENSIONS}))"
            rf"(?![\w/-])"
        )
        _BACKTICK_SPAN = re.compile(r"`([^`]+)`")
        _ENFORCEMENT_TRIGGER = re.compile(r"(?i:\benforced\b|\bruns in\b|\bchecked by\b)|\bCI\b")
        
        _PINNED_IN = re.compile(r"pinned in\s+`([^`\s]+)`", re.IGNORECASE)
        _VERSION = re.compile(r"(?<![\d.])(\d+(?:\.\d+)+)(?!\.?\d)")
        
        # A count: an integer, not part of a version, decimal, list or percentage,
        # followed by a word. Read with backticked spans removed.
        # A year (1900-2099) is skipped: far more often a date than a file count.
        _COUNT = re.compile(
            r"(?<![\w.,%$/-])(?!(?:19|20)\d\d(?!\d))(\d+)(?=\s+([A-Za-z][\w-]*))")
        
        
        # The frame of a count sentence: "<integer> <noun> ... <link> `<pattern>`".
        # The integer comes before the pattern and one of these words sits between
        # them ("43 suites matching `x/*.sql`", "150 migrations live in `x/*.sql`").
        COUNT_LINK_WORDS = re.compile(
            r"\b(?:in|under|matching|across|beneath|within|inside)\b", re.IGNORECASE)
        
        
        class _ThresholdSigns(NamedTuple):
            units: frozenset[str]  # the word after the integer
            comparator: re.Pattern[str]  # the text just before the integer
        
        
        # Signs that an integer beside a pattern is a threshold, not a file count
        # ("below 500 lines", "at most 10 files"). This list only ever removes
        # claims, never adds one: it is not a table of things that are counted.
        COUNT_NOT_A_COUNT = _ThresholdSigns(
            units=frozenset({
                "line", "lines", "loc", "character", "characters", "char", "chars",
                "word", "words", "byte", "bytes", "kb", "mb", "gb", "token", "tokens",
                "column", "columns", "percent", "ms", "second", "seconds", "sec", "secs",
                "minute", "minutes", "min", "mins", "hour", "hours", "hr", "hrs",
                "day", "days", "week", "weeks", "month", "months", "year", "years",
            }),
            comparator=re.compile(
                r"\b(?:below|under|above|over|at most|at least|up to|no more than|"
                r"fewer than|less than|more than|max|maximum|min|minimum|limit)"
                r"(?:\s+of)?\s*$", re.IGNORECASE),
        )
        _GLOB_CHARS = re.compile(r"[*?]")
        # A path shape: a directory separator, or a last segment with a plain extension.
        _PLAIN_EXTENSION = re.compile(r"\.[A-Za-z0-9]+$")
        COUNT_TOLERANCE = 0.10
        COUNT_MIN_DELTA = 2
        
        _FENCE = re.compile(r"^\s*(```|~~~)")
        # A line that starts its own block rather than continuing the paragraph above.
        _BLOCK_START = re.compile(r"^\s*(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|\||>)")
        _HEADING = re.compile(r"^\s*#{1,6}\s")
        _SENTENCE_END = re.compile(r"[.!?](?=\s|$)")
        
        
        class Unverifiable(Exception):
            """The repository cannot settle the claim either way: skip it, never fail it."""
        
        
        @dataclass
        class Claim:
            kind: str
            line: int
            path: str
            fields: dict[str, Any] = field(default_factory=dict)
        
        
        def _paragraphs(text: str) -> Iterable[list[tuple[int, str]]]:
            """Yield runs of (1-based line number, line) that form one prose block."""
            block: list[tuple[int, str]] = []
            fenced = False
            for number, line in enumerate(text.splitlines(), start=1):
                if _FENCE.match(line):
                    fenced = not fenced
                    if block:
                        yield block
                    block = []
                    continue
                if fenced or not line.strip() or _BLOCK_START.match(line):
                    if block:
                        yield block
                    if fenced or not line.strip():
                        block = []
                    elif _HEADING.match(line):
                        # A heading is its own block: the prose under it, blank line
                        # or not, must not borrow its words or report its line.
                        yield [(number, line)]
                        block = []
                    else:
                        block = [(number, line)]  # a list item or table row seeds its block
                    continue
                block.append((number, line))
            if block:
                yield block
        
        
        def _sentences(block: list[tuple[int, str]]) -> Iterable[tuple[int, str]]:
            """Split one block into (start line, sentence), ignoring stops inside backticks."""
            joined = ""
            starts: list[tuple[int, int]] = []  # (offset in joined, line number)
            for number, line in block:
                if joined:
                    joined += " "
                starts.append((len(joined), number))
                joined += line.strip()
        
            def line_at(offset: int) -> int:
                current = starts[0][1]
                for begin, number in starts:
                    if begin > offset:
                        break
                    current = number
                return current
        
            ticks = [i for i, ch in enumerate(joined) if ch == "`"]
            begin = 0
            for match in _SENTENCE_END.finditer(joined):
                end = match.end()
                if sum(1 for t in ticks if t < end) % 2:
                    continue  # the stop sits inside a backticked span
                yield from _emit(joined, begin, end, line_at)
                begin = end
            yield from _emit(joined, begin, len(joined), line_at)
        
        
        def _emit(joined: str, begin: int, end: int,
                  line_at: Callable[[int], int]) -> Iterable[tuple[int, str]]:
            sentence = joined[begin:end]
            stripped = sentence.strip()
            if stripped:
                yield line_at(begin + len(sentence) - len(sentence.lstrip())), stripped
        
        
        def _enforcement_claims(sentence: str, line: int) -> list[Claim]:
            if not _ENFORCEMENT_TRIGGER.search(sentence):
                return []
            paths: list[str] = []
            for span in _BACKTICK_SPAN.findall(sentence):
                for raw in _SCRIPT_IN_SPAN.findall(span):
                    path = raw.removeprefix("./")
                    if path not in paths:
                        paths.append(path)
            return [Claim("enforcement", line, path) for path in paths]
        
        
        def _pin_claims(sentence: str, line: int) -> list[Claim]:
            pins = _PINNED_IN.findall(sentence)
            if len(pins) != 1:
                return []
            rest = _PINNED_IN.sub(" ", sentence)
            versions = set(_VERSION.findall(rest))
            if len(versions) != 1:
                return []
            return [Claim("pin", line, pins[0], {"version": versions.pop()})]
        
        
        def _count_claims(sentence: str, line: int) -> list[Claim]:
            spans = [m for m in _BACKTICK_SPAN.finditer(sentence)
                     if _GLOB_CHARS.search(m.group(1)) and not re.search(r"\s", m.group(1))]
            if len(spans) != 1:
                return []
            span = spans[0]
            pattern = span.group(1).removeprefix("./")
            if (pattern.startswith(("/", "~")) or ".." in Path(pattern).parts
                    or PureWindowsPath(pattern).drive):
                return []
            if "/" not in pattern and not _PLAIN_EXTENSION.search(pattern):
                return []  # `**kwargs`, `*args`, a `?` placeholder: not a path
            # Blank the backticked spans in place, so offsets still match ``span``.
            prose = _BACKTICK_SPAN.sub(lambda m: " " * len(m.group(0)), sentence)
            numbers = list(_COUNT.finditer(prose))
            if len(numbers) != 1:
                return []
            number = numbers[0]
            if (number.end() > span.start()
                    or not COUNT_LINK_WORDS.search(prose, number.end(), span.start())):
                return []  # not "<integer> <noun> ... <link> `<pattern>`"
            if (number.group(2).lower() in COUNT_NOT_A_COUNT.units
                    or COUNT_NOT_A_COUNT.comparator.search(prose[:number.start()])):
                return []  # a threshold ("below 500 lines"), not a count of files
            return [Claim("count", line, pattern, {"claimed": int(number.group(1))})]
        
        
        def count_within_tolerance(claimed: int, actual: int) -> bool:
            """True when the difference is at most the larger of 10% or 2."""
            allowed = max(COUNT_TOLERANCE * max(claimed, actual), COUNT_MIN_DELTA)
            return abs(claimed - actual) <= allowed
        
        
        def _unreadable_below(base: Path, rest: tuple[str, ...]) -> bool:
            """True when a directory the pattern would descend into cannot be listed.
            ``Path.glob`` drops such a subtree silently, which would read as a low count."""
            depth = None if "**" in rest else len(rest) - 1
            errors: list[OSError] = []
            for dirpath, dirnames, _ in os.walk(base, onerror=errors.append):
                rel = Path(dirpath).relative_to(base)
                if depth is not None and len(rel.parts) >= depth:
                    dirnames[:] = []
                dirnames[:] = [d for d in dirnames if not is_excluded_path(rel / d)]
            return bool(errors)
        
        
        def _count_matches(repo_root: Path, pattern: str) -> int:
            """Files matching ``pattern`` under the root.
        
            Below the pattern's wildcard-free prefix, the trees every scan excludes
            (``doc_graph.is_excluded_path``: ``.git``, ``.assess``, ``node_modules``,
            ``.venv`` ...) are not counted; a prefix that names one on purpose still
            counts. Raises ``Unverifiable`` when the prefix directory is missing, the
            glob cannot be evaluated, a subtree cannot be read, or the pattern matched
            only directories, so none of those reads as a wrong count. ``_count_claims``
            guarantees a wildcard in the pattern, so the prefix search always ends.
            """
            root = repo_root.resolve()
            parts = Path(pattern).parts
            split = next(i for i, part in enumerate(parts) if _GLOB_CHARS.search(part))
            base = root.joinpath(*parts[:split])
            try:
                if not base.is_dir() or not base.resolve().is_relative_to(root):
                    raise Unverifiable
                if _unreadable_below(base, parts[split:]):
                    raise Unverifiable
                dirs = count = 0
                for match in root.glob(pattern):
                    if is_excluded_path(match.relative_to(base).parent):
                        continue
                    if match.is_file():
                        if match.resolve().is_relative_to(root):
                            count += 1
                    elif match.is_dir():
                        dirs += 1
            except (OSError, ValueError, NotImplementedError, RuntimeError) as error:
                raise Unverifiable from error
            if dirs and (not count or "**" not in parts):
                # Only directories, or a flat pattern mixing files and directories:
                # the sentence may count the directories, which this kind cannot check.
                raise Unverifiable
            return count
        
        
        def _has_ci_config(repo_root: Path) -> bool:
            return any((repo_root / rel).exists() for rel in CI_CONFIG_PATHS)
        
        
        def _verify_enforcement(repo_root: Path, claim: Claim) -> dict[str, Any] | None:
            for rel in CI_CONFIG_PATHS + TASK_RUNNER_PATHS:
                if is_referenced_in(repo_root, claim.path, rel):
                    return None
            return {"reason": "no CI configuration or task runner references the script"}
        
        
        def _verify_pin(repo_root: Path, claim: Claim) -> dict[str, Any] | None:
            version = claim.fields["version"]
            if is_referenced_in(repo_root, version, claim.path):
                return None
            if not (repo_root / claim.path).exists():
                return {"reason": "pinned file does not exist"}
            return {"reason": "pinned file does not contain the version"}
        
        
        def _verify_count(repo_root: Path, claim: Claim) -> dict[str, Any] | None:
            actual = _count_matches(repo_root, claim.path)
            if count_within_tolerance(claim.fields["claimed"], actual):
                return None
            return {"actual": actual,
                    "reason": "the number of files matching the pattern differs from the claim"}
        
        
        _EXTRACTORS: tuple[Callable[[str, int], list[Claim]], ...] = (
            _enforcement_claims,
            _pin_claims,
            _count_claims,
        )
        _VERIFIERS: dict[str, Callable[[Path, Claim], dict[str, Any] | None]] = {
            "enforcement": _verify_enforcement,
            "pin": _verify_pin,
            "count": _verify_count,
        }
        
        
        def extract_claims(text: str) -> list[Claim]:
            """Every claim pattern in ``text``, in document order (before any
            repository-dependent skip, such as enforcement with no CI configured)."""
            claims: list[Claim] = []
            for block in _paragraphs(text):
                for line, sentence in _sentences(block):
                    for extract in _EXTRACTORS:
                        claims.extend(extract(sentence, line))
            return claims
        
        
        def empty_block() -> dict[str, Any]:
            return {"total": 0, "verified": 0, "failed": 0, "failures": []}
        
        
        def scan_instruction_claims(repo_root: Path | str, files: Iterable[str]) -> dict[str, Any]:
            """Extract and verify the claims in each instruction file.
        
            ``files`` are paths relative to ``repo_root`` (the keys of the core's
            ``instruction_files``). A file that cannot be read as UTF-8 is skipped, and
            two keys that resolve to the same file (an ``AGENTS.md`` symlinked to
            ``CLAUDE.md``) are scanned once, under the first key.
        
            Returns ``{total, verified, failed, failures}``; each failure carries
            ``file``, ``line``, ``kind``, ``path`` (the script, pinned file or counted
            pattern) and ``reason``, plus the kind's own fields (``version`` for a pin,
            ``claimed`` and ``actual`` for a count).
            """
            root = Path(repo_root)
            block = empty_block()
            seen: set[Path] = set()
            ci_configured = _has_ci_config(root)
            for rel in files:
                candidate = root / rel
                try:
                    real = candidate.resolve()
                    text = candidate.read_text(encoding="utf-8")
                except (OSError, RuntimeError, UnicodeDecodeError):
                    continue
                if real in seen:
                    continue
                seen.add(real)
                for claim in extract_claims(text):
                    if claim.kind == "enforcement" and not ci_configured:
                        continue  # nothing to check against: unverifiable, not false
                    try:
                        detail = _VERIFIERS[claim.kind](root, claim)
                    except Unverifiable:
                        continue  # the repository cannot settle it: skipped, not false
                    block["total"] += 1
                    if detail is None:
                        block["verified"] += 1
                        continue
                    block["failed"] += 1
                    block["failures"].append({
                        "file": rel, "line": claim.line, "kind": claim.kind,
                        "path": claim.path, **claim.fields, **detail,
                    })
            return block
        
      • interactivity.py 4.4 KB
        """Decide whether an /assess run may prompt a human, and record the offer
        lifecycle when it may not.
        
        /assess makes several consent offers across its run - installing analysis tools,
        running the code-modifying mutation pass, opening a PR, tracking findings,
        freezing a CI gate, filing feedback, uninstalling. Every one needs a human to
        answer. In a **headless or CI** run no human is present, so an offer that blocks
        on input would hang the pipeline forever. The contract is therefore explicit:
        **when no human can answer, every offer is treated as declined** and recorded,
        so a non-interactive run completes with zero interactive prompts and an audit
        trail of what was skipped and why.
        
        **Why not `isatty()`.** The core (`assess_core.py`) is launched via
        `uv run "${CLAUDE_SKILL_DIR}/scripts/assess_core.py"` from a Bash tool, so it runs as a
        tool-invoked subprocess with **no controlling terminal** - `sys.stdin.isatty()`
        is False even in a perfectly normal interactive `/assess`. A subprocess stdin
        TTY says nothing about whether the orchestrator (the agent running SKILL.md) can
        prompt the human via AskUserQuestion, which is a model-level tool that works
        regardless of subprocess stdin. So the decider is an **explicit signal the
        orchestrator passes in**, not a stdin probe: the run is interactive by default,
        and non-interactive only when the orchestrator marks it so (the `--non-interactive`
        flag / ``ASSESS_NON_INTERACTIVE`` env var it sets on its own headless/CI path) or
        when the ``CI`` env var is set. The orchestrator self-determines interactivity in
        Phase 1 (before the core runs) from its own runtime context; passing the same
        signal into the core keeps Phases 2/3 consistent with Phase 1.
        
        Pure stdlib; no side effects. The orchestrator reads `interactive` /`offers`
        from run-context.json and honours the same contract for the offers it makes by
        hand (AskUserQuestion), which a script cannot make for it.
        """
        from __future__ import annotations
        
        import os
        from typing import Any
        
        # Env var the orchestrator may set (as an alternative to the --non-interactive
        # CLI flag) to mark a headless/CI run. Any truthy value counts.
        NON_INTERACTIVE_ENV: str = "ASSESS_NON_INTERACTIVE"
        
        # Canonical offer types spanning the skill's whole consent lifecycle. The three
        # interactive phases plus the end-of-run uninstall offer:
        #   Phase 1 (tool installs):      tool_install
        #   Phase 3 (code modification):  mutation
        #   Phase 2 (write-back):         pr, issue_tracking, ci_gate, feedback
        #   End-of-run:                   uninstall
        OFFER_TYPES: tuple[str, ...] = (
            "tool_install",
            "mutation",
            "pr",
            "issue_tracking",
            "ci_gate",
            "feedback",
            "uninstall",
        )
        
        
        def is_interactive(
            *, non_interactive: bool = False, env: dict[str, str] | None = None
        ) -> bool:
            """True unless an explicit non-interactive signal is present.
        
            Interactive is the **default** - a normal /assess invocation. Non-interactive
            only when the orchestrator says so: ``non_interactive=True`` (the caller's
            headless/CI path, set from its own runtime context), or ``CI`` /
            ``ASSESS_NON_INTERACTIVE`` set truthy in the environment. ``isatty()`` is
            deliberately not consulted - the core always runs as a subprocess with no
            controlling terminal, so it is never a valid interactivity signal here.
            """
            if non_interactive:
                return False
            environ = env if env is not None else os.environ
            if environ.get("CI"):
                return False
            if environ.get(NON_INTERACTIVE_ENV):
                return False
            return True
        
        
        def non_interactive_offers(
            offer_types: tuple[str, ...] = OFFER_TYPES,
            *,
            reason: str = "non-interactive",
        ) -> list[dict[str, str]]:
            """Every offer recorded as skipped - the headless/CI decline contract."""
            return [
                {"type": t, "status": "skipped", "reason": reason} for t in offer_types
            ]
        
        
        def build_offers_block(
            *, non_interactive: bool = False, env: dict[str, str] | None = None
        ) -> dict[str, Any]:
            """Run-context ``offers`` block plus the ``interactive`` flag.
        
            Interactive: ``offers`` is empty - the orchestrator makes the offers live
            via AskUserQuestion. Non-interactive: every offer is pre-recorded as
            skipped, and the orchestrator must make **no** prompts.
            """
            interactive = is_interactive(non_interactive=non_interactive, env=env)
            return {
                "interactive": interactive,
                "offers": [] if interactive else non_interactive_offers(),
            }
        
      • jvm_capabilities.py 21.3 KB
        """JVM/Maven capability-driven analysis offers (issue #113, v1 bounded).
        
        Generalises /assess's tool mapping from a hardcoded per-language allowlist
        (``vulture`` for Python, ``ts-prune`` for TS, ``staticcheck`` for Go) to a
        *capability-driven detect-or-propose* model, proven on ONE capability (liveness)
        in ONE build system (Maven). The defect this fixes is the **non-enumeration
        architecture**: when a repo's language isn't in the allowlist, every analysis
        capability silently degrades to "unavailable" - the report reads "this layer is
        absent here" rather than "a tool could serve this - install one?". JVM is simply
        the first ecosystem to expose it.
        
        For each JVM analysis capability the scan reports a STATE the report and the
        offer-layer (SKILL.md Step 2) act on:
        
          * ``served``         - liveness, when ``mvn dependency:analyze`` has been run
                                 (run-consent) and its coarse module-level candidates
                                 are fed into the dead-code signal.
          * ``offer``          - liveness, when Maven is detected but the analyze goal
                                 has not been run. The agent should offer to resolve it
                                 inside the run. ``consent`` distinguishes the shape:
                                 ``run`` when ``mvn`` is already on PATH (a RUN-consent -
                                 ``dependency:analyze`` needs a *compiling build*, not
                                 just an install), ``install`` when ``mvn`` is absent.
          * ``credited``       - a capability already served by a configured pom.xml
                                 plugin (Checkstyle / SpotBugs / PMD / error-prone /
                                 OpenRewrite); detected and NOT re-offered.
          * ``honest_degrade`` - a capability nothing serves yet; the report NAMES the
                                 capability and an ecosystem-appropriate candidate tool.
                                 A deliverable distinct from both "Present" and a silent
                                 "Missing" - module graph, linting, modernization, and
                                 every capability under Gradle take this state in v1.
        
        Boundary: the candidate tool named here is the deterministic DEFAULT. The
        assessing agent has latitude to propose a different ecosystem-appropriate tool
        at runtime (SKILL.md Step 2); that choice is human-judged, not CI-tested. CI
        tests SIGNAL CONSUMPTION - given a tool's output, the scorecard feeds correctly.
        
        Detection (issue #351): a build file counts only when the repository holds at
        least one ``.java``, ``.kt``, ``.scala`` or ``.groovy`` file outside
        platform-wrapper ``android/`` directories (Flutter, React Native, Capacitor, and
        Cordova's ``platforms/android/``), so a mobile app's generated Gradle shell never
        reads as a JVM codebase. The prune also covers a Flutter *plugin* package: its
        ``android/src/main/kotlin/`` holds real Kotlin beside the plugin's own
        ``pubspec.yaml``, but that code is the plugin's Android platform implementation,
        not a JVM codebase, so it is skipped with the app wrapper.
        
        Out of scope for v1 (fast-follow): healing the module graph (``jdeps``), linting,
        and modernization; Gradle plugin reading and a served Gradle path; non-JVM
        languages benefiting from the same general flow.
        """
        from __future__ import annotations
        
        import json
        import os
        import re
        import shutil
        from pathlib import Path
        from typing import Any
        
        from lib.doc_graph import is_excluded_path
        
        # Capabilities a JVM project exposes, in report order. Only ``liveness`` is
        # healed in v1; the rest honest-degrade (or are credited when a plugin serves
        # them).
        JVM_CAPABILITIES = ("liveness", "module_graph", "linting", "modernization")
        
        # Ecosystem-appropriate DEFAULT candidate per capability. The agent may propose
        # an alternative at runtime (reasoned latitude); this is what the deterministic
        # report names so honest-degrade is never silent.
        _CANDIDATE_TOOLS = {
            "liveness": "mvn dependency:analyze",
            "module_graph": "jdeps",
            "linting": "Checkstyle / SpotBugs / error-prone",
            "modernization": "OpenRewrite",
        }
        
        _CAPABILITY_GLOSS = {
            "liveness": "coarse module-level dead-dependency detection",
            "module_graph": "static inter-module dependency graph",
            "linting": "style and bug-pattern static analysis",
            "modernization": "automated API / idiom migration",
        }
        
        # pom.xml needles (lowercased) that mean a capability is already served, so the
        # offer-flow CREDITS it rather than re-offering. Coarse on purpose: a substring
        # match against the pom text. error-prone is configured as a compiler
        # annotation-processor (``error_prone_core``), not a standalone plugin, so it is
        # matched by its artifact stem as well as the hyphenated name.
        _PLUGIN_CREDITS = [
            ("maven-checkstyle-plugin", "linting", "Checkstyle"),
            ("spotbugs-maven-plugin", "linting", "SpotBugs"),
            ("findbugs-maven-plugin", "linting", "FindBugs"),
            ("maven-pmd-plugin", "linting", "PMD"),
            ("error_prone_core", "linting", "error-prone"),
            ("error-prone", "linting", "error-prone"),
            ("rewrite-maven-plugin", "modernization", "OpenRewrite"),
            ("modernizer-maven-plugin", "modernization", "Modernizer"),
        ]
        
        # Cap parsed liveness candidates so a pathological multi-module reactor can't
        # bloat run-context.json (mirrors liveness_scan.MAX_CANDIDATES intent).
        MAX_JVM_CANDIDATES = 50
        
        # Section headers in ``mvn dependency:analyze`` output. Only "unused declared"
        # is surfaced as liveness dead-weight (a declared dependency nothing in the
        # module references - coarse module-level dead code). "Used undeclared" is a
        # build-hygiene signal, counted in a note but not a liveness candidate.
        _UNUSED_DECLARED_HEADER = "unused declared dependencies"
        _USED_UNDECLARED_HEADER = "used undeclared dependencies"
        
        # A Maven coordinate line, after the optional ``[WARNING]``/``[INFO]`` log
        # prefix is stripped: ``group:artifact:type:version:scope`` (>= 4 colons). The
        # leading whitespace under a section header is what Maven uses to nest entries.
        _XML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
        _LOG_PREFIX_RE = re.compile(r"^\s*\[[A-Z]+\]\s*")
        _COORD_RE = re.compile(
            r"^([\w.\-]+):([\w.\-]+):[\w.\-]+:[\w.\-]+(?::[\w.\-]+)?\s*$"
        )
        
        
        # A JVM codebase needs at least one of these source files outside a platform
        # wrapper; a build file alone (a stray pom.xml, a Flutter android/ shell) does not
        # make a repository a JVM project.
        _JVM_SOURCE_SUFFIXES = (".java", ".kt", ".scala", ".groovy")
        _MAVEN_FILES = frozenset({"pom.xml"})
        _GRADLE_FILES = frozenset({"build.gradle", "build.gradle.kts"})
        
        # package.json dependencies whose presence makes a sibling ``android/`` directory
        # a generated platform wrapper rather than a JVM codebase.
        _WRAPPER_NPM_PACKAGES = frozenset({"react-native", "@capacitor/android", "cordova-android"})
        # The package that marks a Cordova app root, whose wrapper is platforms/android/.
        _CORDOVA_NPM_PACKAGES = frozenset({"cordova-android"})
        
        
        def _package_json_names_wrapper(path: Path,
                                        packages: frozenset[str] = _WRAPPER_NPM_PACKAGES,
                                        ) -> bool:
            try:
                data = json.loads(_read(path))
            except ValueError:
                return False
            if not isinstance(data, dict):
                return False
            for section in ("dependencies", "devDependencies"):
                deps = data.get(section)
                if isinstance(deps, dict) and packages.intersection(deps):
                    return True
            return False
        
        
        def _is_platform_wrapper_parent(dirpath: Path, filenames: list[str]) -> bool:
            """True when ``dirpath``'s ``android/`` child is a platform wrapper: the
            directory holds a ``pubspec.yaml`` (Flutter) or a ``package.json`` naming
            React Native, Capacitor or Cordova's Android platform."""
            if "pubspec.yaml" in filenames:
                return True
            return ("package.json" in filenames
                    and _package_json_names_wrapper(dirpath / "package.json"))
        
        
        def _is_cordova_root(dirpath: Path, filenames: list[str]) -> bool:
            """True when ``dirpath`` is a Cordova app root, whose generated Android
            project sits at ``platforms/android/``: a ``config.xml`` in the Cordova
            namespace, or a ``package.json`` naming ``cordova-android``."""
            if "config.xml" in filenames and "cordova.apache.org" in _read(dirpath / "config.xml"):
                return True
            return ("package.json" in filenames
                    and _package_json_names_wrapper(dirpath / "package.json",
                                                    _CORDOVA_NPM_PACKAGES))
        
        
        def _scan_jvm_tree(repo_root: Path,
                           extra_exclude_dirs: set[str] | None = None,
                           extra_exclude_patterns: list[str] | None = None,
                           ) -> tuple[list[str], list[str], bool]:
            """One walk returning ``(pom_files, gradle_files, has_jvm_source)``.
        
            Skips vendored / build / fixture dirs, any user-supplied exclude (so a
            fixture ``pom.xml`` under ``tests/fixtures/`` never makes a Python repo look
            like a Maven project), and every platform-wrapper ``android/`` directory,
            whose build files and source both belong to a non-JVM app: an ``android/``
            beside the app manifest, or Cordova's ``platforms/android/``.
            """
            from lib.assess_config import is_user_excluded
            extra_dirs = extra_exclude_dirs or set()
            extra_pats = extra_exclude_patterns or []
            poms: list[str] = []
            gradles: list[str] = []
            has_source = False
            wrappers: set[Path] = set()
            for dirpath, dirnames, filenames in os.walk(repo_root):
                here = Path(dirpath)
                rel_dir = here.relative_to(repo_root)
                if "android" in dirnames and _is_platform_wrapper_parent(here, filenames):
                    wrappers.add(rel_dir / "android")
                if "platforms" in dirnames and _is_cordova_root(here, filenames):
                    wrappers.add(rel_dir / "platforms" / "android")
                dirnames[:] = [
                    d for d in dirnames
                    if rel_dir / d not in wrappers
                    and not is_excluded_path(rel_dir / d)
                    and not is_user_excluded(rel_dir / d, extra_dirs, [])
                ]
                for name in filenames:
                    is_pom = name in _MAVEN_FILES
                    is_gradle = name in _GRADLE_FILES
                    is_source = name.endswith(_JVM_SOURCE_SUFFIXES)
                    if not (is_pom or is_gradle or (is_source and not has_source)):
                        continue
                    rel = rel_dir / name
                    if is_excluded_path(rel) or is_user_excluded(rel, extra_dirs, extra_pats):
                        continue
                    if is_pom:
                        poms.append(rel.as_posix())
                    elif is_gradle:
                        gradles.append(rel.as_posix())
                    else:
                        has_source = True
            return sorted(poms), sorted(gradles), has_source
        
        
        def detect_build_system(repo_root: Path,
                                extra_exclude_dirs: set[str] | None = None,
                                extra_exclude_patterns: list[str] | None = None,
                                ) -> tuple[str | None, list[str]]:
            """Return ``(build_system, sorted_relative_build_files)``.
        
            A ``pom.xml`` or Gradle build file counts only when the repository holds at
            least one ``.java``, ``.kt``, ``.scala`` or ``.groovy`` file outside
            platform-wrapper directories (an ``android/`` beside a ``pubspec.yaml``, or
            beside a ``package.json`` naming ``react-native``, ``@capacitor/android`` or
            ``cordova-android``; or Cordova's ``platforms/android/``). Build files under a
            wrapper are never listed.
        
            Maven wins when both are present - it is the served path in v1, so a
            polyglot repo with a ``pom.xml`` still gets the liveness offer. Gradle is
            detected (so it honest-degrades with a named candidate) but has no served
            path in v1.
            """
            repo_root = repo_root.resolve()
            poms, gradles, has_source = _scan_jvm_tree(
                repo_root,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
            )
            if not has_source:
                return None, []
            if poms:
                return "maven", poms
            if gradles:
                return "gradle", gradles
            return None, []
        
        
        def _read(path: Path) -> str:
            try:
                return path.read_text(encoding="utf-8", errors="ignore")
            except OSError:
                return ""
        
        
        def detect_configured_plugins(repo_root: Path, build_files: list[str]) -> dict[str, list[str]]:
            """Map ``capability -> [served_by, ...]`` for plugins already configured in
            the given Maven ``build_files``.
        
            Coarse: a lowercased substring match against each pom's text. Crediting an
            already-configured tool (so it isn't re-offered) is low-harm if over-eager,
            and a precise XML parse would add a dependency for marginal gain in v1.
            """
            served: dict[str, list[str]] = {}
            for rel in build_files:
                # Strip XML comments first: an explanatory comment ("no error-prone yet")
                # must not credit a tool the build doesn't actually configure.
                text = _XML_COMMENT_RE.sub(" ", _read((repo_root / rel))).lower()
                if not text:
                    continue
                for needle, capability, label in _PLUGIN_CREDITS:
                    if needle in text:
                        served.setdefault(capability, [])
                        if label not in served[capability]:
                            served[capability].append(label)
            return served
        
        
        def parse_dependency_analyze(stdout: str, pom_path: str = "pom.xml") -> list[dict]:
            """Parse ``mvn dependency:analyze`` output into coarse liveness candidates.
        
            Surfaces *unused declared dependencies* - a dependency a module declares but
            nothing in it references, i.e. dead weight at module granularity. Returns
            the same ``{path, line, kind, symbol}`` shape as the per-symbol dead-code
            tier so the existing ``dead_code`` consumers need no change. ``path`` is the
            pom that declared it; ``symbol`` is the ``group:artifact`` coordinate.
            """
            out: list[dict] = []
            in_unused = False
            for raw in stdout.splitlines():
                lowered = raw.lower()
                if _UNUSED_DECLARED_HEADER in lowered:
                    in_unused = True
                    continue
                if _USED_UNDECLARED_HEADER in lowered:
                    in_unused = False
                    continue
                if not in_unused:
                    continue
                stripped = _LOG_PREFIX_RE.sub("", raw).strip()
                m = _COORD_RE.match(stripped)
                if not m:
                    # A non-coordinate line ends the unused-declared block.
                    if stripped:
                        in_unused = False
                    continue
                out.append({
                    "path": pom_path,
                    "line": 0,
                    "kind": "unused declared dependency",
                    "symbol": f"{m.group(1)}:{m.group(2)}",
                })
            return out
        
        
        def count_used_undeclared(stdout: str) -> int:
            """Count *used undeclared dependencies* - a build-hygiene signal noted
            alongside the liveness candidates but not itself dead weight."""
            count = 0
            in_block = False
            for raw in stdout.splitlines():
                lowered = raw.lower()
                if _USED_UNDECLARED_HEADER in lowered:
                    in_block = True
                    continue
                if _UNUSED_DECLARED_HEADER in lowered:
                    in_block = False
                    continue
                if not in_block:
                    continue
                stripped = _LOG_PREFIX_RE.sub("", raw).strip()
                if _COORD_RE.match(stripped):
                    count += 1
                elif stripped:
                    in_block = False
            return count
        
        
        def _liveness_capability(build_system: str, mvn_on_path: bool,
                                 analyze_output: str | None,
                                 pom_path: str) -> dict:
            """Build the ``liveness`` capability entry, the only one healed in v1."""
            cap: dict[str, Any] = {
                "candidate_tool": _CANDIDATE_TOOLS["liveness"],
                "gloss": _CAPABILITY_GLOSS["liveness"],
            }
            # Gradle has no served liveness path in v1 - honest-degrade with a named
            # candidate rather than a silent miss.
            if build_system != "maven":
                cap.update({
                    "state": "honest_degrade",
                    "consent": None,
                    "candidate_count": 0,
                    "candidates": [],
                    "note": ("Gradle liveness is deferred to a fast-follow; Maven is the "
                             "served path in v1. A candidate tool is named so the "
                             "capability degrades honestly rather than silently."),
                })
                return cap
            if analyze_output is not None:
                candidates = parse_dependency_analyze(analyze_output, pom_path=pom_path)[:MAX_JVM_CANDIDATES]
                used_undeclared = count_used_undeclared(analyze_output)
                cap.update({
                    "state": "served",
                    "consent": "run",
                    "candidate_count": len(candidates),
                    "candidates": candidates,
                    "used_undeclared_count": used_undeclared,
                    "note": ("Coarse module-level liveness from `mvn dependency:analyze`: "
                             f"{len(candidates)} unused declared dependency(ies), "
                             f"{used_undeclared} used-undeclared. Dependency granularity, "
                             "not per-symbol - it flags dead weight a module declares but "
                             "never references."),
                })
                return cap
            # Maven detected, analyze not run: OFFER. Consent shape depends on whether
            # mvn is already invokable (run-consent) or must be installed first.
            cap.update({
                "state": "offer",
                "consent": "run" if mvn_on_path else "install",
                "candidate_count": 0,
                "candidates": [],
                "note": (
                    "`mvn dependency:analyze` needs a compiling build (resolves deps, "
                    "may hit the network), so a read-only assessment does not run it by "
                    "default. " + (
                        "Offer to RUN it against the project (run-consent)."
                        if mvn_on_path else
                        "Maven is not on PATH; offer to INSTALL it, then run the analyze "
                        "goal (install-consent)."
                    )
                ),
            })
            return cap
        
        
        def _credited_or_degraded(capability: str, served: dict[str, list[str]]) -> dict:
            """Build a non-liveness capability entry: credited when a configured plugin
            serves it, otherwise honest-degrade with a named candidate."""
            cap: dict[str, Any] = {
                "candidate_tool": _CANDIDATE_TOOLS[capability],
                "gloss": _CAPABILITY_GLOSS[capability],
            }
            if capability in served:
                cap.update({
                    "state": "credited",
                    "served_by": served[capability],
                    "note": (f"Already served by configured {', '.join(served[capability])} "
                             "in pom.xml - detected and credited, not re-offered."),
                })
            else:
                cap.update({
                    "state": "honest_degrade",
                    "note": (f"No configured tool serves {capability} "
                             f"({_CAPABILITY_GLOSS[capability]}); v1 names a candidate "
                             "rather than healing it. Honest-degrade is a deliverable, "
                             "not a silent miss."),
                })
            return cap
        
        
        def scan_jvm_capabilities(repo_root: Path, *,
                                  run_build_tools: bool = False,
                                  analyze_output: str | None = None,
                                  mvn_on_path: bool | None = None,
                                  extra_exclude_dirs: set[str] | None = None,
                                  extra_exclude_patterns: list[str] | None = None,
                                  ) -> dict:
            """Capability-driven JVM scan. Never raises - degrades to ``available: False``.
        
            ``analyze_output`` lets a caller (and CI's consumption test) feed canned
            ``mvn dependency:analyze`` output without invoking Maven, so the SERVED path
            is exercised deterministically. With ``run_build_tools=True`` and ``mvn`` on
            PATH and no ``analyze_output`` supplied, the analyze goal is run for real
            (opt-in run-consent). The default scan stays read-only: liveness reports the
            ``offer`` state instead.
            """
            repo_root = repo_root.resolve()
            build_system, build_files = detect_build_system(
                repo_root,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
            )
            if build_system is None:
                return {"available": False, "build_system": None, "build_files": []}
        
            if mvn_on_path is None:
                mvn_on_path = shutil.which("mvn") is not None
            pom_path = build_files[0] if build_files else "pom.xml"
        
            # Opt-in run-consent: actually run the analyze goal when asked and possible.
            if (build_system == "maven" and analyze_output is None
                    and run_build_tools and mvn_on_path):
                analyze_output = _run_dependency_analyze(repo_root)
        
            served = (detect_configured_plugins(repo_root, build_files)
                      if build_system == "maven" else {})
        
            capabilities = {
                "liveness": _liveness_capability(
                    build_system, mvn_on_path, analyze_output, pom_path),
                "module_graph": _credited_or_degraded("module_graph", served),
                "linting": _credited_or_degraded("linting", served),
                "modernization": _credited_or_degraded("modernization", served),
            }
            return {
                "available": True,
                "build_system": build_system,
                "build_files": build_files,
                "capabilities": capabilities,
            }
        
        
        def _run_dependency_analyze(repo_root: Path) -> str | None:
            """Run ``mvn dependency:analyze`` (opt-in run-consent). Returns combined
            stdout/stderr, or ``None`` on any failure - the scan then falls back to the
            offer state rather than crashing the assessment."""
            import subprocess
            try:
                proc = subprocess.run(
                    ["mvn", "-q", "dependency:analyze"],
                    cwd=str(repo_root), capture_output=True, text=True,
                    timeout=300, check=False,
                )
            except (OSError, subprocess.TimeoutExpired):
                return None
            return (proc.stdout or "") + (proc.stderr or "")
        
      • keyhole_signals.py 61.5 KB
        """Keyhole-signal integration: turn the five lib modules into run-context blocks.
        
        Task #5's deterministic core. The individual signals (change-coupling B1/B2/B4,
        the doc x complexity join C, understanding B4+D2, static-vs-historical B3, static
        structure A1-A4) each live in their own module with their own contract tests.
        This module is the *integration barrier*: it derives the per-directory
        containment view, assembles the five new ``run-context.json`` blocks
        (``behaviour`` / ``documentation`` / ``understanding`` / ``runtime`` /
        ``structure``), and crucially emits the **derived findings** - the deterministic
        array of the six named findings that is the assessment's primary product.
        
        Every function here is a pure transform of already-computed signal outputs (plus,
        for containment, the git-log commit-file-sets the orchestrator parses once and
        reuses). No function writes files; ``assess_core.build_run_context`` calls
        :func:`integrate` and merges the result into the context dict.
        
        **Defensive by construction.** :func:`integrate` wraps each block build in a
        catch-all so one signal's failure or timeout degrades that block to
        ``available: False`` rather than crashing the whole ``/assess`` run. The
        deterministic core stays ignorant of LLM-derived prose - this module emits only
        structured data + the named findings; the LLM write-back fills judgement later.
        """
        from __future__ import annotations
        
        from collections import Counter, defaultdict
        from dataclasses import dataclass, field
        from pathlib import Path
        
        from lib.assess_config import is_user_excluded
        from lib.change_coupling import (
            RenameMap,
            authorship_analysis,
            build_rename_map,
            change_coupling_pairs,
            containment_ratio,
            find_self_referential_tests,
            fold_renames,
            parse_commit_file_sets,
            repo_top,
        )
        from lib.coupling_analysis import detect_hidden_coupling, find_refactor_boundaries
        from lib.doc_complexity_join import (
            _extract_file_ccn,
            _high_ccn_threshold,
            analyze_doc_complexity_join,
        )
        from lib.liveness_scan import STATIC_REACHABILITY_CAVEAT
        from lib.structure_drift import detect_grouping_disagreement
        from lib.sibling_tests import find_colocated_test, is_test_path
        from lib.understanding_analysis import analyze_understanding
        
        # Caps so a pathological repo can't bloat run-context.json. The treemap and
        # liveness blocks already cap their own lists; these bound the new ones.
        MAX_COUPLING_PAIRS = 100
        MAX_CONTAINMENT_DIRS = 50
        MAX_AUTHORSHIP_PATHS = 40
        MAX_ATTENTION_UNITS = 10
        
        # A directory must be touched by at least this many commits before its
        # containment ratio is meaningful - below it the ratio is noise (one or two
        # commits can't establish whether edits "stay contained").
        MIN_DIR_COMMITS_FOR_CONTAINMENT = 5
        
        # The named derived findings, in a fixed report order (worst-first, the one
        # positive last). The action strings are the deterministic recommendation the
        # report leads with; the LLM elaborates but never contradicts them.
        FINDING_ORDER = [
            "hidden_coupling",
            "lying_map",
            "unexplained_complexity",
            "untrusted_hotspot",  # E1: complex churning code with hollow tests
            "self_referential_tests",  # E2: tests authored with the code they cover
            "unactioned_intent",  # stale promissory markers that survived many edits
            "accretion_ratchet",  # files that only ever grow - never meaningfully cut back
            "orphaned_understanding",
            "candidate_dead_weight",
            "override_contradicts_signals",  # archetype marker disagrees with the signals
            "refactor_boundary",
        ]
        FINDING_ACTIONS = {
            "hidden_coupling": "investigate the seam",
            "lying_map": "fix or delete the doc",
            "unexplained_complexity": "write the missing contract (do NOT auto-generate)",
            "untrusted_hotspot": "strengthen tests to pin observable behaviour (not internal state)",
            "self_referential_tests": "request human review - tests verify internal consistency, not truth",
            "unactioned_intent": "action the promise: fix it, ticket it, or delete the marker/skip",
            "accretion_ratchet": "refactor down: extract, delete dead code, or split the file",
            "orphaned_understanding": "assign a human anchor before further change",
            "candidate_dead_weight": "verify liveness, then delete if dead",
            "override_contradicts_signals": (
                "Review archetype marker - deterministic signals suggest a different "
                "classification"
            ),
            "refactor_boundary": "safe to hand an agent in isolation",
        }
        
        # The execution *mode* each finding type warrants - the deterministic posture an
        # executor should take before touching the code, derived from the finding, never
        # guessed by the LLM. Three modes, each tracing to one of the write-side
        # tendencies the toolkit guards against:
        #
        #   characterize_first  - understand/contract the code before changing it. The
        #       risk is acting blind on a seam or a complexity nobody has pinned, so the
        #       first move is to characterise (write the missing contract, investigate
        #       the seam, anchor an owner), not to edit.
        #   verify_then_retire  - a self-description that may be lying (a stale doc, an
        #       aged marker, a self-referential test, a maybe-dead file). Verify whether
        #       it is still true, then retire it - delete, ticket, or escalate. Never
        #       trust it as-is.
        #   refactor_safe       - a bounded island safe to restructure in isolation (a
        #       refactor boundary, or an accreted file to extract/split down).
        #
        # Every name in FINDING_ORDER is mapped; a finding that reaches mode derivation
        # without a mapping (or an action with no finding attribution) falls back to
        # DEFAULT_FINDING_MODE - characterize_first, the conservative "understand before
        # you touch it" posture.
        FINDING_MODES = {
            "hidden_coupling": "characterize_first",
            "lying_map": "verify_then_retire",
            "unexplained_complexity": "characterize_first",
            "untrusted_hotspot": "characterize_first",
            "self_referential_tests": "verify_then_retire",
            "unactioned_intent": "verify_then_retire",
            "accretion_ratchet": "refactor_safe",
            "orphaned_understanding": "characterize_first",
            "candidate_dead_weight": "verify_then_retire",
            "override_contradicts_signals": "characterize_first",
            "refactor_boundary": "refactor_safe",
        }
        DEFAULT_FINDING_MODE = "characterize_first"
        # The closed set of modes, exposed for validators/tests that assert coverage.
        FINDING_MODE_VALUES = frozenset(FINDING_MODES.values())
        
        
        def mode_for_finding(name: str | None) -> str:
            """The deterministic execution mode for a finding type.
        
            Returns :data:`DEFAULT_FINDING_MODE` for an unknown or missing finding name,
            so a caller can derive a mode unconditionally (an action with no finding
            attribution still gets the conservative characterize-first posture).
            """
            return FINDING_MODES.get(name or "", DEFAULT_FINDING_MODE)
        
        # --------------------------------------------------------------------------
        # B2 - per-directory containment
        # --------------------------------------------------------------------------
        
        def _candidate_dirs(
            commit_sets: list[set[Path]], min_commits: int, max_dirs: int,
        ) -> list[str]:
            """Directories worth computing a containment ratio for.
        
            Every ancestor directory of every touched file is a candidate (a "module"
            can live at any level), except the repo root ``.`` - its containment is
            vacuously high (everything is under it) and tells us nothing. A directory
            qualifies only when at least ``min_commits`` commits touch *something* under
            it; the busiest ``max_dirs`` win. Counting is per-commit (a commit touching
            three files in one dir counts once for that dir), so the threshold means
            "this many distinct changes," matching the containment denominator.
            """
            dir_commits: Counter[str] = Counter()
            for files in commit_sets:
                dirs: set[str] = set()
                for f in files:
                    for parent in Path(f).parents:
                        s = parent.as_posix()
                        if s != ".":
                            dirs.add(s)
                for d in dirs:
                    dir_commits[d] += 1
            eligible = [(d, n) for d, n in dir_commits.items() if n >= min_commits]
            eligible.sort(key=lambda t: (-t[1], t[0]))
            return [d for d, _ in eligible[:max_dirs]]
        
        
        def containment_by_dir(
            repo_root: Path,
            commit_sets: list[set[Path]],
            min_commits: int = MIN_DIR_COMMITS_FOR_CONTAINMENT,
            max_dirs: int = MAX_CONTAINMENT_DIRS,
        ) -> dict[str, float]:
            """B2: ``{directory: containment_ratio}`` for the active directories.
        
            Reuses :func:`change_coupling.containment_ratio` (one pass over the commit
            file-sets per directory). Returns repo-relative posix directory keys mapped
            to a ratio in ``[0, 1]`` (rounded), highest = safest island.
            """
            dirs = _candidate_dirs(commit_sets, min_commits, max_dirs)
            return {
                d: round(containment_ratio(repo_root, d, commit_sets), 4) for d in dirs
            }
        
        
        def project_static_modularity(
            structure: dict | None, dirs: list[str],
        ) -> dict | None:
            """Project the repo-level static-modularity view onto per-directory keys.
        
            ``structure_graph`` currently emits a single repo-level ``modularity_q`` /
            ``front_door_ratio`` (not per-directory). The B3 cross
            (``detect_hidden_coupling`` / ``find_refactor_boundaries``) consumes a
            per-directory view, so - per ``coupling_analysis``'s documented contract -
            the caller is responsible for the projection. This is the v1 **coarse**
            projection: every directory in ``dirs`` inherits the repo-level metrics. It
            means "the repo looks modular overall, yet this directory bleeds
            historically" -> a hidden-coupling *candidate* worth a human's eye, never a
            verdict.
        
            The caller must pass only directories the static graph has evidence about
            (Python-bearing ones - the import graph is silent on a ``docs/`` or
            ``.github/`` tree). A directory absent from the returned dict has no static
            evidence and correctly degrades to ``bleeding_module`` (historical-only)
            rather than a false ``hidden_coupling``. Returns ``None`` (the fully
            graceful historical-only path) when no static graph is available at all.
            """
            if not structure or not structure.get("available"):
                return None
            metrics = {
                "modularity_q": structure.get("modularity_q"),
                "front_door_ratio": structure.get("front_door_ratio"),
            }
            return {d: dict(metrics) for d in dirs}
        
        
        def _python_bearing_dirs(commit_sets: list[set[Path]]) -> set[str]:
            """Repo-relative dirs (and ancestors) that contain at least one .py file.
        
            Derived from the commit file-sets (git-consistent, no extra filesystem
            walk) so it agrees with the containment view. These are the only
            directories the Python import graph could have evidence about; projecting
            the static-modularity metrics onto anything else manufactures false
            hidden-coupling findings on doc / config trees.
            """
            out: set[str] = set()
            for files in commit_sets:
                for f in files:
                    if f.suffix != ".py":
                        continue
                    for parent in Path(f).parents:
                        s = parent.as_posix()
                        if s != ".":
                            out.add(s)
            return out
        
        
        # --------------------------------------------------------------------------
        # Block builders (pure transforms of upstream signal outputs)
        # --------------------------------------------------------------------------
        
        def build_behaviour_block(
            repo_root: Path, commit_sets: list[set[Path]], structure: dict | None,
        ) -> dict:
            """The ``behaviour`` block: B1 coupling, B2 containment, B3 disagreement."""
            if not commit_sets:
                return {
                    "available": False,
                    "reason": "no git history (commit file-sets empty)",
                    "containment_by_dir": {},
                    "change_coupling_pairs": [],
                    "static_history_disagreement": [],
                    "hidden_coupling_findings": [],
                    "refactor_boundaries": [],
                }
            containment = containment_by_dir(repo_root, commit_sets)
            pairs = change_coupling_pairs(commit_sets)[:MAX_COUPLING_PAIRS]
            # Only project the (Python import-graph) static metrics onto Python-bearing
            # directories; a bleeding doc/config tree has no static evidence and
            # degrades to bleeding_module rather than a false hidden_coupling.
            python_dirs = _python_bearing_dirs(commit_sets)
            static_dirs = [d for d in containment if d in python_dirs]
            static_mod = project_static_modularity(structure, static_dirs)
            disagreement = detect_hidden_coupling(containment, static_modularity=static_mod)
            boundaries = find_refactor_boundaries(containment, static_modularity=static_mod)
            return {
                "available": True,
                "containment_by_dir": containment,
                "change_coupling_pairs": pairs,
                "static_history_disagreement": disagreement,
                "hidden_coupling_findings": [
                    d for d in disagreement if d["finding"] == "hidden_coupling"
                ],
                "refactor_boundaries": boundaries,
                "static_modularity_projection": (
                    "repo-level (coarse)" if static_mod is not None else "none"
                ),
            }
        
        
        def build_documentation_block(doc_join: dict) -> dict:
            """The ``documentation`` block: freshness, complexity coverage, Signal C."""
            if not doc_join.get("available"):
                return {
                    "available": False,
                    "reason": doc_join.get("reason", "doc x complexity join unavailable"),
                    "freshness_by_doc": {},
                    "complexity_coverage": {},
                    "stale_doc_on_complexity": [],
                    "unexplained_complexity": [],
                }
            # The doc_join "docs" list mixes real docs with undocumented high-complexity
            # code surfaced as unexplained_complexity (subject_code_count 0, freshness 0).
            # freshness/coverage are meaningful only for real docs.
            real_docs = [d for d in doc_join["docs"] if d["finding"] != "unexplained_complexity"]
            findings = doc_join.get("findings", {})
            return {
                "available": True,
                "high_ccn_threshold": doc_join.get("high_ccn_threshold"),
                "freshness_by_doc": {d["path"]: d["freshness"] for d in real_docs},
                "complexity_coverage": {
                    d["path"]: {
                        "complexity_summarised": d["complexity_summarised"],
                        "subject_code_count": d["subject_code_count"],
                        "doc_value": d["doc_value"],
                    }
                    for d in real_docs
                },
                "stale_doc_on_complexity": findings.get("lying_maps", []),
                "unexplained_complexity": findings.get("unexplained_complexity", []),
                "good_contracts": findings.get("good_contracts", []),
            }
        
        
        def build_understanding_block(understanding: dict) -> dict:
            """The ``understanding`` block: human anchor, intent source, authorship class."""
            if not understanding.get("available"):
                return {
                    "available": False,
                    "reason": understanding.get("reason", "no authorship data to analyse"),
                    "human_anchor_by_path": {},
                    "intent_source_by_path": {},
                    "authorship_class_by_path": {},
                    "orphaned_understanding": [],
                }
            modules = understanding["modules"]
            return {
                "available": True,
                "high_ccn_threshold": understanding.get("high_ccn_threshold"),
                "human_anchor_by_path": {m["path"]: m["human_anchor"] for m in modules},
                "intent_source_by_path": {m["path"]: m["intent_source"] for m in modules},
                "authorship_class_by_path": {m["path"]: m["authorship_class"] for m in modules},
                "orphaned_understanding": understanding.get("orphaned_understanding", []),
                "modules": modules,
            }
        
        
        def build_runtime_block(dead_code: dict, observability: dict) -> dict:
            """The ``runtime`` block: D1 static reachability + the observability rung.
        
            Reuses the existing ``liveness_scan`` outputs rather than re-deriving:
            ``static_reachability`` is the dead-code candidate set (what nothing in this
            repo references), carrying its own cross-boundary caveat. The observability
            rung is the runtime-evidence axis - rung 3 (reachable) is the only one that
            lets an agent actually verify liveness.
            """
            return {
                "available": True,
                "static_reachability": {
                    "available": dead_code.get("available", False),
                    "candidate_count": dead_code.get("candidate_count", 0),
                    "candidates": dead_code.get("candidates", []),
                    "tools": dead_code.get("tools", []),
                    "caveat": dead_code.get("caveat", STATIC_REACHABILITY_CAVEAT),
                },
                "observability_rung": observability.get("rung"),
                "runtime_evidence_available": bool(
                    observability.get("reachable", {}).get("present")
                ),
            }
        
        
        # --------------------------------------------------------------------------
        # Derived findings (the primary output)
        # --------------------------------------------------------------------------
        
        def _high_complexity_paths(complexity_stats: dict) -> list[str]:
            """Paths at or above the high-CCN threshold (same gate the joins use)."""
            ccn = _extract_file_ccn(complexity_stats)
            threshold = _high_ccn_threshold(complexity_stats)
            return sorted(p for p, c in ccn.items() if c >= threshold)
        
        
        def candidate_dead_weight_paths(
            complexity_stats: dict,
            dead_code: dict,
            intent_source_by_path: dict[str, bool],
        ) -> list[str]:
            """Derive *candidate dead weight* with asymmetric delete caution (PRD 5).
        
            A false "dead" is far worse than a false "alive", so this fires only on
            **positive** static-reachability evidence: a high-complexity path that the
            dead-code scan flagged (nothing in the repo references it) *and* that has no
            intent source explaining why it should exist. Mere absence of runtime
            evidence is never enough - that would flag every undocumented complex file
            in a repo without observability. The action stays "verify liveness, then
            delete if dead", never "delete".
            """
            high = set(_high_complexity_paths(complexity_stats))
            flagged = {c.get("path") for c in dead_code.get("candidates", [])}
            return sorted(
                p for p in high
                if p in flagged and not intent_source_by_path.get(p, False)
            )
        
        
        def assemble_findings(paths_by_name: dict[str, list[str]]) -> list[dict]:
            """Assemble the six named findings in fixed order.
        
            Each finding is ``{name, paths, action}``; ``paths`` is deduped and sorted
            for determinism. All six are always present (paths may be empty) so the
            run-context shape is stable and the report can rely on it.
            """
            return [
                {
                    "name": name,
                    "paths": sorted(set(paths_by_name.get(name, []))),
                    "action": FINDING_ACTIONS[name],
                }
                for name in FINDING_ORDER
            ]
        
        
        def _state_stale_threshold(findings: list[dict], promissory_markers: dict) -> None:
            """Append the scan's stale threshold to the ``unactioned_intent`` action.
        
            A reader weighing a marker that survived 6 edits against one that survived
            65 needs the bar both cleared. No-op when the scan carries no threshold.
            """
            threshold = promissory_markers.get("stale_touches_threshold")
            if not promissory_markers.get("available") or not threshold:
                return
            for f in findings:
                if f["name"] == "unactioned_intent":
                    f["action"] = (
                        f"{f['action']} (stale: an untracked marker that survived "
                        f"{threshold} or more edits to its own file)"
                    )
        
        
        def apply_config_excludes(
            findings: list[dict],
            exclude_dirs: set[str],
            exclude_patterns: list[str],
        ) -> tuple[list[dict], list[str]]:
            """Drop config-excluded paths from findings, returning ``(filtered, dropped)``.
        
            User-supplied excludes (``.assess/config.toml``) filter most scans at their
            source, but a path can still reach a finding through a signal the scan-level
            filter never sees (the git-log change-coupling and containment views parse
            raw commit file-sets). Filtering here keeps the finding set honouring the
            config, while the dropped paths are returned so the disclosure can make the
            suppression *visible* rather than silent - config-based suppression is a form
            of guardrail erosion when it happens without a trace.
        
            Returns the findings unchanged and an empty dropped-list when no excludes are
            configured, so the caller can call unconditionally.
            """
            if not exclude_dirs and not exclude_patterns:
                return findings, []
            dropped: set[str] = set()
            filtered: list[dict] = []
            for f in findings:
                kept: list[str] = []
                for p in f["paths"]:
                    if is_user_excluded(Path(p), exclude_dirs, exclude_patterns):
                        dropped.add(p)
                    else:
                        kept.append(p)
                filtered.append({**f, "paths": kept})
            return filtered, sorted(dropped)
        
        
        # Findings built from git-log path strings. Only these can name a path that no
        # longer exists: every other finding reads the working tree or the stats file.
        GIT_HISTORY_FINDINGS = frozenset({"hidden_coupling", "refactor_boundary"})
        
        
        def prune_missing_finding_paths(
            findings: list[dict], base: Path,
        ) -> tuple[list[dict], list[str]]:
            """Drop git-history finding paths absent under ``base``, returning ``(filtered, dropped)``.
        
            Renamed paths were already folded onto their current names, so a path still
            missing here was deleted with no current equivalent. ``dropped`` is the
            sorted list for the run-context ``pruned_finding_paths`` disclosure, so the
            pruning is counted rather than silent.
            """
            dropped: set[str] = set()
            filtered: list[dict] = []
            for f in findings:
                if f["name"] not in GIT_HISTORY_FINDINGS:
                    filtered.append(f)
                    continue
                kept = [p for p in f["paths"] if (base / p).exists()]
                dropped.update(p for p in f["paths"] if p not in kept)
                filtered.append({**f, "paths": kept})
            return filtered, sorted(dropped)
        
        
        # A path with any component of one of these names (case-insensitive) is kept out
        # of attention ranking: archived material is finished, so a finding on it is
        # never the first place to look. The final component counts too, so a directory
        # finding on `tools/archive` itself is excluded. The finding still names the path.
        ARCHIVE_DIR_NAMES = frozenset({"archive", "archived", "attic"})
        
        
        def is_archive_path(path: str) -> bool:
            """True when any component of ``path`` is an archive directory name."""
            return any(part.lower() in ARCHIVE_DIR_NAMES for part in Path(path).parts)
        
        
        def exclude_archive_from_attention(
            findings: list[dict], tie_break: AttentionTieBreak | None = None,
        ) -> tuple[list[dict], list[str]]:
            """Build the attention list with archive paths left out, returning ``(attention, dropped)``.
        
            ``dropped`` is the sorted list of archive paths that a negative finding
            names, the raw material for the run-context ``excluded_as_archive``
            disclosure, so the exclusion is counted rather than silent. The findings are
            not modified.
            """
            dropped = sorted({
                p for f in findings if f["name"] != "refactor_boundary"
                for p in f["paths"] if is_archive_path(p)
            })
            if not dropped:
                return build_attention_list(findings, tie_break=tie_break), []
            ranked = [
                {**f, "paths": [p for p in f["paths"] if not is_archive_path(p)]}
                for f in findings
            ]
            return build_attention_list(ranked, tie_break=tie_break), dropped
        
        
        @dataclass(frozen=True)
        class AttentionTieBreak:
            """Data that orders attention rows of equal score.
        
            ``hotspot_rank`` maps a ``top_hotspots`` path to its position in that list
            (composite rank order). ``severity`` maps a finding name to ``{path:
            severity}``, higher meaning worse; a row takes the highest severity among
            its findings, and a path with no entry counts as 0.
            """
        
            hotspot_rank: dict[str, int] = field(default_factory=dict)
            severity: dict[str, dict[str, float]] = field(default_factory=dict)
        
            def key(self, unit: dict) -> tuple:
                """Sort key: score desc, hotspot rank (members first), severity desc, path."""
                path = unit["path"]
                severity = max(
                    (self.severity.get(name, {}).get(path, 0.0) for name in unit["findings"]),
                    default=0.0,
                )
                rank = self.hotspot_rank.get(path, len(self.hotspot_rank))
                return (-unit["score"], rank, -severity, path)
        
        
        def attention_tie_break(
            complexity_stats: dict,
            promissory_markers: dict | None,
            behaviour: dict,
        ) -> AttentionTieBreak:
            """Build the attention tie-break from data the run already holds.
        
            Hotspot rank is the ``top_hotspots`` list order. Severity for
            ``unactioned_intent`` is the highest ``top_offenders[].severity`` among the
            file's stale markers. Severity for ``hidden_coupling`` is ``1 -
            containment_ratio``: a lower ratio means more of the directory's commits
            bleed outside it, the worse seam (``coupling_analysis`` sorts ascending for
            the same reason). A structure-drift directory with no hidden-coupling row
            falls back to ``containment_by_dir``.
        
            Both severities meet in one sort, so each is on a 0-1 scale: coupling is
            already, and marker severity (unbounded, at least 5 for a stale marker) is
            divided by the run's highest. Without that, every stale-marker file would
            outrank every coupling directory at equal score, and at the attention cap
            would evict them.
            """
            rank: dict[str, int] = {}
            for h in complexity_stats.get("top_hotspots") or []:
                path = h.get("path") if isinstance(h, dict) else None
                if path and path not in rank:
                    rank[path] = len(rank)
            markers: dict[str, float] = {}
            for m in (promissory_markers or {}).get("top_offenders") or []:
                path, sev = m.get("path"), m.get("severity")
                if path and isinstance(sev, (int, float)):
                    markers[path] = max(markers.get(path, 0.0), float(sev))
            top_marker = max(markers.values(), default=0.0)
            if top_marker > 0:
                markers = {p: v / top_marker for p, v in markers.items()}
            containment: dict[str, float] = {
                d: float(r) for d, r in (behaviour.get("containment_by_dir") or {}).items()
                if isinstance(r, (int, float))
            }
            for h in behaviour.get("hidden_coupling_findings") or []:
                if isinstance(h.get("containment_ratio"), (int, float)):
                    containment[h["path"]] = float(h["containment_ratio"])
            return AttentionTieBreak(
                hotspot_rank=rank,
                severity={
                    "unactioned_intent": markers,
                    "hidden_coupling": {d: 1.0 - r for d, r in containment.items()},
                },
            )
        
        
        def build_attention_list(
            findings: list[dict], max_units: int = MAX_ATTENTION_UNITS,
            tie_break: AttentionTieBreak | None = None,
        ) -> list[dict]:
            """Rank the few units worst across axes - the "where to look" list.
        
            A unit's score is how many *negative* findings name it (the one positive
            finding, ``refactor_boundary``, is a safe zone, never an attention row).
            Higher score = worse across more axes = look here first. Equal scores order
            by ``tie_break`` (hotspot rank, then severity, then path); without one they
            fall through to path.
            """
            reasons: dict[str, list[str]] = defaultdict(list)
            for f in findings:
                if f["name"] == "refactor_boundary":
                    continue
                for path in f["paths"]:
                    reasons[path].append(f["name"])
            units = [
                {"path": path, "findings": sorted(set(names)), "score": len(set(names))}
                for path, names in reasons.items()
            ]
            units.sort(key=(tie_break or AttentionTieBreak()).key)
            return units[:max_units]
        
        
        # --------------------------------------------------------------------------
        # E1 - untrusted hotspots (complexity x hollow tests)
        # --------------------------------------------------------------------------
        
        # A hotspot is "untrusted" when at least this fraction of its mutants survive -
        # the suite runs the code but doesn't pin it. Asymmetric like dead-weight: this
        # fires only on positive mutation evidence, so a read-only /assess (no opt-in
        # mutation pass) reports no untrusted hotspots rather than guessing.
        DEFAULT_SURVIVOR_DENSITY_THRESHOLD = 0.3
        
        
        def find_untrusted_hotspots(
            complexity_stats: dict,
            test_pressure: dict,
            threshold_survivor_density: float = DEFAULT_SURVIVOR_DENSITY_THRESHOLD,
        ) -> list[str]:
            """E1: complexity hotspots whose tests are hollow (mutants survive).
        
            Crosses the complexity hotspot list with the per-file mutation survivor
            density. A hotspot whose tests let a high fraction of mutants survive is a
            trust failure: the suite *visits* the code but doesn't *pin* it. Returns the
            sorted hotspot paths over the density threshold.
        
            Degrades to ``[]`` whenever there is no per-file mutation data - the default
            read-only /assess run never mutates, so E1 stays silent rather than
            manufacturing a finding from the always-on cheap heuristics. It speaks only
            when an opt-in mutation pass populated ``test_pressure.per_file``.
            """
            if not isinstance(test_pressure, dict):
                return []
            per_file = test_pressure.get("per_file") or []
            if not per_file:
                return []
            hotspot_paths = {
                h.get("path")
                for h in complexity_stats.get("top_hotspots", [])
                if h.get("path")
            }
            density_by_file: dict[str, float] = {}
            for entry in per_file:
                total = entry.get("total")
                survived = entry.get("survived") or 0
                if total:
                    density_by_file[entry.get("file")] = survived / total
            return sorted(
                p for p in hotspot_paths
                if density_by_file.get(p, 0.0) >= threshold_survivor_density
            )
        
        
        # --------------------------------------------------------------------------
        # E2 - self-referential test authorship (test+code co-located AND co-committed)
        # --------------------------------------------------------------------------
        
        def _find_sibling_test(repo_root: Path, rel_path: str) -> Path | None:
            """Return the co-located test file for a source path, or ``None``.
        
            Uses ``lib.sibling_tests.find_colocated_test``, the co-location layer of
            the resolver the hotspot page and ``test_focus`` read, so the three share one
            idiom list. E2 stops at co-location by definition (test and code co-located
            AND co-committed); a mirrored ``tests/`` tree is out of its scope. A file that
            is itself a test maps to ``None`` - it is not a source needing a sibling.
            Returns ``None`` when the source isn't on disk or no sibling is found.
            """
            if not (repo_root / rel_path).is_file() or is_test_path(rel_path):
                return None
            return find_colocated_test(repo_root, rel_path)
        
        
        def build_test_to_code_map(
            repo_root: Path, source_paths: list[str],
        ) -> dict[str, str]:
            """Map ``test_file -> source_file`` via co-location conventions.
        
            Best-effort and filesystem-only: each source path that has a co-located test
            contributes one ``{repo-relative test path: source path}`` entry. Paths the
            co-location idioms miss (far-away mirror test trees) simply don't appear,
            which correctly degrades E2 to "no evidence" rather than a false negative.
            """
            repo_root = Path(repo_root)
            mapping: dict[str, str] = {}
            for src in source_paths:
                test = _find_sibling_test(repo_root, src)
                if test is None:
                    continue
                try:
                    rel = test.relative_to(repo_root).as_posix()
                except ValueError:
                    rel = test.as_posix()
                mapping[rel] = src
            return mapping
        
        
        # --------------------------------------------------------------------------
        # Accretion ratchet finding: files that only ever grow
        # --------------------------------------------------------------------------
        
        # Maximum number of per-file detail lines emitted in the finding items list.
        # The full list is available in the run-context accretion_ratchet block; this
        # cap keeps the finding items readable and bounded.
        MAX_ACCRETION_ITEMS = 10
        
        # Unreliable-history disclaimer, mirroring the pattern used by other
        # reliability-flagged signals (e.g. unactioned_intent aging_reliable).
        _ACCRETION_UNRELIABLE_DISCLAIMER = (
            "History reliability: UNRELIABLE (degenerate history - shallow clone or "
            "squashed import). Results shown but should not be acted on without "
            "verifying against a full clone."
        )
        
        
        def _accretion_ratchet_finding(run_context: dict) -> list[str]:
            """Derive the accretion_ratchet finding paths from the run-context block.
        
            Reads ``run_context["accretion_ratchet"]``; returns an empty list when the
            block is absent, unavailable, or carries no flagged files - so the finding
            degrades to silent rather than manufacturing paths. The returned list is
            sorted by net additions descending (worst first), then by path for a stable
            tie-break, matching the serialization order in the block. Determinism is
            non-negotiable: no set iteration, no dict-order dependency.
            """
            block = run_context.get("accretion_ratchet") or {}
            if not block.get("available"):
                return []
            files = block.get("files") or []
            if not files:
                return []
            # The block already sorts by (-net_additions, path); re-sort defensively so
            # the finding list is a total, deterministic order regardless of upstream.
            ordered = sorted(files, key=lambda f: (-f["net_additions"], f["path"]))
            return [f["path"] for f in ordered]
        
        
        def _format_accretion_items(run_context: dict) -> list[str]:
            """Build the human-readable detail items for the accretion_ratchet finding.
        
            Returns a roll-up sentence followed by one line per file (capped at
            MAX_ACCRETION_ITEMS), then a reliability disclaimer when the history is
            degenerate. The format mirrors the sibling per-file lines used in the
            report: path, net LOC added, time span, commit count, and deletion
            fraction, with plain-English time span phrasing.
            """
            block = run_context.get("accretion_ratchet") or {}
            files = block.get("files") or []
            if not files:
                return []
        
            total = block.get("total_in_band", len(files))
            top = sorted(files, key=lambda f: (-f["net_additions"], f["path"]))
            hottest = top[:MAX_ACCRETION_ITEMS]
        
            def _months_str(months: float) -> str:
                """Human-readable time span: whole months, or '<1mo' for short spans."""
                if months < 1.0:
                    return "<1mo"
                return f"{round(months)}mo"
        
            items: list[str] = [
                f"{total} file{'s' if total != 1 else ''} show monotonic growth; "
                f"the {len(hottest)} hottest {'are' if len(hottest) != 1 else 'is'} below"
            ]
            for f in hottest:
                net = f["net_additions"]
                months = _months_str(f["time_span_months"])
                commits = f["commit_count"]
                del_frac = f["deletion_fraction"]
                items.append(
                    f"{f['path']} — +{net:,} LOC over {months} across {commits} commit"
                    f"{'s' if commits != 1 else ''}, {del_frac:.0%} net reductions"
                    " — only ever grows"
                )
        
            if not block.get("reliable", True):
                items.append(_ACCRETION_UNRELIABLE_DISCLAIMER)
        
            return items
        
        
        # --------------------------------------------------------------------------
        # Structure drift (Tier 1) as a B3 hidden-coupling signal
        # --------------------------------------------------------------------------
        #
        # Tier 1's ``human_split_but_cochange`` is, by construction, a B3 signal: file
        # pairs the commit log keeps coupling that *no* declared ownership boundary
        # groups - a hidden seam the map omits, exactly the question B3
        # (static-vs-historical disagreement) already asks. So it folds into the
        # *existing* ``hidden_coupling`` finding rather than a new finding type, after
        # the seam allowlist (applied inside ``detect_grouping_disagreement``) has
        # absorbed the known-good seams.
        #
        # The aggregation tests directory *pairs*, not single directories: a genuine
        # hidden seam is two trees that keep co-changing across several distinct file
        # pairs, not one hub file that touches everything. The version hot-file
        # ``plugin.json`` co-changes with a file in every tree on each PR - inflating its
        # own directory's single-dir count - but each of those couplings is a *different*
        # counterpart directory through the *same* hub file, so no directory *pair*
        # recurs. Requiring a directory pair to recur (``min_pairs`` distinct file pairs
        # straddling the same two trees) is the recurrence test that distinguishes a
        # mutual entanglement from a repo-wide hub, and yields no false positive from the
        # version-bump ritual.
        
        MIN_DRIFT_PAIRS_FOR_HIDDEN_COUPLING = 2
        
        
        def _parent_dir(path_str: str) -> str:
            """The repo-relative parent directory of a file path (``.`` for repo root)."""
            return Path(path_str).parent.as_posix()
        
        
        def structure_drift_hidden_coupling_dirs(
            tier1: dict, min_pairs: int = MIN_DRIFT_PAIRS_FOR_HIDDEN_COUPLING,
        ) -> list[str]:
            """Directories entangled by a recurring Tier 1 hidden seam.
        
            Aggregates the post-allowlist ``human_split_but_cochange`` pairs (co-change
            with no declared boundary) to *directory pairs* and keeps the directories of
            any directory pair straddled by at least ``min_pairs`` distinct file pairs.
            Testing directory pairs (not single directories) is what filters a repo-wide
            hub - the version hot-file couples with every tree, inflating its own
            directory's appearances, but always through a *different* counterpart
            directory, so no directory pair recurs and it never reads as a seam. A pair
            where both files share a directory is ignored (intra-directory cohesion is
            not a cross-tree seam), as is any pair touching the repo root ``.`` (vacuous
            containment, never an attention unit). Returns a sorted, deduped path list
            matching the dir granularity of the existing ``hidden_coupling`` finding so
            the attention list stays deterministic.
            """
            if not tier1.get("available"):
                return []
            dir_pair_counts: Counter[tuple[str, str]] = Counter()
            for row in tier1.get("human_split_but_cochange", []):
                da, db = _parent_dir(row["file_a"]), _parent_dir(row["file_b"])
                if da == db or "." in (da, db):
                    continue
                key: tuple[str, str] = (da, db) if da <= db else (db, da)
                dir_pair_counts[key] += 1
            out: set[str] = set()
            for (da, db), n in dir_pair_counts.items():
                if n >= min_pairs:
                    out.add(da)
                    out.add(db)
            return sorted(out)
        
        
        # --------------------------------------------------------------------------
        # Deterministic markdown / summary renderers (the report-skeleton products)
        # --------------------------------------------------------------------------
        
        # Cap on paths-per-finding and attention rows in the rendered markdown so a
        # pathological repo can't bloat the report skeleton. The structured arrays in
        # run-context.json keep the full (already-capped) lists.
        MAX_FINDING_PATHS_RENDERED = 10
        MAX_ATTENTION_ROWS_RENDERED = 5
        
        
        def render_findings_markdown(
            findings: list[dict], attention: list[dict],
        ) -> str:
            """Render the derived findings + attention list as a markdown section.
        
            This is the deterministic report skeleton: the LLM writes prose *around* it
            but cannot omit, rename, or reorder the findings. Findings with no paths are
            skipped (nothing to point at); when none have paths the section says so
            explicitly rather than rendering an empty heading. Always ends with a single
            trailing newline so it concatenates cleanly into the report.
            """
            lines = ["## Cross-Layer Findings (Keyhole Readiness)", ""]
            rendered_any = False
            for f in findings:
                paths = f.get("paths") or []
                if not paths:
                    continue
                rendered_any = True
                lines.append(f"### {f['name']}")
                lines.append("")
                lines.append(f"Action: {f['action']}")
                lines.append("")
                lines.append("Paths:")
                for p in paths[:MAX_FINDING_PATHS_RENDERED]:
                    lines.append(f"- {p}")
                lines.append("")
            if not rendered_any:
                lines.append(
                    "_No cross-layer findings surfaced - no path crossed an axis boundary._"
                )
                lines.append("")
            if attention:
                lines.append("### Attention List (Priority Order)")
                lines.append("")
                for a in attention[:MAX_ATTENTION_ROWS_RENDERED]:
                    names = ", ".join(a.get("findings", []))
                    lines.append(f"- {a['path']} (score {a['score']}): {names}")
                lines.append("")
            return "\n".join(lines).rstrip() + "\n"
        
        
        # Display-name overrides for finding identifiers whose naive underscore->space
        # form reads wrong (compound adjectives need a hyphen). Names not listed here
        # fall back to a plain underscore->space replace in ``finding_display_name``.
        FINDING_DISPLAY_NAMES = {
            "self_referential_tests": "self-referential tests",
        }
        
        
        def finding_display_name(name: str) -> str:
            """Human-readable form of a finding identifier for summary text."""
            return FINDING_DISPLAY_NAMES.get(name, name.replace("_", " "))
        
        
        def _format_summary(concerns: list[dict], safe_zones: int) -> str:
            """One-line human-readable keyhole-readiness summary.
        
            Pure count with a positive/negative split (PRD: never imply commensurability
            with the 0-8 score). Singular/plural handled for the headline count and the
            safe-zone count; the per-finding labels use ``finding_display_name`` (plain
            underscore->space, e.g. ``2 hidden coupling``, with explicit overrides for
            compound adjectives, e.g. ``self-referential tests``).
            """
            def plural(n: int, word: str) -> str:
                return f"{n} {word}" if n == 1 else f"{n} {word}s"
        
            zones = plural(safe_zones, "safe zone")
            if not concerns:
                return f"No structural concerns, {zones}."
            total = sum(c["count"] for c in concerns)
            detail = ", ".join(
                f"{c['count']} {finding_display_name(c['name'])}" for c in concerns
            )
            headline = plural(total, "structural concern")
            return f"{headline} ({detail}), {zones}."
        
        
        def build_keyhole_summary(findings: list[dict]) -> dict:
            """Roll the derived findings into a count/severity readiness summary.
        
            Reported *alongside* the 0-8 layered score, never merged into it: the score
            asks "is the scaffolding in place to catch problems?", this asks "where is
            today's structural pain?". Returns ``{concerns, safe_zones, total_concerns,
            summary_text}`` where ``concerns`` is the per-finding ``{name, count}`` for
            every negative finding with paths and ``safe_zones`` is the
            ``refactor_boundary`` path count (the one positive finding).
            """
            concerns: list[dict] = []
            safe_zones = 0
            for f in findings:
                if f["name"] == "refactor_boundary":
                    safe_zones = len(f["paths"])
                elif f["paths"]:
                    concerns.append({"name": f["name"], "count": len(f["paths"])})
            return {
                "concerns": concerns,
                "safe_zones": safe_zones,
                "total_concerns": sum(c["count"] for c in concerns),
                "summary_text": _format_summary(concerns, safe_zones),
            }
        
        
        # How many attention units are promoted into the mandatory prescribed-actions
        # set. The report's Top 3 Actions must include these.
        MAX_PRESCRIBED_ACTIONS = 3
        
        
        def is_attention_low_signal(attention: list[dict]) -> bool:
            """True when no attention row lands in more than one negative finding.
        
            A top score of 1 means the ranking separates nothing across axes, so its
            rows 2-3 carry no more signal than any other score-1 path; prescribing them
            would crowd out actions the report writer can justify. Only the fully flat
            ranking is capped: once any row scores 2 or more the list keeps its usual
            three prescribed actions, since its top already separates. Empty attention
            is ``False``: there is nothing to prescribe, so nothing to cap.
            """
            return bool(attention) and max(unit["score"] for unit in attention) <= 1
        
        
        def build_prescribed_actions(
            attention: list[dict],
            findings: list[dict],
            max_actions: int = MAX_PRESCRIBED_ACTIONS,
        ) -> list[dict]:
            """Map the top attention units to their finding-derived prescribed actions.
        
            The attention list already ranks units by negative-finding count; this picks
            the action for each unit's *worst* finding (severity = ``FINDING_ORDER``
            minus the positive ``refactor_boundary``, so the deterministic worst-first
            order is the single source of truth). Returns ``{path, action, findings,
            rank}`` for up to ``max_actions`` units - the Top-3 the report MUST include.
            """
            finding_actions = {f["name"]: f["action"] for f in findings}
            severity = [n for n in FINDING_ORDER if n != "refactor_boundary"]
            prescribed: list[dict] = []
            for i, unit in enumerate(attention[:max_actions]):
                for name in severity:
                    if name in unit["findings"]:
                        prescribed.append({
                            "path": unit["path"],
                            "action": finding_actions[name],
                            "findings": unit["findings"],
                            "rank": i + 1,
                        })
                        break
            return prescribed
        
        
        def render_prescribed_actions(prescribed: list[dict]) -> str:
            """Render the mandatory attention-derived actions as Top-3 table rows.
        
            Pre-fills the rank, action, hotspot path, and issue columns of the report's
            Top 3 Actions table; the LLM fills the ``?`` layer/effort/command cells with
            judgement. Returns ``""`` when there is nothing to prescribe (empty
            attention), so the LLM falls back to its own prioritisation.
            """
            if not prescribed:
                return ""
            lines = []
            for p in prescribed:
                # Columns: # | Action | Layer | Effort | Command / First Step | Hotspot
                # files this addresses | Issue
                lines.append(
                    f"| {p['rank']} | {p['action']} | ? | ? | ? | `{p['path']}` | — |"
                )
            return "\n".join(lines)
        
        
        # --------------------------------------------------------------------------
        # Orchestration entry point
        # --------------------------------------------------------------------------
        
        def _safe_block(label: str, fn, fallback: dict) -> dict:
            """Run a block builder, degrading to ``fallback`` on any failure.
        
            Each new signal does git-log / static-graph work; a hang or parse failure in
            one must not crash the whole assessment. The fallback always carries
            ``available: False`` + a reason so the report can say "this signal was
            skipped" rather than silently dropping it.
            """
            try:
                return fn()
            except Exception as e:  # noqa: BLE001 - intentional: degrade, never crash
                return {**fallback, "available": False, "reason": f"{label} failed: {e}"}
        
        
        def _structure_drift_tier1(
            repo_root: Path, structure: dict | None, behaviour: dict,
        ) -> dict:
            """Tier 1 grouping disagreement, fed the behaviour block's co-change pairs.
        
            Returns ``{"available": False}`` (no disagreement to surface) whenever the
            static import graph is unavailable - with no static lens there is nothing to
            disagree with. Otherwise calls ``detect_grouping_disagreement`` with the
            co-change pairs the behaviour block already computed (no second git-log
            parse); the static communities are recomputed inside the detector from the
            same grimp packages ``structure`` was built from, since the structure block
            keeps only modularity_q, not the partition. Any failure degrades to an empty
            available:False result - this is additive B3 context, never a gate.
            """
            if not structure or not structure.get("available"):
                return {"available": False}
            coupling_pairs = (
                behaviour.get("change_coupling_pairs", [])
                if behaviour.get("available") else []
            )
            try:
                return detect_grouping_disagreement(repo_root, coupling_pairs=coupling_pairs)
            except Exception:  # noqa: BLE001 - degrade, never crash
                return {"available": False}
        
        
        def _paths_from_stats(complexity_stats: dict, cap: int = MAX_AUTHORSHIP_PATHS) -> list[str]:
            """The ranked-list paths to run authorship analysis over (capped).
        
            Union of the three top-N lists; these are the high-complexity / high-churn
            units the understanding + dead-weight findings care about. Capped so a huge
            repo doesn't trigger dozens of per-path git calls.
            """
            seen: list[str] = []
            s: set[str] = set()
            for key in ("top_hotspots", "top_complex", "top_large"):
                for entry in complexity_stats.get(key) or []:
                    p = entry.get("path")
                    if p and p not in s:
                        s.add(p)
                        seen.append(p)
            return sorted(seen)[:cap]
        
        
        def integrate(
            *,
            repo_root: Path,
            complexity_stats: dict,
            doc_staleness: dict,
            dead_code: dict,
            observability: dict,
            structure: dict,
            commit_sets: list[set[Path]] | None = None,
            test_pressure: dict | None = None,
            promissory_markers: dict | None = None,
            accretion_ratchet: dict | None = None,
            archetype: dict | None = None,
            exclude_dirs: set[str] | None = None,
            exclude_patterns: list[str] | None = None,
            scope: Path | None = None,
            rename_map: RenameMap | None = None,
        ) -> dict:
            """Build the five run-context blocks + derived findings + attention list.
        
            Pure orchestration over the lib signals. ``commit_sets`` may be passed in
            (the orchestrator parses git log once and reuses it for churn etc.);
            otherwise it is parsed here. ``test_pressure`` is the Layer-1 write-side scan
            (the E1 trust axis crosses it with the complexity hotspots); when absent E1
            degrades to silent. ``promissory_markers`` is the marker-scan summary
            (``promissory_markers.MarkerScan.summary()``); when absent or unreliable the
            ``unactioned_intent`` finding degrades to silent. ``accretion_ratchet`` is the
            serialized ``AccretionScan`` block from ``assess_core._accretion_block``; when
            absent or unavailable the ``accretion_ratchet`` finding degrades to silent.
            ``archetype`` is the run-context archetype block; when its
            ``override_contradicts_signals`` flag is set the ``override_contradicts_signals``
            finding fires against the marker's source file. ``exclude_dirs`` /
            ``exclude_patterns`` are the user-supplied config excludes; a finding path
            matching them is filtered out and reported in ``excluded_finding_paths`` so the
            suppression is disclosed rather than silent. ``rename_map`` (from
            ``change_coupling.build_rename_map``, built here when None) folds history
            recorded under a renamed path onto its current path; a git-history finding
            path that still does not exist is pruned and reported in
            ``pruned_finding_paths``. When the map is incomplete (git failed) nothing is
            pruned: an unfolded old path is not evidence of a deletion.
            Every block is built defensively - a failure in one degrades that block to
            ``available: False`` and leaves the rest intact.
            """
            repo_root = Path(repo_root)
            # Resolved once and shared by the git-log parse, the rename map and the prune.
            top = repo_top(repo_root)
            if commit_sets is None:
                try:
                    commit_sets = parse_commit_file_sets(repo_root, top=top)
                except Exception:  # noqa: BLE001 - degrade to no-history
                    commit_sets = []
            if rename_map is None:
                rename_map = build_rename_map(repo_root, top=top)
            commit_sets = fold_renames(commit_sets, rename_map.paths)
        
            # `/assess <path>` monorepo scoping: confine the change-history file-sets to
            # the subtree so the behaviour block (coupling, containment, hidden-seam)
            # carries no co-change signal from a sibling directory. A commit that touched
            # both a scoped and a sibling file keeps only its scoped files; commits that
            # touched nothing in scope drop out. None (a whole-repo run) is unchanged.
            if scope is not None:
                scope_abs = Path(scope).resolve()
        
                def _in_scope(f: Path) -> bool:
                    try:
                        return (repo_root / f).resolve().is_relative_to(scope_abs)
                    except (ValueError, OSError):
                        return False
        
                commit_sets = [
                    scoped for scoped in ({f for f in s if _in_scope(f)} for s in commit_sets)
                    if scoped
                ]
        
            behaviour = _safe_block(
                "behaviour",
                lambda: build_behaviour_block(repo_root, commit_sets, structure),
                {"containment_by_dir": {}, "change_coupling_pairs": [],
                 "static_history_disagreement": [], "hidden_coupling_findings": [],
                 "refactor_boundaries": []},
            )
        
            documentation = _safe_block(
                "documentation",
                lambda: build_documentation_block(
                    analyze_doc_complexity_join(complexity_stats, doc_staleness, repo_root)
                ),
                {"freshness_by_doc": {}, "complexity_coverage": {},
                 "stale_doc_on_complexity": [], "unexplained_complexity": []},
            )
        
            def _understanding() -> dict:
                paths = _paths_from_stats(complexity_stats)
                authorship_by_path = {p: authorship_analysis(repo_root, p) for p in paths}
                return build_understanding_block(
                    analyze_understanding(
                        repo_root, authorship_by_path, doc_staleness, complexity_stats
                    )
                )
        
            understanding = _safe_block(
                "understanding",
                _understanding,
                {"human_anchor_by_path": {}, "intent_source_by_path": {},
                 "authorship_class_by_path": {}, "orphaned_understanding": []},
            )
        
            runtime = _safe_block(
                "runtime",
                lambda: build_runtime_block(dead_code, observability),
                {"static_reachability": {"available": False, "candidate_count": 0,
                                         "candidates": []},
                 "observability_rung": None, "runtime_evidence_available": False},
            )
        
            dead_weight = candidate_dead_weight_paths(
                complexity_stats, dead_code, understanding.get("intent_source_by_path", {})
            )
        
            # E1 trust axis: complexity hotspots whose tests are hollow. Silent without
            # opt-in mutation data, so it degrades cleanly on the default read-only run.
            try:
                untrusted = find_untrusted_hotspots(complexity_stats, test_pressure or {})
            except Exception:  # noqa: BLE001 - degrade, never crash
                untrusted = []
        
            # E2 trust axis: tests co-located AND co-committed with the code they cover -
            # the suite may verify internal consistency, not truth. Filesystem + git
            # work, wrapped so a parse failure degrades to no finding.
            try:
                source_paths = _paths_from_stats(complexity_stats)
                test_to_code = build_test_to_code_map(repo_root, source_paths)
                self_ref = find_self_referential_tests(repo_root, test_to_code, commit_sets)
                self_ref_paths = sorted({sr["source_file"] for sr in self_ref})
            except Exception:  # noqa: BLE001 - degrade, never crash
                self_ref_paths = []
        
            # Churn-measurement reliability (single source of truth: lib.git_churn, set
            # on the doc-staleness block). When the history is degenerate - every file ~1
            # commit (shallow clone, fresh import, squashed/extracted tree) - the churn
            # signal carries no information, so the two findings derived from it must not
            # be counted: `lying_map` (built on the doc-staleness ratio) and
            # `hidden_coupling` (built on co-commit change coupling, which a single bulk
            # import maximally inflates). lying_map is already suppressed upstream by the
            # confidence cap in the join; hidden_coupling is dropped here. Both blocks
            # keep their raw data (honest); only the *counted findings* degrade.
            churn_degenerate = bool(doc_staleness.get("churn_degenerate", False))
            churn_derived_findings = {"lying_map", "hidden_coupling"}
        
            def _churn_paths(name: str, paths: list[str]) -> list[str]:
                return [] if (churn_degenerate and name in churn_derived_findings) else paths
        
            # Unactioned intent: files carrying stale promissory markers (markers that
            # survived >= threshold edits to their own file). Silent when the scan was
            # unavailable or the history is too thin to age markers (aging_reliable
            # False) - thin history must read "not assessed", never "clean".
            pm = promissory_markers or {}
            unactioned = (
                sorted(pm.get("stale_by_file", {}))
                if pm.get("available") and pm.get("aging_reliable", True)
                else []
            )
        
            # Accretion ratchet: files in the top complexity/size band that only ever
            # grew - monotonic net additions, almost no deletion pressure. Silent when
            # the block is absent or unavailable (the caller hasn't passed it yet, or
            # the scan failed). Paths are extracted in worst-first order (descending
            # net additions) by the helper so the finding list is deterministic.
            ratchet_run_ctx: dict = {"accretion_ratchet": accretion_ratchet or {}}
            accreting_paths = _accretion_ratchet_finding(ratchet_run_ctx)
        
            # Structure drift (Tier 1): grouping disagreement between the declared
            # ownership map, the static import graph, and the commit-log co-change. Run
            # only when the static lens exists (no graph -> nothing to disagree with);
            # fed the behaviour block's already-computed co-change pairs so no second
            # git-log parse happens. Its hidden-seam direction folds into the existing
            # hidden_coupling finding below. Degrades to an empty (available:False) result
            # when no ownership map exists or the detector fails - never crashes the run.
            structure_drift_tier1 = _structure_drift_tier1(repo_root, structure, behaviour)
            # The hidden-seam dirs are co-change-derived, so a degenerate history (which
            # maximally inflates co-change) must suppress them exactly as it suppresses
            # the containment-derived hidden_coupling - via the same _churn_paths gate.
            drift_hidden_dirs = structure_drift_hidden_coupling_dirs(structure_drift_tier1)
        
            # Archetype-override contradiction: an `assess-archetype` marker forces a
            # classification the deterministic signals disagree with. The override still
            # wins the score, but the disagreement fires a finding pointed at the marker's
            # source file so the override is never silent.
            arch = archetype or {}
            override_contradiction_paths = (
                [arch.get("override_source") or "<archetype marker>"]
                if arch.get("override_contradicts_signals") else []
            )
        
            findings = assemble_findings({
                "hidden_coupling": _churn_paths(
                    "hidden_coupling",
                    [h["path"] for h in behaviour.get("hidden_coupling_findings", [])]
                    + drift_hidden_dirs,
                ),
                "lying_map": _churn_paths(
                    "lying_map",
                    [d["path"] for d in documentation.get("stale_doc_on_complexity", [])],
                ),
                "unexplained_complexity": [
                    d["path"] for d in documentation.get("unexplained_complexity", [])
                ],
                "untrusted_hotspot": untrusted,
                "self_referential_tests": self_ref_paths,
                "unactioned_intent": unactioned,
                "accretion_ratchet": accreting_paths,
                "orphaned_understanding": understanding.get("orphaned_understanding", []),
                "candidate_dead_weight": dead_weight,
                "override_contradicts_signals": override_contradiction_paths,
                "refactor_boundary": [b["path"] for b in behaviour.get("refactor_boundaries", [])],
            })
            _state_stale_threshold(findings, pm)
        
            # Dead-path pruning: a git-history finding path absent from the working tree
            # (deleted, no rename to follow) never reaches the report; the dropped paths
            # are carried out for the `pruned_finding_paths` disclosure.
            # Outside a git repo there is no history to go stale, and with an incomplete
            # rename map a missing path may just be unfolded, so nothing is pruned.
            findings, pruned_find
      • liveness_scan.py 33.1 KB
        """Layer 1 inputs: runtime legibility / liveness signals.
        
        Two tiers, both deterministic and best-effort -- they degrade to "not assessed"
        rather than ever blocking the assessment.
        
        **Dead-code tier (cheap, traditional).** Flags *intra-repo* candidate-dead code
        (unused exports / unreferenced symbols) using a language-appropriate tool when
        one is on PATH (``vulture`` for Python, ``ts-prune``/``knip`` for TS/JS,
        ``staticcheck``/``deadcode`` for Go, clippy for Rust). The hard limit, stated in
        the report: static reachability proves "nothing in *this* repo calls it", never
        "no external consumer calls it." Cross-boundary liveness needs the next tier.
        
        **Observability tier (the decisive one), scored by three rungs:**
          1. **Instrumented** -- telemetry is emitted (OpenTelemetry, Prometheus,
             Datadog/APM, structured logging). Necessary, not sufficient.
          2. **Discoverable** -- an ``OBSERVABILITY.md`` / runbook tells the agent where
             runtime truth lives. Orients, but grants no access.
          3. **Reachable** -- the agent has an *invokable* path to runtime state: an MCP
             server over logs/metrics/traces, a repo skill that tails logs or queries
             metrics, a documented runnable CLI. Without this the agent knows telemetry
             exists but cannot use it, so liveness stays unverifiable (the meridian
             case). This is the rung that decides the score.
        
        Boundary: this sees only what the *repo provides* toward agent-reachability; it
        cannot know the agent's live environment. We score what the repo makes reachable
        and say so.
        """
        from __future__ import annotations
        
        import json
        import re
        import shutil
        import subprocess
        from collections.abc import Iterator
        from dataclasses import dataclass, field
        from pathlib import Path
        
        from lib.doc_graph import EXCLUDE_DIRS, EXCLUDE_PATH_SEQUENCES, is_excluded_path
        
        DEAD_CODE_TIMEOUT = 60  # seconds; a slow tool degrades rather than hangs the run
        MAX_CANDIDATES = 50     # cap so a pathological repo can't bloat run-context.json
        
        STATIC_REACHABILITY_CAVEAT = (
            "Static reachability proves nothing in THIS repo references the symbol; it "
            "cannot prove no external consumer (a mobile app, another service) calls it. "
            "Cross-boundary liveness needs telemetry or a named human."
        )
        
        
        # ── dead-code tier ─────────────────────────────────────────────────────────
        
        def _has_ext(repo_root: Path, exts: set[str],
                     extra_exclude_dirs: set[str] | None = None,
                     extra_exclude_patterns: list[str] | None = None,
                     scope: Path | None = None) -> bool:
            return any(_iter_ext(repo_root, exts, extra_exclude_dirs,
                                 extra_exclude_patterns, scope))
        
        
        def _count_ext(repo_root: Path, exts: set[str],
                       extra_exclude_dirs: set[str] | None = None,
                       extra_exclude_patterns: list[str] | None = None,
                       scope: Path | None = None) -> int:
            return sum(1 for _ in _iter_ext(
                repo_root, exts, extra_exclude_dirs, extra_exclude_patterns, scope))
        
        
        def _iter_ext(repo_root: Path, exts: set[str],
                      extra_exclude_dirs: set[str] | None,
                      extra_exclude_patterns: list[str] | None,
                      scope: Path | None = None) -> Iterator[Path]:
            """Yield in-scope files under `scope` (default `repo_root`) whose suffix is
            in `exts`. Excludes match paths relative to `repo_root` either way."""
            from lib.assess_config import is_user_excluded
            extra_dirs = extra_exclude_dirs or set()
            extra_pats = extra_exclude_patterns or []
            root = repo_root.resolve()
            for path in (scope or root).resolve().rglob("*"):
                if not path.is_file() or path.suffix.lower() not in exts:
                    continue
                rel = path.relative_to(root)
                if is_excluded_path(rel):
                    continue
                if is_user_excluded(rel, extra_dirs, extra_pats):
                    continue
                yield path
        
        
        def _parse_vulture(stdout: str) -> list[dict]:
            """vulture: `path:line: unused function 'name' (60% confidence)`."""
            out: list[dict] = []
            rx = re.compile(r"^(.*?):(\d+): (unused \w[\w ]*?) '?([\w.]+)'?")
            for line in stdout.splitlines():
                m = rx.match(line.strip())
                if m:
                    out.append({"path": m.group(1), "line": int(m.group(2)),
                                "kind": m.group(3), "symbol": m.group(4)})
            return out
        
        
        def _parse_ts_prune(stdout: str) -> list[dict]:
            """ts-prune: `path:line - name` (suffix `(used in module)` is not dead)."""
            out: list[dict] = []
            rx = re.compile(r"^(.*?):(\d+) - (\S+)(.*)$")
            for line in stdout.splitlines():
                m = rx.match(line.strip())
                if m and "used in module" not in m.group(4):
                    out.append({"path": m.group(1), "line": int(m.group(2)),
                                "kind": "unused export", "symbol": m.group(3)})
            return out
        
        
        def _parse_staticcheck(stdout: str) -> list[dict]:
            """staticcheck U1000: `path:line:col: ... is unused (U1000)`."""
            out: list[dict] = []
            rx = re.compile(r"^(.*?):(\d+):\d+:\s*(.*?is unused.*)$")
            for line in stdout.splitlines():
                m = rx.match(line.strip())
                if m:
                    out.append({"path": m.group(1), "line": int(m.group(2)),
                                "kind": "unused", "symbol": m.group(3)})
            return out
        
        
        def _parse_deadcode(stdout: str) -> list[dict]:
            """x/tools deadcode: `path:line:col: unreachable func: name`."""
            out: list[dict] = []
            rx = re.compile(r"^(.*?):(\d+):\d+:\s*(unreachable func.*)$")
            for line in stdout.splitlines():
                m = rx.match(line.strip())
                if m:
                    out.append({"path": m.group(1), "line": int(m.group(2)),
                                "kind": "unreachable", "symbol": m.group(3)})
            return out
        
        
        def _parse_knip(stdout: str) -> list[dict]:
            """knip --reporter json: {files:[...], issues:[{file, exports:[...]}]}."""
            try:
                data = json.loads(stdout)
            except (json.JSONDecodeError, ValueError):
                return []
            out: list[dict] = []
            for f in data.get("files", []):
                out.append({"path": f, "line": 0, "kind": "unused file", "symbol": "(file)"})
            for issue in data.get("issues", []):
                path = issue.get("file", "")
                for exp in issue.get("exports", []):
                    name = exp.get("name", exp) if isinstance(exp, dict) else exp
                    out.append({"path": path, "line": 0, "kind": "unused export", "symbol": name})
            return out
        
        
        def _vulture_excludes(extra_exclude_dirs: set[str] | None = None) -> str:
            """Comma-separated glob patterns so vulture skips vendored / build dirs.
        
            Without this a committed `.venv/` or vendored package can fill the
            candidate cap with third-party dead code and crowd out *this* repo's.
            User-supplied `extra_exclude_dirs` (from `.assess/config.toml` or
            `--exclude`) join the same list so a `regulatory-raw/` directory is
            skipped at scan time, not just filtered after.
            """
            extras = extra_exclude_dirs or set()
            dir_globs = [f"*/{d}/*" for d in sorted(EXCLUDE_DIRS | extras)]
            seq_globs = ["*/" + "/".join(seq) + "/*" for seq in EXCLUDE_PATH_SEQUENCES]
            return ",".join(dir_globs + seq_globs)
        
        
        # language -> ordered tool preference. Each tool: cmd builder, parser, and a
        # `builds` flag. Tools run from `.` (cwd=root) so they report repo-relative
        # paths, never the author's absolute layout. `builds` tools resolve/compile the
        # project (can hit the network and write the module cache), so they are NOT run
        # by default - that would make a "read-only assessor" mutate state. They are
        # reported as available-but-not-run unless explicitly opted in.
        # cmd-builder signature: `(root, extra_dirs)`. Only vulture currently uses
        # `extra_dirs` (it accepts a comma-separated `--exclude` list); the other
        # tools take their excludes from external config files or honour the
        # post-scan filter in `_under_excluded`.
        # JavaScript and TypeScript share one tool choice, made by the dominant language
        # (`_js_ts_dominant`), so a stray `.ts` file cannot put ts-prune on a JavaScript
        # repository. `requires` names a root file the tool needs to have a project to
        # analyse; without it the tool is `not_applicable`. `absent_status` and
        # `absent_reason` replace `tool_absent` when no other tool serves the language.
        _TS_EXTS = {".ts", ".tsx", ".mts", ".cts"}
        _JS_EXTS = {".js", ".jsx", ".mjs", ".cjs"}
        _DEAD_CODE_TOOLS: list[dict] = [
            {"language": "python", "tool": "vulture", "exts": {".py"}, "builds": False,
             "cmd": lambda root, extra_dirs: [
                 "vulture", ".", "--exclude", _vulture_excludes(extra_dirs),
             ],
             "parser": _parse_vulture},
            {"language": "typescript", "tool": "ts-prune", "exts": _TS_EXTS,
             "builds": False, "requires": "tsconfig.json",
             "cmd": lambda root, extra_dirs: ["ts-prune"], "parser": _parse_ts_prune},
            {"language": "typescript", "tool": "knip", "exts": _TS_EXTS,
             "builds": True,
             "cmd": lambda root, extra_dirs: ["knip", "--reporter", "json"],
             "parser": _parse_knip},
            {"language": "javascript", "tool": "knip", "exts": _JS_EXTS,
             "builds": True, "absent_status": "honest_degrade",
             "absent_reason": ("JavaScript liveness is unserved; knip would provide it "
                               "(npm install -g knip)"),
             "cmd": lambda root, extra_dirs: ["knip", "--reporter", "json"],
             "parser": _parse_knip},
            {"language": "go", "tool": "deadcode", "exts": {".go"}, "builds": True,
             "cmd": lambda root, extra_dirs: ["deadcode", "./..."],
             "parser": _parse_deadcode},
            {"language": "go", "tool": "staticcheck", "exts": {".go"}, "builds": True,
             "cmd": lambda root, extra_dirs: [
                 "staticcheck", "-checks", "U1000", "./...",
             ],
             "parser": _parse_staticcheck},
        ]
        
        
        def _under_excluded(path_str: str,
                            extra_exclude_dirs: set[str] | None = None,
                            extra_exclude_patterns: list[str] | None = None) -> bool:
            """True if a candidate path sits under a vendored / build directory, or
            matches a user-supplied exclude. Used to filter dead-code-tool output
            so candidates from `regulatory-raw/` reference data don't surface."""
            from lib.assess_config import is_user_excluded
            if is_excluded_path(Path(path_str)):
                return True
            if extra_exclude_dirs or extra_exclude_patterns:
                return is_user_excluded(
                    Path(path_str), extra_exclude_dirs or set(),
                    extra_exclude_patterns or [],
                )
            return False
        
        
        @dataclass
        class DeadCodeResult:
            available: bool = False
            candidates: list[dict] = field(default_factory=list)
            tools: list[dict] = field(default_factory=list)  # {language, tool, status, reason}
            caveat: str = STATIC_REACHABILITY_CAVEAT
        
            def as_dict(self) -> dict:
                return {
                    "available": self.available,
                    "candidate_count": len(self.candidates),
                    "candidates": self.candidates[:MAX_CANDIDATES],
                    "tools": self.tools,
                    "caveat": self.caveat,
                }
        
        
        def _js_ts_dominant(repo_root: Path, extra_dirs: set[str],
                            extra_pats: list[str],
                            scope: Path | None = None) -> tuple[str, int, int]:
            """(dominant language, TypeScript count, JavaScript count) over the files
            under `scope` (default `repo_root`). A tie goes to TypeScript; either way the
            losing language's files are not analysed, and the scan records that as a
            `not_applicable` entry."""
            ts = _count_ext(repo_root, _TS_EXTS, extra_dirs, extra_pats, scope)
            js = _count_ext(repo_root, _JS_EXTS, extra_dirs, extra_pats, scope)
            return ("javascript" if js > ts else "typescript"), ts, js
        
        
        def _candidate_in_scope(repo_root: Path, rel_path: str, scope: Path | None) -> bool:
            """True if a tool-reported candidate path lies within `scope`.
        
            Tools report paths relative to `repo_root` (their cwd); resolve against it
            and compare. A whole-repo run (`scope` is None) keeps every candidate.
            """
            if scope is None:
                return True
            try:
                return (repo_root / rel_path).resolve().is_relative_to(scope.resolve())
            except (ValueError, OSError):
                return False
        
        
        def scan_dead_code(repo_root: Path, run: bool = True,
                           run_build_tools: bool = False,
                           extra_exclude_dirs: set[str] | None = None,
                           extra_exclude_patterns: list[str] | None = None,
                           scope: Path | None = None,
                           ) -> DeadCodeResult:
            """Best-effort intra-repo dead-code scan. Never raises.
        
            Static tools (vulture, ts-prune) run by default. Tools that resolve/compile
            the project (`builds: True` - deadcode, staticcheck, knip) are skipped
            unless `run_build_tools` is set, so the default scan stays read-only.
            JavaScript and TypeScript get one tool choice, by the dominant language of
            the in-scope files; ts-prune also needs a root `tsconfig.json`, and without
            one it is recorded `not_applicable` rather than run against no project.
        
            `extra_exclude_dirs` and `extra_exclude_patterns` come from
            `.assess/config.toml` / `--exclude` and apply at three points: the
            language-presence probe (`_has_ext` - so a repo whose only Python lives
            in `regulatory-raw/` reports `tool_absent: no Python in scope`), the
            vulture `--exclude` argument, and the post-scan filter on candidates.
        
            `scope` (an absolute path under `repo_root`) restricts the candidates to a
            subtree for `/assess <path>` monorepo scoping, so a scoped run carries no
            dead-code signal from a sibling directory. The language-presence probe and
            the dominant-language choice count only the in-scope files. The tool still
            runs over the repo (it needs the whole import graph to judge liveness) and
            only its reported candidates are confined. Omit it for a whole-repo run.
            """
            repo_root = repo_root.resolve()
            result = DeadCodeResult()
            seen_languages: set[str] = set()
            extra_dirs = extra_exclude_dirs or set()
            extra_pats = extra_exclude_patterns or []
            js_ts, ts_count, js_count = _js_ts_dominant(
                repo_root, extra_dirs, extra_pats, scope)
        
            for spec in _DEAD_CODE_TOOLS:
                lang = spec["language"]
                if lang in seen_languages:  # one tool per language: first available wins
                    continue
                if not _has_ext(
                    repo_root, spec["exts"],
                    extra_exclude_dirs=extra_dirs,
                    extra_exclude_patterns=extra_pats,
                    scope=scope,
                ):
                    continue
                tool = spec["tool"]
                requires = spec.get("requires")
                if lang in ("javascript", "typescript") and lang != js_ts:
                    # The losing language is reported once, so a not-analysed half of
                    # the code never hides behind the winner's "ran" entry.
                    seen_languages.add(lang)
                    win, lose = ((f"{js_count} JavaScript", f"{ts_count} TypeScript")
                                 if js_ts == "javascript"
                                 else (f"{ts_count} TypeScript", f"{js_count} JavaScript"))
                    result.tools.append({
                        "language": lang, "tool": tool, "status": "not_applicable",
                        "reason": (f"{win} file(s) against {lose} file(s); the dominant "
                                   f"language's tool is used, so {tool} is not run and "
                                   f"the {lose} file(s) are not analysed"),
                    })
                    continue
                if requires and not (repo_root / requires).is_file():
                    result.tools.append({
                        "language": lang, "tool": tool, "status": "not_applicable",
                        "reason": (f"no root {requires}; {tool} runs from the repository "
                                   "root and would have no project to analyse"),
                    })
                    continue
                if shutil.which(tool) is None:
                    reason = f"{tool} not on PATH"
                    if spec.get("absent_reason"):
                        reason += f"; {spec['absent_reason']}"
                    result.tools.append({"language": lang, "tool": tool,
                                         "status": spec.get("absent_status", "tool_absent"),
                                         "reason": reason})
                    continue
                seen_languages.add(lang)
                if not run:
                    result.tools.append({"language": lang, "tool": tool,
                                         "status": "available_not_run",
                                         "reason": "execution disabled"})
                    continue
                if spec["builds"] and not run_build_tools:
                    result.tools.append({
                        "language": lang, "tool": tool, "status": "available_not_run",
                        "reason": (f"{tool} would build the project (may write the module "
                                   "cache / hit the network); not run by a read-only "
                                   f"assessment. Run `{' '.join(spec['cmd'](repo_root, extra_dirs))}` "
                                   "manually to cross-check."),
                    })
                    continue
                try:
                    proc = subprocess.run(
                        spec["cmd"](repo_root, extra_dirs), cwd=str(repo_root),
                        capture_output=True, text=True, timeout=DEAD_CODE_TIMEOUT,
                        check=False,
                    )
                except subprocess.TimeoutExpired:
                    result.tools.append({"language": lang, "tool": tool,
                                         "status": "timeout",
                                         "reason": f"exceeded {DEAD_CODE_TIMEOUT}s"})
                    continue
                except (OSError, FileNotFoundError) as e:  # pragma: no cover
                    result.tools.append({"language": lang, "tool": tool,
                                         "status": "error", "reason": str(e)})
                    continue
                # Keep the signal about THIS repo: drop candidates under vendored/build
                # dirs OR under user-supplied excludes (regulatory-raw, etc.).
                found = [
                    c for c in spec["parser"](proc.stdout)
                    if not _under_excluded(
                        c["path"],
                        extra_exclude_dirs=extra_dirs,
                        extra_exclude_patterns=extra_pats,
                    )
                    and _candidate_in_scope(repo_root, c["path"], scope)
                ]
                result.available = True
                result.candidates.extend(found)
                result.tools.append({"language": lang, "tool": tool, "status": "ran",
                                     "reason": f"{len(found)} candidate(s)"})
            return result
        
        
        # ── observability tier ───────────────────────────────────────────────────
        
        # Exact manifest filenames. .NET .csproj files are matched by suffix instead
        # (the name varies per project), so they are not listed here.
        _MANIFESTS = [
            "package.json", "pyproject.toml", "requirements.txt", "Pipfile",
            "go.mod", "Cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts",
            "Gemfile", "composer.json",
        ]
        
        # substring -> human signal name. Matched against manifest text (lowercased).
        _INSTRUMENTED_SIGNALS = {
            "opentelemetry": "OpenTelemetry", "otel": "OpenTelemetry",
            "prom-client": "Prometheus", "prometheus": "Prometheus",
            "micrometer": "Micrometer/Prometheus",
            "dd-trace": "Datadog APM", "datadog": "Datadog",
            "newrelic": "New Relic", "elastic-apm": "Elastic APM", "sentry": "Sentry",
            "structlog": "structured logging (structlog)", "zerolog": "structured logging (zerolog)",
            "logrus": "structured logging (logrus)", "zap": "structured logging (zap)",
            "winston": "structured logging (winston)", "pino": "structured logging (pino)",
            "slog": "structured logging (slog)",
        }
        
        # Filename / directory signal: a file or dir literally named for runbooks /
        # observability / dashboards is a strong single-hit signal.
        _RUNBOOK_NAME_RE = re.compile(
            r"(observability|runbook|on[- ]?call|oncall|dashboards?)", re.IGNORECASE)
        # Body-content fallback: weaker, so it requires >=2 *distinct* hits (see
        # _detect_discoverable). `dashboard` is deliberately absent here - it's too
        # overloaded in product docs (a UI "dashboard" feature) and is already covered
        # by the filename signal when a doc is actually a dashboards runbook.
        _RUNBOOK_CONTENT_RE = re.compile(
            r"\b(runbook|observability|grafana|prometheus|datadog|slo|sli|"
            r"data[- ]freshness|alerting|on[- ]?call)\b", re.IGNORECASE)
        # Two distinct body tokens required before a plain-named doc counts as a runbook.
        _RUNBOOK_CONTENT_MIN_HITS = 2
        # Runnable query *commands* (not product names in prose) signal rung-3
        # reachability. Matched only against fenced code blocks so a runbook that merely
        # mentions "dashboards live in Grafana" doesn't get mistaken for one the agent
        # can actually execute -- that distinction is the whole meridian case.
        _RUNNABLE_QUERY_RE = re.compile(
            r"\b(kubectl\s+logs|kubectl\s+get|stern\s|logcli\s|promtool\s|"
            r"journalctl|docker\s+logs|aws\s+logs|gcloud\s+logging|az\s+monitor|"
            r"datadog-ci|sumo\b|splunk\s|curl[^\n`]*/(metrics|api))",
            re.IGNORECASE)
        _FENCE_RE = re.compile(r"```.*?\n(.*?)```", re.DOTALL)
        
        
        def _fenced_code(text: str) -> str:
            """Concatenate the contents of fenced code blocks (```...```)."""
            return "\n".join(_FENCE_RE.findall(text))
        # Observability-flavoured names for MCP servers / repo skills (rung 3) - the
        # rung the model says *decides* the Layer 1 score, so a false match inflates the
        # headline directly. Short/overloaded tokens are word-anchored so names like
        # changelog-generator, blog, catalog, login, dialog, backlog, detail, retail
        # don't get mistaken for telemetry channels. Distinctive tokens stay as
        # substrings (they don't collide with ordinary words).
        _OBS_TOOL_RE = re.compile(
            r"\blog(s|ging|ger)?\b|\bmetrics?\b|\btrac(e|es|er|ing)\b|\btail\b|"
            r"\bdashboards?\b|\botel\b|\btempo\b|"
            r"\btelemetr|\bobservab|opentelemetry|grafana|prometheus|datadog|loki",
            re.IGNORECASE)
        
        
        def _iter_files(repo_root: Path, exts: set[str] | None = None,
                        extra_exclude_dirs: set[str] | None = None,
                        extra_exclude_patterns: list[str] | None = None,
                        ) -> list[Path]:
            from lib.assess_config import is_user_excluded
            extra_dirs = extra_exclude_dirs or set()
            extra_pats = extra_exclude_patterns or []
            out: list[Path] = []
            for path in repo_root.rglob("*"):
                if not path.is_file():
                    continue
                rel = path.relative_to(repo_root)
                if is_excluded_path(rel):
                    continue
                if is_user_excluded(rel, extra_dirs, extra_pats):
                    continue
                if exts is not None and path.suffix.lower() not in exts:
                    continue
                out.append(path)
            return out
        
        
        def _read(path: Path) -> str:
            try:
                return path.read_text(encoding="utf-8", errors="ignore")
            except OSError:
                return ""
        
        
        def _detect_instrumented(repo_root: Path, files: list[Path]) -> list[str]:
            signals: set[str] = set()
            for path in files:
                if path.name in _MANIFESTS or path.suffix == ".csproj":
                    text = _read(path).lower()
                    for needle, label in _INSTRUMENTED_SIGNALS.items():
                        if needle in text:
                            signals.add(label)
                # Config-file presence is also instrumentation evidence.
                if path.name.lower() in {"otel-collector-config.yaml", "otel-collector-config.yml",
                                         "prometheus.yml", "prometheus.yaml"}:
                    signals.add("telemetry config present")
            return sorted(signals)
        
        
        def _detect_discoverable(repo_root: Path, files: list[Path]) -> tuple[list[str], list[Path]]:
            signals: set[str] = set()
            runbooks: list[Path] = []
            for path in files:
                if path.suffix.lower() not in {".md", ".mdx", ".markdown"}:
                    continue
                rel = path.relative_to(repo_root)
                # Match the doc's own name or an *intra-repo* directory (runbooks/,
                # observability/) - never the repo-root dir name, which says nothing.
                dir_parts = rel.parts[:-1]
                if _RUNBOOK_NAME_RE.search(path.stem) or any(_RUNBOOK_NAME_RE.search(p) for p in dir_parts):
                    signals.add(f"runbook/observability doc: {rel}")
                    runbooks.append(path)
                    continue
                # Body-level fallback: weaker than a filename, so require >=2 distinct
                # tokens. A single "alerting" or "dashboard" mention in product docs
                # shouldn't tip a repo to discoverable (rung 2).
                hits = {m.group(1).lower() for m in _RUNBOOK_CONTENT_RE.finditer(_read(path))}
                if len(hits) >= _RUNBOOK_CONTENT_MIN_HITS:
                    signals.add(f"observability content ({', '.join(sorted(hits))}): {rel}")
                    runbooks.append(path)
            return sorted(signals), runbooks
        
        
        def _detect_reachable(repo_root: Path, files: list[Path],
                              runbooks: list[Path]) -> list[str]:
            signals: set[str] = set()
        
            # 1. .mcp.json exposing an observability server.
            for mcp in files:
                if mcp.name != ".mcp.json":
                    continue
                if _OBS_TOOL_RE.search(_read(mcp)):
                    signals.add(f"MCP server over telemetry: {mcp.relative_to(repo_root)}")
        
            # 2. Repo skills named for logs/metrics/traces. These live under `skills/`
            #    or `.claude/skills/` - the latter is in EXCLUDE_DIRS (so absent from
            #    `files`), so scan directly for SKILL.md rather than reusing the walk.
            _skip = {"node_modules", ".venv", "venv", "dist", "build", ".git"}
            for path in repo_root.rglob("SKILL.md"):
                rel = path.relative_to(repo_root)
                parts = [p.lower() for p in rel.parts]
                if any(p in _skip for p in parts):
                    continue
                if "skills" in parts and _OBS_TOOL_RE.search(path.parent.name):
                    signals.add(f"repo skill for telemetry: {rel}")
        
            # 3. Runbooks whose fenced code blocks hold runnable query commands.
            for rb in runbooks:
                if _RUNNABLE_QUERY_RE.search(_fenced_code(_read(rb))):
                    signals.add(f"runbook with runnable queries: {rb.relative_to(repo_root)}")
        
            return sorted(signals)
        
        
        @dataclass
        class ObservabilityResult:
            instrumented: list[str] = field(default_factory=list)
            discoverable: list[str] = field(default_factory=list)
            reachable: list[str] = field(default_factory=list)
            rung: int = 0
        
            def as_dict(self) -> dict:
                return {
                    "rung": self.rung,
                    "instrumented": {"present": bool(self.instrumented), "signals": self.instrumented},
                    "discoverable": {"present": bool(self.discoverable), "signals": self.discoverable},
                    "reachable": {"present": bool(self.reachable), "signals": self.reachable},
                    "boundary": (
                        "Scores what the repo makes agent-reachable; cannot observe the "
                        "agent's live environment."
                    ),
                }
        
        
        def scan_observability(repo_root: Path,
                               extra_exclude_dirs: set[str] | None = None,
                               extra_exclude_patterns: list[str] | None = None,
                               ) -> ObservabilityResult:
            repo_root = repo_root.resolve()
            # One tree walk shared across all three rung detectors (was ~4-5 walks).
            files = _iter_files(
                repo_root,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
            )
            instrumented = _detect_instrumented(repo_root, files)
            discoverable, runbooks = _detect_discoverable(repo_root, files)
            reachable = _detect_reachable(repo_root, files, runbooks)
            # Instrumentation is *necessary* for the doc-based ladder (see the rung model
            # above: rung 0 == "no runtime instrumentation; liveness unknowable"). A repo
            # that only *documents* observability - an SRE how-to, or this toolkit's own
            # SKILL.md describing what a runbook looks like - trips the discoverable /
            # runbook-prose detectors without emitting any telemetry, which previously
            # inflated such a repo straight to rung 3. So prose evidence only elevates
            # the rung when the repo is actually instrumented. Genuinely *invokable*
            # tooling (an .mcp.json telemetry server, a logs/metrics repo skill) is real
            # agent-reachability on its own and still scores rung 3 without a manifest.
            tool_reachable = [s for s in reachable
                              if s.startswith(("MCP server", "repo skill"))]
            if tool_reachable:
                rung = 3
            elif instrumented:
                rung = 3 if reachable else 2 if discoverable else 1
            else:
                rung = 0
            return ObservabilityResult(
                instrumented=instrumented, discoverable=discoverable,
                reachable=reachable, rung=rung,
            )
        
        
        def _merge_jvm_liveness(dead_code: dict, jvm: dict) -> None:
            """Fold the JVM liveness capability into the existing ``dead_code`` block so
            the per-symbol dead-code consumers (the ``runtime`` block, the report) see
            Maven candidates without a schema change. The full capability-offer detail
            (honest-degrade + crediting) rides separately on ``jvm_capabilities``.
        
            A ``served`` Maven liveness contributes its coarse module-level candidates
            and flips ``available``; an ``offer`` contributes only a tool entry so the
            block records "a tool could serve this" rather than a silent miss.
            """
            liveness = jvm.get("capabilities", {}).get("liveness", {})
            state = liveness.get("state")
            tool = liveness.get("candidate_tool", "mvn dependency:analyze")
            if state == "served":
                candidates = liveness.get("candidates", [])
                existing = dead_code.setdefault("candidates", [])
                existing.extend(candidates)
                dead_code["candidate_count"] = len(existing)
                if candidates:
                    dead_code["available"] = True
                dead_code.setdefault("tools", []).append({
                    "language": "java", "tool": tool, "status": "ran",
                    "reason": f"{len(candidates)} unused-declared-dependency candidate(s)",
                })
            elif state == "offer":
                consent = liveness.get("consent")
                dead_code.setdefault("tools", []).append({
                    "language": "java", "tool": tool,
                    "status": "available_not_run",
                    "reason": liveness.get("note", "Maven liveness offer pending"),
                    "consent": consent,
                })
            elif state == "honest_degrade":
                dead_code.setdefault("tools", []).append({
                    "language": "java", "tool": tool,
                    "status": "honest_degrade",
                    "reason": liveness.get("note", "no served liveness path"),
                })
        
        
        def _merge_dart_liveness(dead_code: dict, dart: dict) -> None:
            """Record Dart liveness in ``dead_code.tools`` as one ``honest_degrade`` entry.
        
            Built here rather than as a ``_DEAD_CODE_TOOLS`` spec because the scan never
            runs the analyzer: a spec would try to run ``dart`` whenever it is on PATH,
            and no parser reads analyzer output. The entry keeps the non-JVM shape
            (``language``, ``tool``, ``status``, ``reason``) and adds no candidates.
            """
            liveness = dart.get("capabilities", {}).get("liveness", {})
            if liveness.get("state") != "honest_degrade":
                return
            dead_code.setdefault("tools", []).append({
                "language": "dart", "tool": liveness.get("candidate_tool"),
                "status": "honest_degrade", "reason": liveness.get("note"),
            })
        
        
        def scan_liveness(repo_root: Path, run_dead_code: bool = True,
                          run_build_tools: bool = False,
                          extra_exclude_dirs: set[str] | None = None,
                          extra_exclude_patterns: list[str] | None = None,
                          scope: Path | None = None,
                          ) -> dict:
            """Top-level Layer 1 scan: dead-code candidates + observability rungs, plus
            the capability-driven JVM offer block when a Maven/Gradle project is found
            and the Dart capability block when a ``pubspec.yaml`` is found.
        
            `run_build_tools` defaults to False so the scan stays read-only - build-
            mutating dead-code tools (and `mvn dependency:analyze`, a run-consent goal)
            are reported as available-but-not-run / offer rather than executed.
            `extra_exclude_dirs` and `extra_exclude_patterns` come from
            `.assess/config.toml` / `--exclude` and apply to the dead-code scan, the
            observability tree walk, JVM build-file detection and Dart `pubspec.yaml`
            detection alike.
        
            `scope` (an absolute path under `repo_root`) confines the dead-code
            candidates to a subtree for `/assess <path>` monorepo scoping; the
            observability rungs stay repo-level (telemetry is a whole-repo property).
            """
            from lib.dart_capabilities import scan_dart_capabilities
            from lib.jvm_capabilities import scan_jvm_capabilities
        
            dead_code = scan_dead_code(
                repo_root, run=run_dead_code, run_build_tools=run_build_tools,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
                scope=scope,
            ).as_dict()
            jvm = scan_jvm_capabilities(
                repo_root, run_build_tools=run_build_tools,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
            )
            if jvm.get("available"):
                _merge_jvm_liveness(dead_code, jvm)
            dart = scan_dart_capabilities(
                repo_root,
                extra_exclude_dirs=extra_exclude_dirs,
                extra_exclude_patterns=extra_exclude_patterns,
            )
            if dart.get("available"):
                _merge_dart_liveness(dead_code, dart)
            result = {
                "dead_code": dead_code,
                "observability": scan_observability(
                    repo_root,
                    extra_exclude_dirs=extra_exclude_dirs,
                    extra_exclude_patterns=extra_exclude_patterns,
                ).as_dict(),
            }
            if jvm.get("available"):
                result["jvm_capabilities"] = jvm
            if dart.get("available"):
                result["dart_capabilities"] = dart
            return result
        
      • ownership_parser.py 18.8 KB
        """Ownership-map parsing for Layer 0 structure-drift detection.
        
        Ownership is declared in two places an LLM contributor reads as authoritative
        boundaries: a GitHub ``CODEOWNERS`` file (glob -> owner) and a freeform
        ``ARCHITECTURE.md`` (prose -> "module X owns these paths"). Both are *declared*
        maps of how the code is meant to be organised. The structure-drift signals
        (tasks 9/10) compare those declarations against where the code actually lives
        and how it actually changes - a glob that matches nothing, a declared module
        whose files have scattered, a boundary the commit history no longer respects.
        
        This module is only the parse half: turn the two declaration formats into
        ``{declared_boundary: {matched_file_paths}}`` maps and flag the globs that
        already match zero files (the cheapest drift - a boundary the filesystem has
        left behind). It mirrors ``doc_graph.py`` for module shape: a single shared
        file walk with the same ``EXCLUDE_DIRS`` / ``is_excluded_path`` resolution,
        ``tracked_files`` to honour ``.gitignore``, and honest degradation to an
        ``available=False`` result rather than ever crashing the assessment.
        
        Determinism is a contract: the same repo must produce byte-identical output, so
        every returned collection is sorted at the boundary and no set/dict iteration
        order leaks out. The drift signals that consume this module (tasks 9/10) and the
        orchestrator wiring (task 11) are deliberately not here - this is the parser and
        empty-glob detector only.
        """
        from __future__ import annotations
        
        import fnmatch
        import re
        import sys
        from pathlib import Path
        
        from lib.doc_graph import is_excluded_path, is_repo_file
        from lib.git_churn import tracked_files
        
        # Where an ownership map can legitimately live. CODEOWNERS is recognised by
        # GitHub at the repo root, in ``.github/``, or in ``docs/`` - we honour the same
        # three so a repo that keeps it in any of them is read, not missed.
        CODEOWNERS_LOCATIONS: tuple[str, ...] = (
            "CODEOWNERS",
            ".github/CODEOWNERS",
            "docs/CODEOWNERS",
        )
        
        # Markdown docs that declare module boundaries in prose. ``ARCHITECTURE.md`` is
        # the convention, but a repo often carries the same boundary map in a top-level
        # or per-package ``README.md`` (a "co-change seam map" / module reference). We
        # scan the conventional names wherever they sit so the declaration is read from
        # whatever file actually holds it, not only one hard-coded path.
        ARCHITECTURE_BASENAMES: frozenset[str] = frozenset({
            "architecture.md",
            "design.md",
        })
        
        # A README only counts as an architecture doc when it actually declares module
        # boundaries - a generic project README is not an ownership map. We treat a
        # README as a boundary declaration when its prose carries an ownership/seam
        # vocabulary (see ``_declares_boundaries``); otherwise it is skipped.
        README_BASENAMES: frozenset[str] = frozenset({"readme.md"})
        
        # Prose that marks a markdown doc as declaring module ownership/boundaries. Used
        # to admit a README as an architecture doc only when it genuinely maps modules.
        _BOUNDARY_VOCAB_RE = re.compile(
            r"\b(owns?|owner|ownership|boundar(?:y|ies)|module(?:s)?|seam(?:s)?|"
            r"co-?change|cohesion|co-?locat)",
            re.IGNORECASE,
        )
        
        # Inline-code spans and fenced blocks carry the path references we extract from
        # prose - ``doc_graph.py`` strips these to *avoid* phantom links, but here a
        # path written as code (`` `skills/assess/scripts` ``) is exactly the boundary
        # declaration we want, so we read them rather than strip them.
        _FENCE_RE = re.compile(r"```.*?\n.*?```", re.DOTALL)
        _INLINE_CODE_RE = re.compile(r"`([^`\n]{1,200})`")
        _WIKILINK_RE = re.compile(r"\[\[([^\[\]]+?)\]\]")
        # A bare path reference in prose: a slash-bearing token that looks like a repo
        # path. Anchored on a path separator so plain words don't match; trailing
        # punctuation is trimmed by the caller.
        _BARE_PATH_RE = re.compile(r"(?<![\w./-])([A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+)")
        
        # A markdown section header (``#``..``######``). The text after the hashes names
        # the module the section is about; its path references are attributed to it.
        _HEADER_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*$")
        
        # Glob characters that make a CODEOWNERS pattern a wildcard rather than a literal
        # path. Used only to classify a pattern for reporting; matching uses fnmatch.
        _GLOB_CHARS = set("*?[")
        
        
        def _warn(msg: str) -> None:
            """Best-effort warning to stderr; never raises, never blocks the scan."""
            print(f"note: ownership_parser: {msg}", file=sys.stderr)
        
        
        def _strip_anchor(target: str) -> str:
            """Drop a ``|alias`` (wikilink) and ``#anchor`` / ``?query`` from a target."""
            target = target.split("|", 1)[0]
            target = target.split("#", 1)[0]
            target = target.split("?", 1)[0]
            return target.strip().strip("\"'")
        
        
        def _codeowners_path(repo_root: Path) -> Path | None:
            """The single CODEOWNERS file in effect, by GitHub's location precedence."""
            for rel in CODEOWNERS_LOCATIONS:
                candidate = repo_root / rel
                if candidate.is_file():
                    return candidate
            return None
        
        
        def _glob_to_fnmatch(pattern: str) -> tuple[str, ...]:
            """Translate a CODEOWNERS glob to the fnmatch patterns it matches against.
        
            Returns one or more fnmatch patterns (any match counts), normalising the two
            places CODEOWNERS rules differ from shell globs:
        
            - A leading ``/`` anchors to the repo root; without it the pattern matches at
              any depth. fnmatch has no anchor concept, so an unanchored pattern is
              tried both as-is (matching at root) *and* prefixed with ``*/`` (matching in
              any subdirectory) - so a bare ``Makefile`` claims both ``Makefile`` and
              ``tools/Makefile``. An anchored or already-path-qualified pattern is tried
              root-relative only.
            - A trailing ``/`` (a directory) matches everything beneath it, so ``docs/``
              becomes ``docs/*``.
        
            fnmatch's ``*`` crosses ``/`` for us, so ``**`` and ``*`` behave the same;
            every ``**`` is collapsed to ``*`` for one predictable form.
            """
            pat = pattern.strip()
            anchored = pat.startswith("/")
            pat = pat.lstrip("/")
            if pat.endswith("/"):
                pat = pat + "*"
            pat = pat.replace("**", "*")
            # An anchored or path-qualified pattern is root-relative; a bare, unanchored
            # pattern matches at any depth, so also try the subdirectory form.
            if anchored or "/" in pat.rstrip("*"):
                return (pat,)
            return (pat, "*/" + pat)
        
        
        def _match_glob(pattern: str, rel_paths: list[str]) -> set[Path]:
            """Repo-relative paths matching a CODEOWNERS glob.
        
            Matches both the file itself and any file beneath a directory pattern, so a
            ``src/`` rule claims every file under ``src``. A path matches when it matches
            any of the fnmatch forms ``_glob_to_fnmatch`` derives for the pattern.
            """
            forms = _glob_to_fnmatch(pattern)
            out: set[Path] = set()
            for rp in rel_paths:
                if any(fnmatch.fnmatch(rp, form) for form in forms):
                    out.add(Path(rp))
            return out
        
        
        def parse_codeowners(repo_root: Path) -> dict[str, set[Path]]:
            """Parse the repo's CODEOWNERS into ``{glob_pattern: {matched_file_paths}}``.
        
            GitHub CODEOWNERS format: each non-comment line is ``pattern @owner...``;
            we keep the pattern (the declared boundary) and resolve it against the
            git-tracked file set so ``.gitignore``'d files never count. Comment (``#``)
            and blank lines are skipped. A pattern that appears twice is unioned. Every
            returned path set is the resolved match set; the empty-glob detector reads
            these to flag patterns that match nothing.
        
            Degrades to an empty dict when no CODEOWNERS file exists - the caller reads
            that as ``available: False`` ("no ownership map"). A malformed individual
            line is skipped with a warning rather than aborting the parse.
            """
            repo_root = repo_root.resolve()
            path = _codeowners_path(repo_root)
            if path is None:
                return {}
        
            tracked = tracked_files(repo_root)
            rel_paths = _tracked_rel_paths(repo_root, tracked)
        
            try:
                text = path.read_text(encoding="utf-8", errors="ignore")
            except OSError as exc:
                _warn(f"could not read {path}: {exc}")
                return {}
        
            out: dict[str, set[Path]] = {}
            for lineno, raw in enumerate(text.splitlines(), start=1):
                line = raw.strip()
                if not line or line.startswith("#"):
                    continue
                # ``pattern @owner1 @owner2`` - the pattern is the first whitespace token.
                try:
                    pattern = line.split()[0]
                except IndexError:  # pragma: no cover - split() of non-empty never empty
                    _warn(f"{path}:{lineno}: could not parse line, skipping")
                    continue
                out.setdefault(pattern, set()).update(_match_glob(pattern, rel_paths))
            return out
        
        
        def _tracked_rel_paths(
            repo_root: Path, tracked: frozenset[Path] | None,
        ) -> list[str]:
            """Repo-relative POSIX path strings for every tracked, non-excluded file.
        
            Honours the same ``EXCLUDE_DIRS`` / ``is_excluded_path`` resolution the doc
            graph uses, and falls back to a filesystem walk (with the symlink guard)
            when the tree is not under git, so the glob resolver always has a file set.
            """
            rels: list[str] = []
            if tracked is not None:
                for abs_path in tracked:
                    try:
                        rel = abs_path.relative_to(repo_root)
                    except ValueError:
                        continue
                    if is_excluded_path(rel):
                        continue
                    rels.append(rel.as_posix())
                return sorted(rels)
            # Non-git tree: walk the filesystem, applying the same excludes + symlink guard.
            for abs_path in repo_root.rglob("*"):
                if not abs_path.is_file():
                    continue
                try:
                    rel = abs_path.relative_to(repo_root)
                except ValueError:
                    continue
                if is_excluded_path(rel):
                    continue
                if not is_repo_file(abs_path, repo_root, None):
                    continue
                rels.append(rel.as_posix())
            return sorted(rels)
        
        
        def _discover_arch_docs(repo_root: Path) -> list[Path]:
            """Markdown docs that declare module boundaries, in deterministic order.
        
            Admits the conventional architecture filenames (``ARCHITECTURE.md`` /
            ``DESIGN.md``) wherever they sit, plus any ``README.md`` whose prose carries
            the ownership/seam vocabulary (a generic README is skipped). Mirrors the doc
            graph's exclude resolution and symlink guard so vendored or build-artifact
            docs never count.
            """
            repo_root = repo_root.resolve()
            tracked = tracked_files(repo_root)
            found: list[Path] = []
            for path in repo_root.rglob("*"):
                if not path.is_file() or path.suffix.lower() != ".md":
                    continue
                try:
                    rel = path.relative_to(repo_root)
                except ValueError:
                    continue
                if is_excluded_path(rel):
                    continue
                if not is_repo_file(path, repo_root, tracked):
                    continue
                name = path.name.lower()
                if name in ARCHITECTURE_BASENAMES:
                    found.append(path)
                elif name in README_BASENAMES and _readme_declares_boundaries(path):
                    found.append(path)
            return sorted(found)
        
        
        def _readme_declares_boundaries(path: Path) -> bool:
            """True if a README's prose carries the module-ownership/seam vocabulary."""
            try:
                text = path.read_text(encoding="utf-8", errors="ignore")
            except OSError:
                return False
            return _declares_boundaries(text)
        
        
        def _declares_boundaries(text: str) -> bool:
            """True if ``text`` reads as a module-ownership / seam declaration."""
            return bool(_BOUNDARY_VOCAB_RE.search(text))
        
        
        def _extract_path_refs(
            segment: str, suffixes: tuple[str, ...] = (".md", ".py"),
        ) -> set[str]:
            """All path references in a prose segment: inline code, wikilinks, bare paths.
        
            Reads code spans rather than stripping them (the opposite of the doc graph),
            because a path written as code is exactly the boundary declaration we want.
            Returns raw, repo-relative-looking path strings; resolution to real files is
            the caller's job. A slash-free code span counts only when it ends in one of
            ``suffixes`` (the doc graph passes every doc extension it walks).
            """
            refs: set[str] = set()
            for m in _INLINE_CODE_RE.finditer(segment):
                token = _strip_anchor(m.group(1))
                if "/" in token or token.endswith(suffixes):
                    refs.add(token.rstrip("/.,;:)"))
            for m in _WIKILINK_RE.finditer(segment):
                token = _strip_anchor(m.group(1))
                if token:
                    refs.add(token.rstrip("/.,;:)"))
            # Bare paths in plain prose, but not inside the code spans we already read
            # (those are caught above with cleaner boundaries).
            defenced = _INLINE_CODE_RE.sub(" ", segment)
            for m in _BARE_PATH_RE.finditer(defenced):
                token = _strip_anchor(m.group(1))
                refs.add(token.rstrip("/.,;:)"))
            return {r for r in refs if r}
        
        
        def _resolve_ref(
            ref: str, repo_root: Path, rel_paths: set[str],
        ) -> set[Path]:
            """Resolve a declared path reference to the tracked files it names.
        
            A reference can be an exact file (``lib/doc_graph.py``), a directory whose
            every file is claimed (``skills/assess/scripts``), or a bare note name
            (``doc_graph.py``) matched by basename. Returns the set of repo-relative
            file paths it resolves to; empty when it matches nothing tracked.
            """
            norm = ref.lstrip("/").rstrip("/")
            if not norm:
                return set()
            # Exact tracked file.
            if norm in rel_paths:
                return {Path(norm)}
            # Directory prefix: claim every tracked file beneath it.
            prefix = norm + "/"
            under = {Path(rp) for rp in rel_paths if rp.startswith(prefix)}
            if under:
                return under
            # Bare basename: match any tracked file with this name.
            if "/" not in norm:
                by_name = {Path(rp) for rp in rel_paths if Path(rp).name == norm}
                if by_name:
                    return by_name
            return set()
        
        
        def parse_architecture_md(repo_root: Path) -> dict[str, set[Path]]:
            """Parse boundary-declaring docs into ``{declared_module: {file_paths}}``.
        
            Walks each architecture doc (``ARCHITECTURE.md`` / ``DESIGN.md`` / a
            boundary-declaring ``README.md``), splits it into sections by markdown
            header, and attributes every path reference in a section's body to the
            module the header names. Path references are read from inline code,
            wikilinks, and bare prose paths, then resolved against the tracked file set;
            references that resolve to nothing are dropped (a declared module keeps only
            its real files).
        
            The declared-module key is namespaced by the doc that declares it
            (``<rel-doc>::<header>``) so two docs declaring a "Module reference" section
            don't collide. Degrades to an empty dict when no boundary doc exists. A doc
            that fails to read is skipped with a warning; the rest still parse.
            """
            repo_root = repo_root.resolve()
            docs = _discover_arch_docs(repo_root)
            if not docs:
                return {}
        
            tracked = tracked_files(repo_root)
            rel_paths = set(_tracked_rel_paths(repo_root, tracked))
        
            out: dict[str, set[Path]] = {}
            for doc in docs:
                try:
                    text = doc.read_text(encoding="utf-8", errors="ignore")
                except OSError as exc:
                    _warn(f"could not read {doc}: {exc}")
                    continue
                doc_rel = doc.relative_to(repo_root).as_posix()
                _parse_one_arch_doc(text, doc_rel, repo_root, rel_paths, out)
            return out
        
        
        def _parse_one_arch_doc(
            text: str,
            doc_rel: str,
            repo_root: Path,
            rel_paths: set[str],
            out: dict[str, set[Path]],
        ) -> None:
            """Attribute one doc's path references to the modules its headers name.
        
            Mutates ``out`` in place, keying each declared module ``<doc_rel>::<header>``
            and unioning the files its section's references resolve to. References before
            the first header are attributed to the doc itself (``<doc_rel>::<doc>``).
            """
            # Strip fenced code blocks: a fence is a sample/listing, and the bare-path
            # regex over its contents would manufacture spurious module->file edges.
            body = _FENCE_RE.sub("\n", text)
            current = f"{doc_rel}::{Path(doc_rel).name}"
            buffer: list[str] = []
        
            def flush(section_key: str, lines: list[str]) -> None:
                if not lines:
                    return
                segment = "\n".join(lines)
                files: set[Path] = set()
                for ref in _extract_path_refs(segment):
                    files |= _resolve_ref(ref, repo_root, rel_paths)
                if files:
                    out.setdefault(section_key, set()).update(files)
        
            for line in body.splitlines():
                m = _HEADER_RE.match(line)
                if m:
                    flush(current, buffer)
                    buffer = []
                    header_text = m.group(2).strip()
                    current = f"{doc_rel}::{header_text}"
                else:
                    buffer.append(line)
            flush(current, buffer)
        
        
        def is_glob(pattern: str) -> bool:
            """True if a CODEOWNERS pattern is a wildcard rather than a literal path."""
            return any(c in _GLOB_CHARS for c in pattern) or pattern.endswith("/")
        
        
        def find_empty_globs(ownership_map: dict[str, set[Path]]) -> list[dict]:
            """CODEOWNERS patterns that match zero tracked files.
        
            An empty glob is the cheapest structure-drift signal: a declared boundary
            the filesystem has already left behind (a renamed directory, a deleted
            module, a typo'd pattern). Returns ``[{pattern, declared_in}]`` sorted by
            pattern for deterministic output. ``declared_in`` is the fixed source
            ``"CODEOWNERS"`` - the only producer of these glob keys.
            """
            empties = [
                {"pattern": pattern, "declared_in": "CODEOWNERS"}
                for pattern, files in ownership_map.items()
                if not files
            ]
            return sorted(empties, key=lambda e: e["pattern"])
        
        
        def parse_ownership(repo_root: Path) -> dict:
            """Combined ownership parse with the module-shape degradation contract.
        
            Runs both parsers and the empty-glob detector and returns a JSON-serialisable
            summary mirroring ``doc_graph.py``'s ``available``/``reason`` shape: when no
            ownership map of either kind is found, ``available`` is False with reason
            ``"no ownership map"``. Every collection is sorted at the boundary (sets ->
            sorted lists) so the same repo yields byte-identical output.
            """
            repo_root = repo_root.resolve()
            codeowners = parse_codeowners(repo_root)
            architecture = parse_architecture_md(repo_root)
        
            if not codeowners and not architecture:
                return {
                    "available": False,
                    "reason": "no ownership map",
                    "codeowners_globs": [],
                    "architecture_modules": [],
                    "empty_globs": [],
                }
        
            empty = find_empty_globs(codeowners)
            return {
                "available": True,
                "reason": "",
                "codeowners_globs": [
                    {"pattern": pattern, "matched_files": sorted(str(p) for p in files)}
                    for pattern, files in sorted(codeowners.items())
                ],
                "architecture_modules": [
                    {"module": module, "files": sorted(str(p) for p in files)}
                    for module, files in sorted(architecture.items())
                ],
                "empty_globs": empty,
            }
        
      • promissory_markers.py 20.5 KB
        """Promissory-marker scan: stale TODO/FIXME, suppressions, disabled tests.
        
        Detects the four families of *promissory markers* - lines where the code makes
        a promise about its own future - and ages each one by the number of commits to
        its file that have landed since the marker was introduced ("survived touches").
        A marker that survived many edits to an actively-maintained file is unactioned
        intent: the damning case. A marker in a dormant file is just dormant; calendar
        age alone cannot tell these apart, so survived-touches is the primary metric.
        
        Families and the layer each one wounds:
        
        - ``todo``           TODO / FIXME / HACK / XXX / TBD          -> Layer 8 (intent tracking)
        - ``deprecation``    @deprecated / DEPRECATED / remove-after  -> Layer 2 (design honesty)
        - ``suppression``    noqa / type: ignore / eslint-disable ... -> Layer 3 (linter integrity)
        - ``disabled_test``  pytest.mark.skip / it.skip / @Disabled   -> Layer 5 (CI integrity)
        
        A marker is *tracked* (pressure exists) when it cites an issue, ticket, URL, or
        deadline date - or, for suppressions, when it carries an inline justification
        (``//nolint:x // reason``, ``eslint-disable-line x -- reason``). A justified
        suppression is never stale, however many edits it survived: the reason is the
        record, and there is no promise left to keep. Other tracked markers still age,
        because an issue or a deadline can go stale while the marker stays. The bare
        remainder is the debt. Each marker's
        introducing commit is also classified agent/human (reusing the conservative B4
        identity rules from ``change_coupling``), so "agent-introduced unactioned
        intent" is a measured quantity, not an article of faith.
        
        Pure subprocess (rg + git) and stdlib. No LLM calls. Degrades to
        ``available: False`` when ``rg`` is missing or the directory is not a git
        repo; never raises out of ``scan_promissory_markers``.
        
        CLI (standalone use)::
        
            uv run promissory_markers.py <repo_root> [--stale-touches 5] [--json OUT]
        """
        
        from __future__ import annotations
        
        import json
        import re
        import subprocess
        import sys
        from collections import defaultdict
        from collections.abc import Iterable
        from concurrent.futures import ThreadPoolExecutor
        from dataclasses import dataclass, field
        from pathlib import Path
        from typing import Any
        
        try:
            from lib.change_coupling import _coauthors_have_agent, _identity_is_agent
            from lib.git_churn import churn_is_degenerate
        except ImportError:  # standalone CLI: script dir is lib/, put scripts/ on path
            sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
            from lib.change_coupling import _coauthors_have_agent, _identity_is_agent
            from lib.git_churn import churn_is_degenerate
        
        # Default: a marker is stale once this many commits to its file landed after it.
        STALE_TOUCHES_DEFAULT = 5
        
        # Severity weight per family: a stale suppression or disabled test is a hole in
        # an enforcement layer; a bare TODO is unactioned intent but wounds nothing yet.
        FAMILY_WEIGHTS = {
            "suppression": 3,
            "disabled_test": 3,
            "deprecation": 2,
            "todo": 1,
        }
        
        # One rg pattern per family. Kept deliberately coarse: precision comes from the
        # comment-context filter and the survived-touches join, not from the regex.
        # Case-sensitive with word boundaries on purpose - a case-insensitive TODO
        # matches every Dart ``toDouble()``. Ecosystem-specific syntaxes (Dart's
        # ``// ignore:``) must be listed explicitly; absence means a silent miss, so
        # new entries need a fixture in tests/test_promissory_markers.py.
        FAMILY_PATTERNS = {
            "todo": r"\b(TODO|FIXME|HACK|XXX|TBD)\b|remove (after|before|once|when)|temporary (workaround|hack|fix)",
            "deprecation": r"@[Dd]eprecated\b|\bDEPRECATED\b",
            "suppression": (
                r"#\s*noqa|#\s*type:\s*ignore|eslint-disable|//\s*nolint|"
                r"@SuppressWarnings|#\s*nosec|rubocop:disable|pylint:\s*disable|"
                r"@ts-ignore|@ts-nocheck|//\s*NOSONAR|"
                r"//\s*ignore(_for_file)?:"  # Dart analyzer
            ),
            "disabled_test": (
                r"pytest\.mark\.skip|@unittest\.skip|\bxfail\b|"
                r"\b(it|test|describe|xit|xdescribe)\.skip\(|"
                r"t\.Skip\(|@Disabled\b|@Ignore\b|"
                r"\bskip:\s*(true|')"  # Dart test() / Playwright fixme param
            ),
        }
        
        # A marker counts as "linked" (tracked intent - pressure exists) when it cites
        # an issue, a ticket, a URL, or a deadline date.
        LINKED_RE = re.compile(r"#\d+|\b[A-Z][A-Z0-9]+-\d+\b|https?://|\b\d{4}-\d{2}-\d{2}\b")
        
        # A suppression with an inline justification is tracked: recorded reasoning is
        # pressure (nolintlint-style). Matched as a second comment segment after the
        # directive, e.g. ``//nolint:nilerr // error conveyed via response status``, or
        # as ESLint's documented ``-- reason`` description, e.g.
        # ``// eslint-disable-line no-console -- CLI prints by design``. The ``--`` must
        # follow whitespace, so a hyphenated rule name is not read as a reason, and a
        # block directive's reason must sit inside its own ``/* ... */`` or in a
        # trailing ``//`` comment - code after ``*/`` is not a reason.
        JUSTIFIED_SUPPRESSION_RE = re.compile(
            r"(nolint[^/]*//|noqa[^#]*#|eslint-disable[^*]*\*/\s*//|"
            r"//\s*ignore:[^/]*//|@SuppressWarnings\(.+\)\s*//)\s*\S"
            r"|/\*\s*eslint-disable[^*]*?\s--\s*[^\s*]"
            r"|//\s*eslint-disable\S*\s.*?\s--\s*\S"
        )
        
        # Comment leaders; a todo/deprecation hit must sit after one of these on its
        # line (suppressions and disabled tests are syntactic and skip the check).
        # Prose files count whole-line for todo/deprecation, and are excluded entirely
        # for the syntactic families (a t.Skip in a guide is an example, not debt).
        COMMENT_LEADERS = ("#", "//", "/*", "*", "<!--", "--", ";;", "%", '"""', "'''")
        PROSE_SUFFIXES = {".md", ".markdown", ".rst", ".txt", ".adoc"}
        
        # Generated / vendored / lockfile noise that rg's gitignore pass won't catch
        # when the files are committed. Mirrors the treemap's exclude spirit; the
        # generated-Dart entries proved load-bearing (1,684 of a Flutter repo's 1,698
        # suppressions were codegen ``ignore_for_file`` boilerplate).
        EXCLUDE_GLOBS = [
            "!**/*.pb.go", "!**/*_pb2.py", "!**/*_pb.ts", "!**/*.g.dart",
            "!**/*.freezed.dart", "!**/*.gr.dart", "!**/*.min.js", "!**/*.bundle.js",
            "!**/package-lock.json", "!**/deno.lock", "!**/*.lock", "!**/*.map",
            "!**/node_modules/**", "!**/vendor/**", "!**/dist/**", "!**/build/**",
            "!**/.assess/**", "!**/tests/fixtures/**", "!**/*.svg",
        ]
        
        # Cap the markers carried into run-context.json so a pathological repo can't
        # bloat the bus; family totals always reflect the full scan.
        MAX_TOP_OFFENDERS = 10
        
        
        @dataclass
        class Marker:
            path: str
            line: int
            family: str
            text: str
            linked: bool
            justified: bool = False  # suppression carrying an inline justification
            commit: str = ""
            author_time: int = 0
            agent_introduced: bool | None = None  # None = could not classify
            survived_touches: int = -1  # -1 = could not be aged
            severity: float = 0.0
        
            def to_dict(self) -> dict[str, Any]:
                return {
                    "path": self.path,
                    "line": self.line,
                    "family": self.family,
                    "text": self.text[:160],
                    "linked": self.linked,
                    "justified": self.justified,
                    "commit": self.commit[:12],
                    "agent_introduced": self.agent_introduced,
                    "survived_touches": self.survived_touches,
                    "severity": round(self.severity, 2),
                }
        
        
        @dataclass
        class MarkerScan:
            available: bool
            reason: str = ""
            stale_touches_threshold: int = STALE_TOUCHES_DEFAULT
            markers: list[Marker] = field(default_factory=list)
            aging_reliable: bool = True  # False when history is too thin to age markers
        
            def is_stale(self, m: Marker) -> bool:
                """Stale = survived at least the threshold of edits, unless justified.
        
                Only a justified suppression is exempt. A marker linked to an issue or a
                date still ages: ``remove after 2019-06-01`` surviving 65 edits is the
                broken promise, not tracked intent.
                """
                return (
                    not m.justified
                    and m.survived_touches >= self.stale_touches_threshold
                )
        
            @property
            def stale(self) -> list[Marker]:
                return [m for m in self.markers if self.is_stale(m)]
        
            def stale_by_file(self) -> dict[str, dict[str, Any]]:
                """Per-file rollup of stale markers, for hotspot pages and findings."""
                rollup: dict[str, dict[str, Any]] = {}
                for m in self.stale:
                    entry = rollup.setdefault(
                        m.path, {"count": 0, "families": set(), "max_survived": 0}
                    )
                    entry["count"] += 1
                    entry["families"].add(m.family)
                    entry["max_survived"] = max(entry["max_survived"], m.survived_touches)
                return {
                    p: {**e, "families": sorted(e["families"])} for p, e in rollup.items()
                }
        
            def summary(self) -> dict[str, Any]:
                fam: dict[str, dict[str, int]] = defaultdict(
                    lambda: {
                        "total": 0, "stale": 0, "linked": 0, "justified": 0,
                        "agent_introduced": 0,
                    }
                )
                for m in self.markers:
                    fam[m.family]["total"] += 1
                    fam[m.family]["linked"] += int(m.linked)
                    fam[m.family]["justified"] += int(m.justified)
                    fam[m.family]["agent_introduced"] += int(bool(m.agent_introduced))
                    fam[m.family]["stale"] += int(self.is_stale(m))
                bare = sum(1 for m in self.markers if m.family == "todo" and not m.linked)
                linked = sum(1 for m in self.markers if m.family == "todo" and m.linked)
                stale = self.stale
                top = sorted(stale, key=lambda m: -m.severity)[:MAX_TOP_OFFENDERS]
                return {
                    "available": self.available,
                    "reason": self.reason,
                    "aging_reliable": self.aging_reliable,
                    "stale_touches_threshold": self.stale_touches_threshold,
                    "families": dict(fam),
                    "todo_bare": bare,
                    "todo_linked": linked,
                    "total_markers": len(self.markers),
                    "total_stale": len(stale),
                    "stale_agent_introduced": sum(
                        1 for m in stale if m.agent_introduced
                    ),
                    "stale_by_file": self.stale_by_file(),
                    "top_offenders": [m.to_dict() for m in top],
                }
        
        
        def _unavailable_summary(reason: str) -> dict[str, Any]:
            return MarkerScan(available=False, reason=reason).summary()
        
        
        def _run(cmd: list[str], cwd: Path) -> str:
            out = subprocess.run(
                cmd, cwd=cwd, capture_output=True, text=True, errors="replace"
            )
            return out.stdout
        
        
        def _extra_globs(
            extra_exclude_dirs: Iterable[str] | None,
            extra_exclude_patterns: Iterable[str] | None,
        ) -> list[str]:
            """Translate `.assess/config.toml` excludes into rg globs (excludes parity)."""
            globs: list[str] = []
            for d in sorted(extra_exclude_dirs or []):
                globs.append(f"!**/{d}/**")
            for p in sorted(extra_exclude_patterns or []):
                globs.append(f"!**/{p}")
            return globs
        
        
        def _detect(repo_root: Path, extra_globs: list[str]) -> list[Marker]:
            """Stage 1: one rg pass per family, comment-context filtered."""
            markers: list[Marker] = []
            for family, pattern in FAMILY_PATTERNS.items():
                cmd = ["rg", "-n", "--no-heading", "--no-messages", "-e", pattern]
                for g in [*EXCLUDE_GLOBS, *extra_globs]:
                    cmd += ["--glob", g]
                cmd.append(".")
                for raw in _run(cmd, repo_root).splitlines():
                    parts = raw.split(":", 2)
                    if len(parts) != 3:
                        continue
                    path, line_s, text = parts
                    path = path.removeprefix("./")
                    is_prose = Path(path).suffix.lower() in PROSE_SUFFIXES
                    # Syntactic families in prose files are code examples, not debt.
                    if family in ("suppression", "disabled_test") and is_prose:
                        continue
                    if family in ("todo", "deprecation") and not _comment_context(
                        is_prose, text, pattern
                    ):
                        continue
                    justified = family == "suppression" and bool(
                        JUSTIFIED_SUPPRESSION_RE.search(text)
                    )
                    markers.append(
                        Marker(
                            path=path,
                            line=int(line_s),
                            family=family,
                            text=text.strip(),
                            linked=justified or bool(LINKED_RE.search(text)),
                            justified=justified,
                        )
                    )
            return markers
        
        
        def _comment_context(is_prose: bool, text: str, pattern: str) -> bool:
            """Keep a todo/deprecation hit only when it sits in a comment-ish context.
        
            Prose files count whole-line; code files require a comment leader at or
            before the match position on the same line. This is a line-local heuristic,
            not a parser - block-comment interiors that start with a bare word are the
            known false-negative, and string-literal mentions are the false-positive it
            exists to drop.
            """
            if is_prose:
                return True
            m = re.search(pattern, text)
            if not m:
                return False
            prefix = text[: m.start()]
            return any(lead in prefix for lead in COMMENT_LEADERS) or prefix.strip() == ""
        
        
        def _blame_ages(repo_root: Path, markers: list[Marker]) -> None:
            """Stage 2: batched git blame per hit-file -> introducing commit + time."""
            by_file: dict[str, list[Marker]] = defaultdict(list)
            for m in markers:
                by_file[m.path].append(m)
        
            def blame_one(item: tuple[str, list[Marker]]) -> None:
                path, ms = item
                cmd = ["git", "blame", "--porcelain"]
                for m in ms:
                    cmd += ["-L", f"{m.line},{m.line}"]
                cmd += ["--", path]
                out = _run(cmd, repo_root)
                # Porcelain emits ranges in the order requested; each range opens with
                # "<sha> <orig_line> <final_line> <n>" followed by headers incl.
                # author-time, then the content line (tab-prefixed).
                idx = 0
                sha, atime = "", 0
                for ln in out.splitlines():
                    if re.match(r"^[0-9a-f]{40} \d+ \d+", ln):
                        sha = ln.split()[0]
                    elif ln.startswith("author-time "):
                        atime = int(ln.split()[1])
                    elif ln.startswith("\t"):
                        if idx < len(ms):
                            ms[idx].commit = sha
                            ms[idx].author_time = atime
                        idx += 1
        
            with ThreadPoolExecutor(max_workers=8) as pool:
                list(pool.map(blame_one, by_file.items()))
        
        
        def _classify_authorship(repo_root: Path, markers: list[Marker]) -> None:
            """Stage 3: classify each marker's introducing commit agent/human.
        
            One batched ``git log --no-walk`` over the unique SHAs (single subprocess),
            reusing the conservative B4 identity rules: ``[bot]`` marker, AI e-mail
            hints, or an agent Co-Authored-By trailer. Conservative by construction -
            a human's work is never labelled agent on weak evidence.
            """
            shas = sorted({m.commit for m in markers if m.commit})
            if not shas:
                return
            out = subprocess.run(
                ["git", "log", "--no-walk", "--stdin",
                 "--format=%H%x02%ae%x02%an%x02%(trailers:key=Co-Authored-By,valueonly,separator=%x1d)"],
                cwd=repo_root, input="\n".join(shas), capture_output=True,
                text=True, errors="replace",
            ).stdout
            agent_by_sha: dict[str, bool] = {}
            for ln in out.splitlines():
                parts = ln.split("\x02")
                if len(parts) != 4:
                    continue
                sha, email, name, coauthors = parts
                agent_by_sha[sha] = (
                    _identity_is_agent(email, name) or _coauthors_have_agent(coauthors)
                )
            for m in markers:
                m.agent_introduced = agent_by_sha.get(m.commit)
        
        
        def _file_commit_times(repo_root: Path) -> dict[str, list[int]]:
            """One git-log pass: per-file list of commit timestamps (newest first)."""
            # %at (author time) to match blame's author-time header - committer time
            # diverges after rebases and would skew the survived-touches comparison.
            out = _run(
                ["git", "log", "--format=%x01%at", "--name-only", "--no-renames"],
                repo_root,
            )
            times: dict[str, list[int]] = defaultdict(list)
            current = 0
            for ln in out.splitlines():
                if ln.startswith("\x01"):
                    current = int(ln[1:])
                elif ln.strip():
                    times[ln.strip()].append(current)
            return times
        
        
        def scan_promissory_markers(
            repo_root: Path,
            stale_touches: int = STALE_TOUCHES_DEFAULT,
            extra_exclude_dirs: Iterable[str] | None = None,
            extra_exclude_patterns: Iterable[str] | None = None,
            scope: Path | None = None,
        ) -> MarkerScan:
            """Full pipeline: detect -> blame-age -> authorship -> severity.
        
            `scope` (an absolute path under `repo_root`) confines the markers to a
            subtree for `/assess <path>` monorepo scoping, so a scoped run carries no
            unactioned-intent signal from a sibling directory. Omit it for a whole-repo
            run.
            """
            try:
                subprocess.run(["rg", "--version"], capture_output=True, check=True)
            except (OSError, subprocess.CalledProcessError):
                return MarkerScan(available=False, reason="rg not on PATH")
            if not (repo_root / ".git").exists():
                return MarkerScan(available=False, reason="not a git repository root")
        
            try:
                markers = _detect(
                    repo_root, _extra_globs(extra_exclude_dirs, extra_exclude_patterns)
                )
                if scope is not None:
                    scope_abs = scope.resolve()
                    markers = [
                        m for m in markers
                        if (repo_root / m.path).resolve().is_relative_to(scope_abs)
                    ]
                _blame_ages(repo_root, markers)
                _classify_authorship(repo_root, markers)
                commit_times = _file_commit_times(repo_root)
                counts = {p: len(ts) for p, ts in commit_times.items()}
        
                # Degenerate history (every file ~1 commit: shallow clone, squashed
                # import) means survived-touches carries no information. Stay honest:
                # report markers but mark aging unreliable so nothing reads as "clean".
                # Same verdict definition as every other churn consumer (git_churn).
                aging_reliable = not churn_is_degenerate(counts.values())
                ranked = sorted(counts.values())
                n_ranked = len(ranked)
                for m in markers:
                    ts = commit_times.get(m.path, [])
                    if m.author_time:
                        m.survived_touches = sum(1 for t in ts if t > m.author_time)
                    file_count = counts.get(m.path, 0)
                    # bisect-free decile: fraction of files with fewer commits
                    below = sum(1 for c in ranked if c < file_count)
                    decile = (below / n_ranked) * 10 if n_ranked else 0.0
                    m.severity = FAMILY_WEIGHTS[m.family] * max(m.survived_touches, 0) * (
                        1 + decile / 10
                    )
                return MarkerScan(
                    available=True,
                    stale_touches_threshold=stale_touches,
                    markers=markers,
                    aging_reliable=aging_reliable,
                )
            except Exception as exc:  # noqa: BLE001 - degrade, never crash the core
                return MarkerScan(available=False, reason=f"{type(exc).__name__}: {exc}")
        
        
        def main() -> int:
            import argparse
            import time
        
            ap = argparse.ArgumentParser(description=__doc__)
            ap.add_argument("repo_root", type=Path)
            ap.add_argument("--stale-touches", type=int, default=STALE_TOUCHES_DEFAULT)
            ap.add_argument("--json", type=Path, help="write full summary JSON here")
            args = ap.parse_args()
        
            t0 = time.monotonic()
            scan = scan_promissory_markers(args.repo_root.resolve(), args.stale_touches)
            elapsed = time.monotonic() - t0
            s = scan.summary()
            s["elapsed_seconds"] = round(elapsed, 2)
        
            if args.json:
                args.json.write_text(json.dumps(s, indent=2))
        
            if not scan.available:
                print(f"unavailable: {scan.reason}")
                return 1
            print(f"scanned in {elapsed:.2f}s  threshold={scan.stale_touches_threshold} touches")
            print(f"{'family':<15}{'total':>7}{'stale':>7}{'linked':>8}{'agent':>7}")
            for fam, row in sorted(s["families"].items()):
                print(
                    f"{fam:<15}{row['total']:>7}{row['stale']:>7}{row['linked']:>8}"
                    f"{row['agent_introduced']:>7}"
                )
            print(
                f"\nbare:linked TODOs = {s['todo_bare']}:{s['todo_linked']}   "
                f"total stale = {s['total_stale']}/{s['total_markers']}   "
                f"stale agent-introduced = {s['stale_agent_introduced']}"
            )
            print("\ntop offenders (severity = weight x survived x churn-decile):")
            for m in s["top_offenders"]:
                who = {True: "agent", False: "human", None: "?"}[m["agent_introduced"]]
                print(
                    f"  {m['severity']:>7.1f}  survived {m['survived_touches']:>3}  "
                    f"[{m['family']}/{who}] {m['path']}:{m['line']}  {m['text'][:60]}"
                )
            return 0
        
        
        if __name__ == "__main__":
            sys.exit(main())
        
      • raw_source.py 16.5 KB
        """Raw-source subtree detection for /assess read-side metrics (issue #225).
        
        Read-side navigability metrics - orphan rate, reachability, broken links - are a
        property of the *curated* wiki: the navigable layer an agent traverses. A repo
        can also legitimately track trees of raw, machine-extracted source documents - a
        subject-access / disclosure export of hundreds of ``.msg`` / ``.pdf`` / ``.docx``
        files converted to markdown, say. Those files are immutable raw sources: they
        legitimately have no inbound wiki links and carry machine-extracted,
        non-navigational links (``mailto:`` / ``tel:`` / footer URLs lifted from the
        original document). Counting them as orphans and their links as broken inflates
        the figures and masks the actionable curated-wiki signal - the read most likely
        to drive a fix.
        
        This module turns "is this subtree a raw-source dump?" into a deterministic,
        threshold-based signal so :func:`lib.doc_graph.build_doc_graph` can exclude
        qualifying subtrees from the headline read-side metrics and report them
        separately, while leaving a repo with no such tree completely unaffected.
        
        Which contributor tendency does this guard? **Accretion of raw inputs.** An
        agent told to "ingest these documents" lands hundreds of converted files in the
        tree; nothing in that loop wires them into the wiki, so the orphan count
        ratchets up and the curated-layer signal drowns. The exclusion keeps the
        read-side number honest about the layer a human actually curates, and surfaces
        the raw tree by name + count so the exclusion stays legible rather than hidden.
        
        Detection is **graph-derived** - it reuses the link graph ``build_doc_graph``
        already computes, so there is no second parse - and operates on three per-doc
        signals:
        
        - ``in_degree``  - inbound wiki / markdown links (``0`` means nothing links to it)
        - ``out_degree`` - outbound links to *other docs* (``0`` means no internal
          navigation out of the file)
        - ``machine_links`` - count of non-navigational URI-scheme links (``mailto:``,
          ``tel:``, external ``http(s)``) - the machine-extraction fingerprint of a
          converted document
        
        A doc is *link-isolated* when it has no inbound and no outbound internal links
        (and is not an entry point). A subtree qualifies as raw-source when it is large
        enough, almost entirely link-isolated, **and** a meaningful share of its docs
        carry the machine-extraction fingerprint - the two conditions the triage
        decision named: "high density of files with zero inbound wiki links combined
        with machine-extracted, non-navigational content". Requiring the
        machine-extraction share keeps a folder of genuinely standalone *curated* notes
        (isolated, but written by hand with no machine links) from being mistaken for a
        raw dump.
        
        A second fingerprint, **working notes** (issue #366), catches the other tree
        that drowns the curated signal: an agent's plans, session logs or tickets,
        hundreds of pattern-named files hung off one backlog index. Each file has one
        inbound link (from the index), so it is not an orphan and the raw-source test
        never fires, yet the tree swamps the curated wiki's doc count and hub ranking.
        The tendency is the same accretion: every task leaves a note, nothing ever
        consolidates them. :func:`classify_working_notes_trees` names such trees so
        ``build_doc_graph`` excludes them the same way it excludes raw trees. One
        invariant bounds what such a tree takes out of the headline: a doc leaves only
        if it is itself a positional note, or an index whose links go into such notes.
        """
        from __future__ import annotations
        
        import re
        from collections import Counter
        from typing import Any
        
        # Conservative, precision-first thresholds. A false positive (excluding a
        # curated folder) is the costly error - it hides real navigability gaps - so the
        # bar is set high: a large, almost-entirely-isolated subtree, half of whose docs
        # carry the machine-extraction fingerprint. A false negative (a borderline raw
        # tree left counted) merely preserves today's behaviour and is recoverable via
        # `.assess/config.toml` `exclude_dirs`.
        RAW_TREE_MIN_FILES = 10  # a tree, not a couple of stray files
        RAW_TREE_ISOLATION_DENSITY = 0.9  # >= this fraction must be link-isolated
        RAW_TREE_MACHINE_DENSITY = 0.5  # >= this fraction must carry a machine link
        
        
        def _ancestor_dirs(rel: str) -> list[str]:
            """Return every ancestor directory of a posix rel path, root excluded.
        
            ``"a/b/c.md"`` -> ``["a", "a/b"]``; a root-level file ``"c.md"`` -> ``[]``
            (it has no enclosing subtree, so it can never anchor a raw-tree exclusion).
            """
            parts = rel.split("/")
            return ["/".join(parts[:i]) for i in range(1, len(parts))]
        
        
        def _is_ancestor_path(ancestor: str, path: str) -> bool:
            """True when ``path`` is ``ancestor`` itself or nested beneath it."""
            return path == ancestor or path.startswith(ancestor + "/")
        
        
        def _is_isolated(signal: dict[str, Any], rel: str, entries: frozenset[str] | set[str]) -> bool:
            """A doc with no inbound and no outbound internal links, and not an entry."""
            if rel in entries:
                return False
            return int(signal.get("in_degree", 0)) == 0 and int(signal.get("out_degree", 0)) == 0
        
        
        def classify_raw_trees(
            doc_signals: dict[str, dict],
            *,
            entries: frozenset[str] | set[str] | None = None,
            min_files: int = RAW_TREE_MIN_FILES,
            isolation_density: float = RAW_TREE_ISOLATION_DENSITY,
            machine_density: float = RAW_TREE_MACHINE_DENSITY,
        ) -> list[dict]:
            """Identify maximal raw-source subtrees from per-doc graph signals.
        
            ``doc_signals`` maps a doc's repo-relative posix path to a dict with
            ``in_degree`` / ``out_degree`` / ``machine_links``. ``entries`` is the set of
            entry-point doc paths (README / MOC / base hubs), which never count toward a
            subtree's link-isolation. Returns a list of ``{"path", "file_count",
            "docs"}`` for each *outermost* qualifying subtree, sorted by path. An empty
            list means no raw-source tree was detected (the common case - the repo is
            unaffected).
        
            Pure and IO-free: the unit the tests pin. ``build_doc_graph`` gathers the
            signals and acts on the verdict.
            """
            entries = entries or frozenset()
        
            # Group every doc under each of its ancestor directories so a subtree's
            # stats include all descendants, not just direct children.
            by_dir: dict[str, list[str]] = {}
            for rel in doc_signals:
                for d in _ancestor_dirs(rel):
                    by_dir.setdefault(d, []).append(rel)
        
            qualifying: dict[str, list[str]] = {}
            for directory, docs in by_dir.items():
                n = len(docs)
                if n < min_files:
                    continue
                isolated = sum(1 for r in docs if _is_isolated(doc_signals[r], r, entries))
                machine = sum(1 for r in docs if int(doc_signals[r].get("machine_links", 0)) > 0)
                if isolated / n >= isolation_density and machine / n >= machine_density:
                    qualifying[directory] = docs
        
            # Keep only the outermost qualifying subtrees: a qualifying child nested
            # under a qualifying parent is subsumed by the parent's exclusion.
            kept: list[str] = []
            for directory in sorted(qualifying, key=lambda x: (x.count("/"), x)):
                if any(_is_ancestor_path(anc, directory) for anc in kept):
                    continue
                kept.append(directory)
        
            return [
                {
                    "path": directory,
                    "file_count": len(qualifying[directory]),
                    "docs": sorted(qualifying[directory]),
                }
                for directory in sorted(kept)
            ]
        
        
        # Working-notes thresholds (issue #366), precision-first for the same reason as
        # the raw-tree ones: excluding a curated folder hides real navigability gaps,
        # while missing a notes tree only keeps today's figures. All three legs must
        # hold, and the tree takes out only notes and their index (see `_tree_docs`).
        #
        # A working-notes name family is a series whose names are positions, not
        # subjects: every name is a date (2026-01-31-standup) or a word and a counter
        # with nothing after it (plan_07, PROJ-123), so the name says when or which
        # entry and never what the page is about. A counter followed by a title
        # (adr-0001-use-postgres, rfc-042-streaming, step-1-install) names a subject,
        # and a dotted version (release-2.1.0, v1.2.3) is a release, not a counter; both
        # are curated, as is a shared word with no counter (how-to-deploy). The rule
        # separates shape, not intent: a numbered series under its own table of
        # contents (chapter-01 .. chapter-20) passes all three legs exactly as plan_NN
        # does and is excluded. `.assess/config.toml` `working_notes_ignore` keeps
        # such a series counted, and `working_notes_dirs` forces a tree the
        # fingerprint misses (issue #367). A cross-linked wiki
        # fails on in-degree (several inbound links per page) and on the index (no one
        # or two pages link to most of it).
        WORKING_NOTES_MIN_FILES = 20  # a pile, not a small wiki section
        WORKING_NOTES_PREFIX_SET = 3  # "a small set of prefixes": plan_/spike_/retro_ at most
        WORKING_NOTES_NAME_DENSITY = 0.8  # >= this fraction carry a sequence name in a shared family
        WORKING_NOTES_LOW_INDEGREE_DENSITY = 0.8  # >= this fraction have in-degree <= 1
        WORKING_NOTES_INDEX_FILES = 2  # "one or two index files"
        WORKING_NOTES_INDEX_SHARE = 0.6  # the top index files link to >= this fraction of the tree
        
        # 2026-01-31, 20260131, 2026_01_31 anywhere in the stem.
        _DATE_RE = re.compile(r"(?<!\d)(?:19|20)\d{2}[-_.]?(?:0[1-9]|1[0-2])[-_.]?(?:0[1-9]|[12]\d|3[01])(?!\d)")
        # plan_07, plan-07, PROJ-123: a word prefix, a separator, then an integer that
        # ends the stem. The stem is lowercased first, so a ticket key is the family of
        # its tracker (PROJ-1 and proj-2 are both ``proj``).
        _SEQUENCE_RE = re.compile(r"^([a-z]+(?:[-_ ][a-z]+)*)[-_ ]\d+$")
        
        
        def _name_key(rel: str) -> str | None:
            """The sequence family a doc's name belongs to, or None when it has none.
        
            ``plan_07.md`` and ``plan-07.md`` -> ``plan``; ``PROJ-12.md`` -> ``proj``;
            ``2026-01-31-standup.md`` -> ``<date>``; ``how-to-deploy.md``,
            ``adr-0001-use-postgres.md``, ``release-2.1.0.md`` and ``v1.2.3.md`` -> None.
            """
            stem = rel.rsplit("/", 1)[-1].rsplit(".", 1)[0]
            if _DATE_RE.search(stem):
                return "<date>"
            seq = _SEQUENCE_RE.match(stem.lower())
            return seq.group(1) if seq else None
        
        
        def _is_working_notes(docs: list[str], doc_signals: dict[str, dict]) -> bool:
            """All three legs of the working-notes fingerprint over one directory."""
            n = len(docs)
            families = Counter(k for k in map(_name_key, docs) if k is not None)
            shared = sorted((c for c in families.values() if c > 1), reverse=True)
            if sum(shared[:WORKING_NOTES_PREFIX_SET]) / n < WORKING_NOTES_NAME_DENSITY:
                return False
            low = sum(1 for r in docs if int(doc_signals[r].get("in_degree", 0)) <= 1)
            if low / n < WORKING_NOTES_LOW_INDEGREE_DENSITY:
                return False
            # Coverage, not concentration: the index files must link to most of the
            # tree. A share of whatever edges exist goes vacuous on a sparse pile (one
            # stray link would be 1/1 and hide every unlinked note).
            sources = Counter(s for r in docs for s in doc_signals[r].get("inbound_sources", ()))
            held = sum(c for _, c in sources.most_common(WORKING_NOTES_INDEX_FILES))
            return held >= WORKING_NOTES_INDEX_SHARE * n
        
        
        def _tree_docs(directory: str, docs: list[str], doc_signals: dict[str, dict],
                       trees: dict[str, list[str]]) -> list[str] | None:
            """The docs ``directory`` takes out of the headline, or None to refuse.
        
            Invariant: a doc leaves the headline only if it is itself a positional
            note, or an index whose links go into such notes. A note is a doc with a
            name family, or a member of a deeper tree already accepted (``trees``,
            decided deepest first). An index is one of the top
            ``WORKING_NOTES_INDEX_FILES`` sources of the notes' inbound links, each
            holding at least an equal part of ``WORKING_NOTES_INDEX_SHARE``; a curated
            page citing one note is a source, not an index. Any other doc stays
            counted: a subdirectory holding no note at all is left out of the tree
            whole, and any other curated doc refuses the directory, so its notes are
            decided by their own subdirectories instead."""
            nested = {r for o, t in trees.items() if _is_ancestor_path(directory, o) for r in t}
            notes = {r for r in docs if r in nested or _name_key(r) is not None}
            sources = Counter(s for r in notes for s in doc_signals[r].get("inbound_sources", ()))
            floor = sum(sources.values()) * WORKING_NOTES_INDEX_SHARE / WORKING_NOTES_INDEX_FILES
            indexes = {s for s, c in sources.most_common(WORKING_NOTES_INDEX_FILES) if c >= floor}
        
            def child(rel: str) -> str | None:
                head, sep, _ = rel[len(directory) + 1:].partition("/")
                return head if sep else None
        
            noted = {child(r) for r in notes}
            tree = []
            for r in docs:
                if r in notes or r in indexes:
                    tree.append(r)
                elif child(r) is None or child(r) in noted:
                    return None
            if len(tree) < WORKING_NOTES_MIN_FILES or not _is_working_notes(tree, doc_signals):
                return None
            return tree
        
        
        def classify_working_notes_trees(
            doc_signals: dict[str, dict], *,
            force: list[str] | tuple[str, ...] = (), ignore: list[str] | tuple[str, ...] = (),
        ) -> list[dict]:
            """Identify working-notes subtrees from per-doc graph signals.
        
            ``doc_signals`` maps a doc's repo-relative posix path to a dict with
            ``in_degree`` and ``inbound_sources`` (the paths of the docs linking or
            referring to it). A directory qualifies when it holds at least
            ``WORKING_NOTES_MIN_FILES`` docs, most named in a small set of families,
            most with in-degree <= 1, and one or two docs link to most of the tree.
            The tree is its notes and their index (``_tree_docs``): a subdirectory
            holding no note stays counted whole, and any other curated doc refuses the
            directory, leaving its deeper trees to stand alone.
        
            Two overrides from ``.assess/config.toml`` (issue #367), both lists of
            repo-relative directories: every doc under a ``force`` directory joins a
            tree at that path whatever its size or fingerprint, and no doc under an
            ``ignore`` directory joins any tree, so ``ignore`` wins where they overlap.
            Ignored docs are removed from the fingerprint's trees, not from its input,
            so ignoring a directory never qualifies its parent; a fingerprint tree
            whose remaining docs no longer pass the size and fingerprint tests is
            dropped whole, so its index returns to the headline with the ignored notes.
            A forced directory holding no doc is not reported.
        
            Returns ``{"path", "file_count", "docs"}`` per outermost tree, sorted by
            path; an outer tree absorbs the docs of any tree inside it.
            ``notes/backlog.md`` over ``notes/2025/`` and ``notes/2026/`` is one tree;
            ``docs/guide.md`` beside ``docs/notes/`` leaves ``docs/notes`` alone.
            """
            # Ignore only ever subtracts: the fingerprint runs on every doc, so removing
            # ignored docs can never tip a parent directory over a threshold. What a
            # fingerprint tree keeps must still be a working-notes tree on its own, or
            # the rest of it (an index whose notes were ignored) returns to the headline.
            kept_docs = {
                r for r in doc_signals if not any(_is_ancestor_path(d, r) for d in ignore)
            }
            trees = {}
            for d, docs in _fingerprint_trees(doc_signals).items():
                left = sorted(set(docs) & kept_docs)
                if len(left) >= WORKING_NOTES_MIN_FILES and _is_working_notes(left, doc_signals):
                    trees[d] = set(left)
            for d in force:
                trees[d] = trees.get(d, set()) | {r for r in kept_docs if _is_ancestor_path(d, r)}
            trees = {d: docs for d, docs in trees.items() if docs}
            kept = [d for d in trees if not any(o != d and _is_ancestor_path(o, d) for o in trees)]
            out = []
            for d in sorted(kept):
                docs = sorted(set().union(*(t for o, t in trees.items() if _is_ancestor_path(d, o))))
                out.append({"path": d, "file_count": len(docs), "docs": docs})
            return out
        
        
        def _fingerprint_trees(doc_signals: dict[str, dict]) -> dict[str, list[str]]:
            """The directories the fingerprint alone classifies, each mapped to the
            docs it takes out of the headline. Nested trees are all returned; the
            caller keeps the outermost."""
            by_dir: dict[str, list[str]] = {}
            for rel in doc_signals:
                for d in _ancestor_dirs(rel):
                    by_dir.setdefault(d, []).append(rel)
        
            qualifying = {
                d: docs for d, docs in by_dir.items()
                if len(docs) >= WORKING_NOTES_MIN_FILES and _is_working_notes(docs, doc_signals)
            }
            trees: dict[str, list[str]] = {}
            for d in sorted(qualifying, key=lambda x: -x.count("/")):  # deepest first
                tree = _tree_docs(d, qualifying[d], doc_signals, trees)
                if tree is not None:
                    trees[d] = tree
            return trees
        
      • README.md 60.4 KB
        # skills/assess/scripts/lib
        
        Deterministic library modules for the `/assess` engine. No LLM calls anywhere in this
        package - every function is a pure transform of filesystem, git, or pre-computed signal
        data, with one bounded exception: live GitHub reads, confined to `gh_cli.py`, which are
        optional (they need a github.com remote and an authenticated `gh`) and degrade to
        `available: False` with a reason, never to a clean result. The LLM reads
        `run-context.json` after the core finishes; it does not call into these modules.
        
        ## The assess_core.py -> lib seam
        
        `assess_core.py` is the orchestrator. It imports from almost every module here, calls
        each one in sequence, and assembles the results into `run-context.json`. When the
        orchestrator grows a new signal, reorganizes the context schema, or changes what data
        blocks it needs, the lib modules that supply that data move in the same commit. This
        is cohesion by design: the lib modules exist to serve the orchestrator's data contract,
        so their shape is coupled to it by construction.
        
        Two modules are the identified co-change hotspots in the git history:
        
        - **`doc_graph.py`** - the foundation for Layer 0 navigability. Both `doc_staleness.py`
          and the understanding analysis in `keyhole_signals.py` depend on its doc->code
          association edges. When the core adds a navigability signal or changes how it
          represents doc reachability, `doc_graph.py` is the first file that moves.
        
        - **`keyhole_signals.py`** - the integration barrier between the individual signal
          modules and the orchestrator. It assembles all five run-context blocks
          (`behaviour`, `documentation`, `understanding`, `runtime`, `structure`) and emits
          the six named derived findings. Because it touches every upstream signal, it
          co-changes with the core on almost every schema or signal-set change.
        
        Seeing these two files in the same commit as `assess_core.py` is expected, not a
        defect. If the core is later decomposed, treat this seam as the natural boundary -
        `doc_graph` and `keyhole_signals` are where the cut-line already lives.
        
        ## The wider co-change seams (cohesion, not entanglement)
        
        `/assess`'s own hotspot scan flags several directories as historically co-changing:
        `skills/assess/scripts`, `skills/assess/scripts/lib`, `skills/assess/tests`,
        `skills/assess/tests/fixtures`, and `scripts` (+ `scripts/tests`). That is the static
        import map saying "separate directories" while git says "they move together." Here the
        coupling is genuine subsystem cohesion, and naming it is the point - so a reader (or an
        agent) trusts the boundary deliberately rather than being surprised by the seam:
        
        - **`scripts/lib` modules <-> `skills/assess/tests`.** Each deterministic module is
          pinned by a test in `skills/assess/tests/`, and the contract in `CLAUDE.md` is explicit
          ("Add a test alongside any change to a deterministic module"). So a module and its test
          are *meant* to move in the same commit - the test is the module's behavioural contract,
          not a separable concern. A reviewer seeing `doc_graph.py` and `test_doc_graph.py` in one
          diff is seeing the intended unit of change.
        - **`scripts/` (standalone build) <-> `skills/`.** The standalone-skill build under
          `scripts/` vendors and transforms the very skills it packages, so a change to a skill's
          shape and the build that ships it co-change by construction. That seam is documented in
          `CLAUDE.md`'s "Standalone skill pipeline" section; it is cohesion between a packager and
          the thing it packages, not a leak.
        
        None of these is a refactor task. They are recorded here so the seam is owned: if any is
        ever cut, this note is where the intended boundary is written down.
        
        ---
        
        ## Module reference
        
        ### Data collection
        
        **`git_churn.py`**
        Shared git-churn machinery: per-file commit counts over a configurable window, plus
        `git_commit_info` for snapshotting the exact SHA and timestamp at run time. Used by the
        code heatmap, the doc-staleness heatmap, and `doc_staleness.py` - churn is computed
        one way, not three. Pure subprocess + stdlib, no heavy dependencies.
        `content_commit_clock` is the last-content-change clock (issue #333): one `git log` pass
        over the docs' history that skips bulk mechanical commits (more than
        `BULK_COMMIT_DOC_SHARE` of the docs and at least `BULK_COMMIT_MIN_DOCS` of them, such as
        a licence-header sweep), so one sweep cannot reset every doc's staleness. A doc whose
        every commit is bulk falls back to its oldest commit and is flagged (`creation_fallback`),
        since that is a creation date rather than a content age. Historical paths are mapped
        through `change_coupling.build_rename_map`, so a mass rename keeps each doc's earlier
        content dates. On a git failure the clock is marked incomplete and degrades to the plain
        newest-commit read; a shallow clone is also marked incomplete.
        
        **`change_coupling.py`**
        Three signals derived from `git log`:
        - B1 change-coupling pairs: file pairs that co-change across commits (hidden edges
          the import graph cannot see).
        - B2 containment ratio: fraction of commits to a module that touch only that module
          (high = an island safe for keyhole edits).
        - B4 authorship: human/agent/mixed/unknown classification, e-mail-based and
          deliberately conservative (never labels a human's work "agent" on weak evidence).
        
        `parse_commit_file_sets` lists each commit's files under the names they had then.
        `build_rename_map` reads `git log --name-status -M --diff-filter=R` into a `RenameMap`:
        `paths`, a historical-path to current-path map (chains resolved first: a path starts from its
        first rename and moves on only through a rename in a commit that descends from the one
        before, checked with `git merge-base --is-ancestor`, so a freed-and-refilled name is not
        chained through in sequence or across sibling branches; then any source name that exists
        again in the working tree is left out), and `complete`, False when git could not be read so an empty map is
        never mistaken for "no renames". `fold_renames` rewrites the commit sets through `paths`,
        so history made before a rename counts under the current path. `repo_top` is the shared
        `git rev-parse --show-toplevel` helper; the git-log readers take an optional `top` so a
        caller that already resolved it skips the extra subprocess. Both readers pin `-M` and
        `core.quotepath=false`, so rename detection ignores the user's `diff.renames` and
        non-ASCII paths come back literal, matching the files on disk.
        
        All results are JSON-serialisable so `assess_core` can drop them straight into
        `run-context.json`.
        
        **`generated_files.py`**
        Content checks for files that are not hand-written source but carry an ordinary name:
        `has_generated_header` sniffs the first 5 lines for a comment line carrying a generator marker (`GENERATED FILE` only when it opens the comment,
        `DO NOT EDIT`, `@generated`, `auto-generated` spaced, hyphenated or joined; matched
        case-insensitively; a marker further down is ignored), and `is_long_line_artifact` flags an
        average line length over the first 1 MB above `LONG_LINE_THRESHOLD` (1,000 characters, the shape of a base64 or
        minified payload). `generated_reason` returns `generated-header`, `long-lines` or None. The
        treemap's `collect` drops matching files unless `--include-artifacts` is passed and lists them
        in the stats file's `excluded_generated`, which `assess_core` copies into `run-context.json`
        for the report and gate to disclose. `GENERATED_NAME_PATTERNS` (`*.generated.*`, `*.gen.ts`,
        `database.types.ts`) is the shared list of generated-name globs: the treemap adds it to its filename
        excludes, and `assess_core` calls `matches_generated_name` so a file those globs newly exclude is never
        recorded as a graduated hotspot. Pure stdlib; an unreadable file is never excluded.
        
        ### Static analysis
        
        **`structure_graph.py`**
        Python import-graph analysis (Signals A1-A4) via `grimp` + `networkx`:
        - A1 comprehension footprint: direct-dependency surface area for a unit.
        - A2 blob vs modular: strongly-connected components and Newman modularity Q.
        - A3 contracts: front-door vs burrow (internals-reaching) inbound edges.
        - A4 breakup candidates: packages whose sub-modules form separable clusters.
        
        Degrades to `available: False` when `grimp`/`networkx` are absent rather than
        blocking the run.
        
        ### Document analysis
        
        **`doc_graph.py`** *(co-change hotspot)*
        Doc link-graph for Layer 0 navigability. Parses `[[wikilinks]]` and
        `[text](relative/path)` links, resolves them to real files, and builds a directed
        graph. Derives PageRank centrality, orphan rate, connectivity, MOC validation, and
        doc->code association edges. The doc->code edges are reused by `doc_staleness.py` and
        `understanding_analysis.py`, making this module a shared dependency for the document
        analysis layer. Folds in vault-native navigation edges from `vault_queries.py` (`.base`
        hubs become entry nodes; `dataview` query blocks emit edges from their declaring note),
        so a vault navigated by dynamic queries doesn't score as orphaned. `doc-graph-svg.py`
        renders this exact graph, so the two artifacts always agree. Obsidian-vault detection
        (`_vault_detected`) walks the repo subtree, pruning `EXCLUDE_DIRS`, to find a `.obsidian/`
        directory anywhere under `repo_root`, not just at the root - a vault kept as a subdirectory
        (`repo/notes/.obsidian/`) sits below the `git rev-parse --show-toplevel` scan target and was
        previously reported as no vault, silently disabling downstream vault accommodations (#179).
        Pruning `EXCLUDE_DIRS` keeps a vendored or build-artifact `.obsidian/` from tripping a false
        positive. Every doc-to-doc edge carries a `kind`: `link` for markdown links, wikilinks and
        vault query edges, `reference` for a backticked token outside a fence that resolves to an
        existing doc (path tokens via `ownership_parser._extract_path_refs`; exact paths before
        guesses: doc-relative, then a path via `ownership_parser._resolve_ref` or a bare basename
        that names exactly one walked doc). Fences are recognised behind blockquote and list-item
        markers too. References settle in a first pass, before the link
        pass: a `.claude/` doc a reference names joins the graph and is read in turn, so links
        reach it from any doc; an uncited one stays excluded. The headline `orphan_rate` and
        `reachability_pct` count both kinds; `link_only_orphan_rate` / `link_only_reachability_pct`
        report links alone over the same node set, so a doc only a reference brought in counts as
        an orphan there. A reference edge also clears the pair from `missing_xrefs` (#353).
        `directory_breakdown` lists `{path, doc_count, unreachable_count, broken_link_count}`
        per top-level directory (`path` is the first segment; root-level docs key as `.`), over
        the same curated layer as the headline: raw-source and working-notes trees are left out.
        A broken link counts toward the directory of the doc it is written in. Rows are ordered by
        unreachable count, then broken links, then doc count, and capped at
        `MAX_DIRECTORY_BREAKDOWN`; `directory_count` carries the uncapped total. Only an uncut
        list (`len(directory_breakdown) == directory_count`) sums to `doc_count`,
        `len(unreachable)` and `dangling_links`; a cut one sums to less (#365).
        
        **`raw_source.py`**
        Raw-source subtree detection (issue #225). Threshold-based, IO-free classifier:
        given per-doc graph signals (in/out degree + a machine-extraction-link count), it
        identifies maximal directory subtrees that are large, almost entirely
        link-isolated, and carry the machine-extraction fingerprint - a dump of raw,
        machine-extracted source documents (a disclosure / SAR export of converted
        `.msg`/`.pdf` files). `doc_graph.py` gathers the signals from the link graph it
        already builds and excludes the qualifying subtrees from the headline read-side
        metrics (orphan rate, reachability, broken links), reporting them separately so
        the curated-wiki signal isn't drowned. Pure - no `doc_graph` import - so
        `doc_graph.py` owns the graph and consumes this module's verdict. Co-changes with
        `doc_graph.py` (its consumer) and its test `tests/test_raw_source.py`.
        
        Its second classifier, `classify_working_notes_trees` (issue #366), finds
        working-notes trees: at least `WORKING_NOTES_MIN_FILES` docs, most named in a
        small set of sequence families (a date, or a word then an integer that ends the
        name: `plan_07`, `PROJ-123`; a counter followed by a title such as
        `adr-0001-use-postgres`, a dotted version, or a shared word alone like
        `how-to-*` is no family), most with in-degree <= 1, and one or two index docs
        linking to most of the tree (coverage of the tree, not a share of whatever edges
        exist, so one stray link into an unlinked pile does not qualify it) - an agent's
        plans or session logs hung off a backlog index. One invariant bounds the
        exclusion: a doc leaves the headline only if it is itself a positional note (a
        name family, or a member of a deeper tree already accepted) or an index whose
        links go into such notes, one of the top sources of their inbound links rather
        than a page citing one note. The tree is those docs: a subdirectory holding no
        note stays counted whole (a `docs/guides/` of curated pages beside 50 notes in
        `docs/`), and any other curated doc refuses the directory, leaving its deeper
        trees to stand alone (`docs/guide.md` beside `docs/notes/`). `notes/backlog.md`
        over `notes/2025/` and `notes/2026/` is one tree. Subdirectories are decided
        deepest first, and the tree must still pass the three legs on its own. Two
        `.assess/config.toml` keys override the verdict (issue #367), passed in as the
        `force` / `ignore` arguments: every doc under a `working_notes_dirs` directory
        joins a tree at that path whatever its size or fingerprint, and no doc under a
        `working_notes_ignore` directory joins any tree (a misclassified `chapter-01` to
        `chapter-20` series stays counted); ignore wins where they overlap, and an outer
        tree absorbs any tree inside it. Only docs are classified, never
        a `.base` hub. It runs on the headline graph (link and reference edges) after
        the raw pass. `doc_graph.py` excludes these trees too and reports
        `excluded_working_notes_trees`, `working_notes_doc_count`,
        `working_notes_orphan_rate` and `working_notes_broken_links`.
        
        **`vault_queries.py`**
        Static parser for Obsidian dynamic-navigation hubs: `.base` view files and
        `dataview` query blocks. Resolves the folder / tag / frontmatter-field
        predicates it can evaluate from the committed files alone (no running Obsidian) into the
        set of notes a hub surfaces. Pure - no filesystem walk, no `doc_graph` import - so
        `doc_graph.py` owns discovery/excludes and consumes this module's parse + selection. Add
        a fixture-backed test here alongside any predicate change.
        
        **`doc_staleness.py`**
        Doc-staleness metric for Layer 0. Associates each doc with the code it describes via
        nearest-ancestor base-doc rules, computes code churn relative to doc maintenance, and
        emits a signed ratio (high = decaying map). The association logic reuses `doc_graph`'s
        code-link edges. For *generated* docs it reads `doc_provenance` (per-doc and via the
        `[[generated]]` config map) and replaces the churn ratio with a source-vs-doc verdict.
        `last_commit_days` and the instruction grader's `freshness_days` (via `content_clock`)
        both read `git_churn.content_commit_clock`, and the skipped bulk commits are reported as
        `bulk_commits_skipped` (newest first, capped) with `bulk_commits_skipped_total`. A doc
        dated by the creation fallback carries `last_change_basis: "creation"` and confidence
        `low`, which keeps it out of the stale-hub finding; `creation_date_fallback_count` counts
        them.
        
        **`doc_provenance.py`**
        Provenance-aware staleness for generated docs (issue #178). Parses a doc's YAML
        frontmatter `source:` / `generated_by:` (no YAML dependency - a minimal stdlib parser),
        resolves the `[[generated]]` folder->source config mapping, and computes `source_newer`
        (is any declared source's last change - git commit time, else mtime - more recent than
        the doc?). `doc_staleness.py` carries that verdict and `doc_complexity_join.py` signs
        freshness from it, so an accurate generated doc is never a `lying_map`. Co-changes with
        `doc_staleness.py` (its consumer) and its test `tests/test_doc_provenance.py`.
        
        **`doc_complexity_join.py`**
        Signal C: crosses the complexity treemap against doc staleness to produce a signed
        `doc_value` per unit. Positive = the doc relieves load on the agent's context window;
        negative = the doc is a lying map over complex code (worse than no doc). The join
        multiplies complexity by a signed freshness score so trivial code generates near-zero
        signal regardless of doc state. When a doc carries a `doc_provenance` verdict, freshness
        comes from the source-vs-doc comparison (a direct, high-confidence signal that bypasses
        the churn-ratio confidence guards) instead of the ratio.
        
        ### Ownership and structure drift
        
        **`ownership_parser.py`**
        Parse half for Layer 0 structure-drift. Ownership is *declared* in two places an LLM
        contributor reads as authoritative boundaries: a GitHub `CODEOWNERS` (glob -> owner,
        honoured at the root, `.github/`, or `docs/` by GitHub's precedence) and a
        boundary-declaring `ARCHITECTURE.md` / `DESIGN.md` - or a seam-mapping `README.md`
        admitted only when its prose carries the ownership/seam vocabulary, so a generic project
        README is skipped. `parse_codeowners` resolves each glob against the git-tracked,
        non-excluded file set into `{glob_pattern: {matched_files}}`; `parse_architecture_md`
        sections each doc by markdown header and attributes the path references in a section's
        body (read from inline code, wikilinks, and bare prose paths) to the module the header
        names, keyed `<doc>::<header>` so two docs declaring the same section don't collide, and
        keeping only references that resolve to real files. `find_empty_globs` flags the CODEOWNERS
        patterns that match zero tracked files - the cheapest drift, a boundary the filesystem has
        left behind - and `parse_ownership` runs both with the module-shape degradation contract
        (`available: False`, reason `"no ownership map"` when neither map exists). It mirrors
        `doc_graph.py` deliberately: the same `EXCLUDE_DIRS` / `is_excluded_path` resolution and
        `tracked_files` filtering, the same honest-degrade-over-crash shape, every collection
        sorted at the boundary for byte-identical output. The one inversion is that it *reads*
        inline-code path spans (a path written as code is the boundary declaration we want) where
        the doc graph strips them. This is the parsing foundation the structure-drift signals
        consume; it owns no drift logic itself.
        
        **`structure_drift.py`**
        The Layer 0 structure-drift signal, built entirely on `ownership_parser`'s parse + resolve
        primitives (it re-implements no parsing). A declaration is a self-description under no
        pressure to stay true: a directory is renamed, a module's files scatter, a pattern is
        typo'd - and the map becomes a *lying map of ownership*, the same defect as a stale doc
        (behaviour) or an aged TODO (intent). **Tier 0** - `detect_path_existence_drift()` - is the
        zero-threshold cut: the enumerate-both-sides shape `doc_graph.py` uses for broken links,
        where side A is every declared boundary (CODEOWNERS globs + architecture-doc path
        references) and side B the tracked, non-excluded file set, and the finding is the
        declarations whose match set is empty. Binary, no statistics; a pattern matching only
        excluded files counts as empty (the excludes are not part of the navigable repo). It emits
        a JSON-serialisable `structure_drift` run-context block (`empty_ownership_patterns`, a
        coverage ratio, and legacy-shape `empty_globs` mirrors for the orchestrator's
        enumerate-both-sides view), degrades to `available: False` reason `"no ownership map"`, and
        sorts every list by `(pattern, declared_in)`. **Tier 1** - `detect_grouping_disagreement()` -
        is the next cut up: not "does the boundary still match any file?" but "do the files a
        boundary groups still belong together?". Three lenses each induce a grouping - the declared
        one (an owner / architecture module groups its files), the static one (an import-graph
        community groups co-dependent modules), and the historical one (files that keep co-changing).
        A grouping is reported as its **co-membership equivalence relation** over canonical file-pairs
        `(min(a, b), max(a, b))`, so disagreement is set algebra over pairs and **invariant to
        community relabeling by construction** - relabel or reorder the groups and nothing changes,
        because the relation carries pairs, never labels. The six metrics are set differences /
        intersections of the three relations (human-vs-static splits/fuses, human-vs-cochange, and
        the two agreement sets). Known-good architectural seams (the lib<->tests and standalone-build
        <->skills seams documented above) are an allowlist subtracted from the *denominator* - correct
        by construction, a seam can only suppress an owned boundary, never manufacture drift. Tier 1
        degrades to `available: False` reason `"no ownership map"` and emits its own `tier_1_available`
        field. `keyhole_signals.py` orchestrates Tier 1 (feeding it the behaviour block's already-parsed
        co-change pairs) and folds the hidden-seam direction (`human_split_but_cochange`) into the
        existing `hidden_coupling` finding - a recurring directory pair, not the version hot-file's
        repo-wide couplings; `assess_core.py` serialises the Tier 0 + Tier 1 result into the
        `structure_drift` run-context block. Co-changes with `ownership_parser.py` (its parse foundation),
        `structure_graph.py` and `change_coupling.py` (the static and historical lenses it consumes),
        `keyhole_signals.py` / `assess_core.py` (its orchestrator and serialiser), and its test
        `tests/test_structure_drift.py`.
        
        ### Signal integration
        
        **`keyhole_signals.py`** *(co-change hotspot)*
        Integration barrier between the individual signal modules and `assess_core`. Derives
        the per-directory containment view from the commit-file sets the orchestrator parses
        once and passes in, then assembles all five run-context blocks from the upstream signal
        outputs. Emits the named derived findings as a fixed-order structured array (the single
        source of order is `FINDING_ORDER`): `hidden_coupling`, `lying_map`,
        `unexplained_complexity`, `untrusted_hotspot`, `self_referential_tests`,
        `unactioned_intent`, `accretion_ratchet`, `orphaned_understanding`,
        `candidate_dead_weight`, `override_contradicts_signals` (archetype marker disagrees with
        the deterministic signals - fed the `archetype` block), and the one positive
        `refactor_boundary` last. After assembly `apply_config_excludes` drops any finding path a
        user config exclude (`exclude_dirs`/`exclude_patterns`) covers - the git-log-derived
        findings never saw the scan-level filter - and returns the dropped paths as
        `excluded_finding_paths` so `assess_core` can serialise the `excluded_by_config`
        disclosure (a suppressed finding is counted and named, never silently vanished).
        `exclude_archive_from_attention` then builds the attention list with any path under an
        `archive/`, `archived/` or `attic/` directory left out (so it never becomes a prescribed
        action) and returns those paths as `archived_finding_paths` for the `excluded_as_archive`
        disclosure; the findings themselves still name them. Rows of equal score are ordered by
        `attention_tie_break` (an `AttentionTieBreak` built from data the run already holds):
        `top_hotspots` members first in hotspot rank order, then descending severity (the highest
        `promissory_markers.top_offenders[].severity` for an `unactioned_intent` file, divided by
        the run's highest so it shares the 0-1 scale of `1 - containment_ratio` for a
        `hidden_coupling` directory; neither finding type outranks the other by scale alone),
        then path. `is_attention_low_signal` marks the list low-signal when its top score is 1
        (no row lands in two negative findings; `False` for an empty list), and `integrate` then
        caps `prescribed_actions` at the rank-1 row instead of three; the flag is serialised as the
        run-context `attention_low_signal`. Before either filter, the commit
        sets are folded through the rename map (so a renamed directory's history lands on its
        current name), and `prune_missing_finding_paths` drops any `hidden_coupling` or
        `refactor_boundary` path absent from the working tree, returning them as
        `pruned_finding_paths` for the run-context block of that name (`paths`, `count`). The
        prune stands down when the rename map is incomplete, since an unfolded old path is not
        evidence of a deletion, and `rename_map_complete` carries that state into the block so
        the report can say renames were not read. Each
        block build is wrapped in a catch-all so one signal's failure degrades that block to
        `available: False` rather than crashing the run. It also runs `structure_drift.py`'s Tier 1
        grouping disagreement (fed the behaviour block's co-change pairs so no second git-log parse
        happens) and folds its hidden-seam direction into the `hidden_coupling` finding, returning the
        Tier 1 result for `assess_core` to serialise into the `structure_drift` run-context block - so
        structure-drift findings flow through this barrier rather than being assembled in the orchestrator.
        
        **`coupling_analysis.py`**
        B3 static-vs-historical disagreement cross: compares the import-graph view
        (`structure_graph`) against the commit-history view (`change_coupling`) to surface
        hidden coupling (looks modular, bleeds historically), bleeding modules (no static graph
        available), and refactor boundaries (high containment + low external coupling, a safe
        zone for keyhole edits). Looks-coupled-but-never-co-changes is suppressed - the static
        graph already surfaces it.
        
        **`gap_actions.py`**
        Builds the run-context `gap_actions` list: Top 3 candidates for the slots
        `prescribed_actions` leaves free, read from two blocks `assess_core` already holds. Each
        entry is `{signal, action, paths}`. A `coverage_report` entry fires when no coverage
        report was found in a repo whose archetype is `software` and names up to three
        `top_hotspots` to measure, skipping `archive/`, `archived/` and `attic/` paths (via
        `keyhole_signals.is_archive_path`); it is silent on a knowledge base (test layers N/A)
        and when no hotspot remains, and when it fires it comes first.
        A `doc_graph` entry fires when `reachability_pct` is below `REACHABILITY_FLOOR` (0.5, with
        its rationale beside it) and names up to ten unreachable docs. A repo with no markdown
        reports reachability 0.0, but nothing is unreachable there, so no `doc_graph` entry fires.
        `[]` when neither fires. There is no lint complexity-rule gap: the core has no detector
        for it, and the layer scorer owns that check.
        
        **`understanding_analysis.py`**
        Signals B4 + D2. Per module: human anchor (has a confirmed human authored it?), intent
        source (is there an externalised spec/doc?), authorship class, and the velocity clock
        (days since the last comprehension event - a human-authored commit - rather than
        calendar age). Primary finding: orphaned understanding - high complexity with no human
        anchor and no externalised spec. Reuses `change_coupling.authorship_analysis` so the
        conservative agent/human classification is defined one way.
        
        ### Configuration
        
        **`assess_config.py`**
        Reads the optional per-repo `.assess/config.toml`: `exclude_dirs` / `exclude_patterns`
        (the same two lists feed every scan - heatmap, doc graph, staleness, liveness - so
        exclusion is consistent), the `[gate]` and `[structure]` sections, and the `[[generated]]`
        folder->source provenance map (issue #178) consumed by `doc_provenance.py`, and the
        `working_notes_dirs` / `working_notes_ignore` directory lists (issue #367), which
        `load_working_notes_config` returns as a typed `WorkingNotesConfig` pair for both
        `assess_core.py` and `doc-graph-svg.py`. `resolve_excludes`
        is the single shared path that combines config excludes with CLI `--exclude`; both the treemap
        CLI and `doc-graph-svg.py` call it, so every artifact computes over the identical doc/code set.
        Degrades silently on missing or malformed config rather than blocking the run.
        
        ### Output and formatting
        
        **`wiki_writer.py`**
        Renders and writes the `.assess/` wiki files (`index.md`, `log.md`,
        `hotspots/*.md`) from string templates. No LLM calls. Pure string formatting + file IO.
        An optional `run_id`/`schema_version` prepends a non-rendering HTML-comment
        provenance stamp to each file (omitted -> byte-identical legacy output).
        Also guards wiki integrity: `prune_orphan_hotspots(assess_dir, repo_root)` stamps
        any hotspot page whose source file left the tree as `retired - file deleted`
        (history preserved, no active page lies about a live file), and
        `retire_excluded_hotspots(assess_dir, paths)` stamps `retired - excluded before
        finalize` on the pages of files the core found first flagged only by a superseded,
        never-finalized run and now excluded by `.assess/config.toml`, returning the paths
        it retired and those whose page had no status token to stamp. Every retired status
        begins `retired`; `is_retired_status` is the one predicate for that, used by the
        pruner (which skips any retired page) and the orphan-invariant tests; `append_log_entry`
        chains each `log.md` entry with a `<!-- chain:<hash> -->` marker and
        `verify_log_chain(assess_dir)` returns `(valid, broken_at_entry)` so a later edit
        of a prior entry is detected and disclosed. The tool's own edits go through
        `rewrite_log_entry(assess_dir, index, new_content)`, which re-chains the entry and
        every later one (stopping at an entry that was already broken); `find_log_entry`,
        `read_log_entries` and `log_entry_is_unfinalized` (placeholder `(LLM fills in)`)
        address entries by their `assess:run_id` stamp, and
        `supersede_unfinalized_log_entry` drops a same-date, same-commit run's unfilled
        last entry before the core appends its own (`last_log_entry_is_unfinalized_run` is
        the condition it acts under, which the core also reads before writing the wiki); it and `--drop-entry` act only on an
        entry that starts with its own stamp (`log_entry_owns_span`), never on a span that
        also holds pre-chain history. Both are additive and back-compat -
        a legacy wiki (no markers, live files) is untouched and reads valid.
        
        **`treemap_render.py`**
        Shared treemap layout and SVG primitives for the code heatmap and the doc-staleness
        heatmap. Pulls `matplotlib`, `squarify`, and `numpy`. Must not be imported by the
        deterministic core (which runs with `networkx` alone) - only by the treemap scripts.
        
        **`ci_workflow.py`**
        Renders the frozen-harness GitHub Action from `templates/assess-gate.yml.template`
        using `string.Template`. Bakes in the toolchain discovered during the current run so
        the workflow is a reproducible contract, not a norm. The emitted workflow pins its
        supply chain (actions to commit SHAs, tools to exact releases) and degrades infra
        failures - toolkit fetch, tool installs, uv setup - to a skip notice so the gate's
        warn-only contract survives a flaky network or a missing tag. `paths` / `paths_ignore`
        render as lists under `on.pull_request`; `find_path_filtered_workflow` line-scans the
        repo's other workflows for a `paths:` / `paths-ignore:` key under a `pull_request`
        trigger or a `dorny/paths-filter` step,
        which is when the CLI applies `DEFAULT_PATHS_IGNORE` (`**/*.md`, `.assess/**`).
        
        **`stats_diff.py`**
        Compares current complexity stats against a prior run and classifies hotspot
        transitions: graduated (was in top list, now absent), regressed (worsened), new, and
        persistent. Pure set operations + arithmetic, no LLM.
        
        ### Scoring
        
        **`agent_instructions_grader.py`**
        Heuristic scoring of agent instruction files (CLAUDE.md, AGENTS.md, GEMINI.md,
        .cursorrules, .github/copilot-instructions.md) on signals that correlate with LLM
        usefulness: positive directives, tradeoff phrases, path references, verifiable
        outcomes, and freshness. Pure regex + arithmetic, filename-agnostic.
        
        **`liveness_scan.py`**
        Layer 1 liveness inputs, three tiers:
        - Dead-code tier: runs a language-appropriate static dead-code tool (vulture, ts-prune,
          staticcheck, etc.) to flag candidate-dead exports within the repo boundary.
          JavaScript and TypeScript share one choice, made by the dominant language of the
          in-scope files (`.ts`/`.tsx`/`.mts`/`.cts` against `.js`/`.jsx`/`.mjs`/`.cjs`; a
          scoped run counts only the scope's files); the losing
          language gets one `not_applicable` entry naming its unanalysed file count. ts-prune
          also needs a root `tsconfig.json`; without one it is recorded `not_applicable` and
          not run. A JavaScript-dominant repo with no `knip` on PATH
          records `javascript` / `knip` / `honest_degrade`, so "not analysed" never reads as
          "0 candidates".
        - Observability tier: scores three rungs - instrumented (telemetry emitted), discoverable
          (runbook present), reachable (agent has an invokable path to runtime state). The
          reachability rung decides the Layer 1 score.
        - Capability-offer tier: delegates to `jvm_capabilities.py` to detect JVM/Maven build
          systems and report, per analysis capability, whether a serving tool is already
          configured, could be run/installed in-session, or honest-degrades with a named
          candidate. Surfaced so a non-enumerated ecosystem proposes a tool rather than
          silently reading "absent". Also delegates to `dart_capabilities.py`, whose
          liveness entry lands in `dead_code.tools` as `dart` / `honest_degrade`.
        
        **`jvm_capabilities.py`**
        JVM/Maven capability-driven analysis offers (issue #113, v1 bounded). Generalises
        `/assess`'s tool mapping from a hardcoded per-language allowlist (vulture for Python,
        ts-prune for TS, staticcheck for Go) to a *capability-driven detect-or-propose* model,
        proven on one capability (liveness) in one build system (Maven). Reports each capability
        in one of four states - `served`, `offer` (with a run-or-install `consent` shape),
        `credited` (a configured pom.xml plugin already serves it), or `honest_degrade` (nothing
        serves it yet; the report names the capability and a candidate tool). A build file
        counts only when at least one `.java`, `.kt`, `.scala` or `.groovy` file exists outside
        platform-wrapper directories: an `android/` beside a `pubspec.yaml`, or beside a
        `package.json` whose `dependencies` or `devDependencies` name `react-native`,
        `@capacitor/android` or `cordova-android`, or a Cordova app's `platforms/android/`
        (beside a Cordova-namespace `config.xml` or a `cordova-android` `package.json`), at
        any depth. A Flutter plugin's own `android/` Kotlin is skipped the same way. Build files and source under
        a wrapper are both skipped, in one `os.walk` that also prunes the shared excludes, so a
        Flutter app never reads as Gradle while a real JVM service beside it still does. Imported by
        `liveness_scan.py`, never by the orchestrator - it is an inward dependency of the
        liveness tier.
        
        **`dart_capabilities.py`**
        Dart capability entries (issue #352), the detect-or-propose flow applied beyond the
        JVM. A repository is Dart when it holds a `pubspec.yaml` outside the shared and
        user-supplied excludes. Two capabilities, in the JVM entry fields (`state`,
        `candidate_tool`, `gloss`, `note`, `served_by` when credited): `linting` is
        `credited` to `dart analyze` (or `flutter analyze` when a package depends on the
        Flutter SDK) when a package's nearest `analysis_options.yaml` (its directory or the
        closest ancestor) enables lint rules through a top-level `include:` or a
        `linter: rules:` list, and `honest_degrade` naming `dart analyze` otherwise, an
        exclude-only file included; `liveness` is always
        `honest_degrade`, naming the analyzer's built-in `unused_*` diagnostics and no
        third-party package. Runs no tool. `liveness_scan.py` adds the Dart `dead_code.tools`
        entry and returns the block as `dart_capabilities`; the orchestrator publishes it as
        `run-context.json` `language_capabilities.dart`, a sibling of the JVM-only
        `capability_offers`. Imported by `liveness_scan.py`, never by the orchestrator.
        
        **`dart_complexity.py`**
        Approximate per-function cyclomatic complexity for Dart (issue #364), because lizard
        has no Dart reader. `scan_dart_functions` is a brace-and-keyword scanner: one forward
        pass that skips `//` and nesting `/* */` comments and single-, double-, triple-quoted
        and raw strings (scanning `${...}` interpolations as code), and counts `if`, `for`,
        `while`, `case`, `catch`, `&&`, `||`, `??` and a ternary `?` per function body.
        Functions are `name(...)` / `name<T>(...)` bodies (`{` or `=>`) and getters; anonymous
        closures fold into their enclosing function. `dart_function_scores` reads at most 1 MB
        of a file (the `generated_files.py` bound) and returns the per-function values and the
        worst function's name. The treemap's `collect` runs it on scc-scored `.dart` files and
        registers it in `FN_BACKENDS` as `dart-scanner` with `approximate: true`. No regex has a
        nested quantifier, so a pathological file costs linear time. Pure stdlib.
        
        **`promissory_markers.py`**
        Write-side erosion instrument: detects the four families of promissory markers
        (TODO/FIXME, deprecations, lint suppressions, disabled tests) via one rg pass per
        family, then ages each marker by *survived touches* - the number of commits to its
        file since the marker's introducing commit (batched `git blame` + one git-log
        pass). A marker that survived many edits to an actively-maintained file is
        unactioned intent; calendar age alone can't tell that from dormancy. Classifies
        markers as tracked (issue/ticket/URL/date reference, or a justified suppression)
        vs bare, and each introducing commit as agent/human (reusing `change_coupling`'s
        conservative B4 identity rules). A justified suppression (inline `-- reason` or
        trailing comment) is never stale and is counted in each family row's `justified`
        (0 outside suppressions); other tracked markers still age, since an issue or a
        deadline can go stale too. The `unactioned_intent` action states the
        `stale_touches_threshold` it applied. Honours the shared excludes and the
        generated-file filter (codegen `ignore_for_file` boilerplate is not debt), and
        degrades aging to `aging_reliable: False` on degenerate history (same verdict as
        `git_churn`).
        Feeds the `unactioned_intent` derived finding, the hotspot pages' marker-debt
        sentence, and the Layer 3/5/8 erosion rules. New ecosystem marker syntaxes need a
        fixture in `tests/test_promissory_markers.py` - absence is a silent miss.
        
        **`agent_ops.py`**
        Layer 8 workflow-maturity evidence: scans the repo-observable agent-operations
        guardrails - `.claude/settings.json` / `.claude/settings.local.json` permission
        `allow`/`deny`/`ask` counts, hook events, sandbox config; scripts under
        `.claude/hooks/`; routine definitions under `.claude/workflows/` /
        `.claude/routines/`. Summary booleans (`permissions_encoded`, `hooks_present`,
        `routines_present`) credit **git-tracked** evidence only, mirroring the Layer 0
        rule (an uncommitted settings file reaches no clone). Deliberately excludes
        `.claude/agents/` and `.claude/skills/` - those are Layer 0's evidence - so the
        two layers never double-count. Pure stdlib JSON/filesystem reads plus
        `git_churn.tracked_files`.
        
        **`gh_cli.py`**
        The one way the core reaches GitHub, shared by every scan that reads live
        platform state. Runs the `gh` binary on `PATH` as a subprocess (no direct HTTP,
        no token read, JSON parsed in Python, never `--jq`/`--template`). Order of work:
        `resolve_github_remote` (pure git; `origin`, else the sole remote; github.com
        only), then the `gh auth status` probe (`open_github`), then `gh_api(path)` /
        `gh_json(args)` calls. Every failure raises `GhUnavailable` with a reason that
        `unavailable()` turns into `{"available": False, "reason"}`: `no_remote`,
        `gh_not_installed`, `not_authenticated`, `no_access` (HTTP 403), `not_found`
        (HTTP 404), `gh_timeout`, `gh_error`, `gh_bad_json`. A scan must degrade on it,
        never report a clean result. Tests fake `gh` with a script first on `PATH`
        (`tests/test_config_drift.py`).
        
        **`config_drift.py`**
        Layer 5 lying signal: tracked GitHub configuration snapshots diffed against the
        live setting via `gh_cli`. Snapshots are ruleset exports - tracked JSON with a
        `name` or `id` and a top-level `rules` array of `type` entries, in
        `.github/rulesets/` or anywhere (matched to a live ruleset by `id`, else `name`);
        a file missing either is skipped, never reported - and classic branch-protection exports - tracked JSON under
        `.github/` with `required_status_checks`, `enforce_admins` or
        `required_pull_request_reviews` at the top level (branch from the export's `url`,
        else the file stem). The diff is snapshot-driven (keys only the API returns are
        not drift), ignores ids, timestamps and links, and folds the `{"enabled": X}` read
        shape into `X`. Lists are sets. Write-shape restriction lists (plain user, team
        and app names) compare against the read shape's objects projected onto
        `login`/`slug`/`name`. A changed scalar list is one entry: `tracked` is
        `{count, removed, sample}`, `live` is `{count, added, sample}`, with at most
        `MAX_SAMPLE` (3) names per sample, never the whole live list. Object lists pair by
        identity (`login`, `slug`, `type`, `context`, `actor_type:actor_id`, `name`; users and
        teams carry `type` as a shared discriminator, so `login`/`slug` are tried first) in both directions, and a
        one-sided item, or a snapshot key the live response omits, is recorded as
        `"present"`/`"absent"`, never as the live object, so live org configuration stays
        out of the committed wiki (the item's identity does travel in `key`). Live rulesets
        are listed with `includes_parents=false`, so a repo snapshot never pairs with an
        inherited org ruleset. Tracked JSON holding none of the snapshot keys is skipped by
        a substring probe before any parse. A missing live ruleset, an
        unprotected branch and a deleted branch are drift entries, not outages. Emits
        `config_drift: {available, entries: [{file, key, tracked, live}], dropped, snapshots}`, entries
        ranked worst first (one-sided `"absent"`, then boolean flips, then other changes) because
        the report renders only `entries[0]`, then capped at `MAX_ENTRIES` (10) with `dropped`
        counting the rest. Stored in the committed wiki: changed scalar settings, list-item
        identities in `key`, and up to three added names per changed list; never a live
        object or a whole live list;
        with no snapshots it calls nothing and reports `entries: []`. Any refused or
        failed read degrades the whole block, never a partial clean result. Add a case
        in `tests/test_config_drift.py` alongside any change to discovery or the diff.
        
        **`review_reality.py`**
        Layer 7 truth-pressure signal: whether merged pull requests were reviewed, via
        `gh_cli`. Samples the `DEFAULT_LIMIT` (30) most recently opened merged pull
        requests (`gh pr list` orders by creation, not merge) with
        `gh pr list --state merged --json author,mergedBy,mergedAt,reviews,reviewDecision,comments`
        and emits `review_reality: {available, merged_count, oldest_merged_days_ago,
        reviewed_share, approved_share, bot_review_share, self_merged_share,
        review_required, hollow_required_review, required_approval_bypassed}`.
        `reviewed_share` counts a review in any state by any account other than the
        author; `approved_share` counts only `APPROVED` ones;
        `bot_review_share` counts a comment by a bot other than `github-actions` (nothing
        in `reviews`). `gh pr list` drops a comment author's `[bot]` suffix, so an author
        object without `is_bot`/`type` is classified by one `gh api users/<login>[bot]`
        probe per distinct login (at most `MAX_LOGIN_PROBES`, 20): type `Bot` is a bot,
        404 is a person, anything else is unknown, as is a comment with no author
        login. An unknown author withholds the share (null) only when it decides a pull
        request: one with a confirmed bot comment counts regardless.
        `review_required` reads the default branch's effective rules
        (`repos/<slug>/rules/branches/<branch>`, inherited rulesets included) and then
        classic protection, each needing `required_approving_review_count` of 1 or more;
        null when neither says yes and one was refused. `hollow_required_review` is
        `review_required` and `reviewed_share` under `HOLLOW_THRESHOLD` (0.2);
        `required_approval_bypassed` is the same test on `approved_share`, so it fires
        where an AI reviewer comments on every change but nobody approves. Both are null
        when either input is unknown or under `MIN_SAMPLE` (5) merges were sampled. The
        rules are read as they stand now, so `oldest_merged_days_ago` travels with them. Counts, shares and booleans only: no title or login reaches
        the block. A failed pull-request read degrades the whole block to `available:
        False`. Tests: `tests/test_review_reality.py`.
        
        **`gate_cost.py`**
        Actions cost of the CI gate the assess-pr skill offers, so the offer can state it.
        Counts pull requests merged in the last `WINDOW_DAYS` (30) days with `gh pr list
        --state merged` via `gh_cli` (not `git log --merges`, which reads zero on a
        squash-merging repository), filtering `mergedAt` in Python because the search
        qualifier is day-granular, and multiplies by `MINUTES_PER_RUN` (5, an assumption
        from one measured run, not a measurement of the target). Emits `gate_cost_estimate:
        {available, runs_per_month, minutes_per_run, minutes_per_month, assumption, capped, private}`
        (`capped` true when the listing hit `PR_LIMIT`, so the counts are lower bounds);
        `private` comes from `gh repo view` and is `null` when that read fails. No remote,
        no `gh`, no auth, a failed read or zero merged pull requests degrade to `{available:
        false, reason}` (`no_merge_history` for the last). Only the counts are stored.
        Tests: `tests/test_gate_cost.py`.
        
        **`accretion_ratchet.py`**
        Write-side accretion instrument: detects files that only ever grow. Walks each
        file's full numstat history in author-time order (one `git log --no-merges
        --no-renames --numstat` pass, sorted explicitly by `%at` then SHA so the
        accumulation order is clone-independent) and flags a file when its running
        net-delta is non-decreasing *and* its deletion fraction (deletions over total
        churn) stays below a threshold - growth with almost no deletion pressure, the
        fingerprint of pure append-only accretion rather than ordinary maintenance. A
        multi-commit gate drops single-touch rename artifacts; binary files (numstat
        `-`) are skipped, and documentation files (`DOC_SUFFIXES`: `.md`, `.markdown`, `.mdx`,
        `.rst`, `.adoc`; not `.txt`, which covers `CMakeLists.txt` and `requirements.txt`) are never
        flagged, since an appended-to document carries no change risk. Compensates the *Accretion* contributor tendency named in the
        repo north star. Degrades to `available: False` on git failure and
        `reliable: False` on degenerate history (same verdict as `git_churn`). Reuses
        `git_churn`'s `GIT_TIMEOUT_SECONDS` and `churn_is_degenerate`; imports no
        orchestrator. Add a fixture-backed test in `tests/test_accretion_ratchet.py`
        alongside any change to the flagging rule.
        
        **`archetype.py`**
        Repository archetype detection (issue #224). Classifies a repo as `software` or
        `knowledge-base` from the code-file ratio + the absence of a runtime surface
        (`package.json`/`pyproject.toml`/`go.mod`/`Dockerfile`/...), with an
        `assess-archetype:` marker in any instruction file that **forces or suppresses**
        the verdict. A knowledge base has no code surface for the write-side layers, so
        the block names L2-L7 as `na_layers` (rendered N/A, excluded from the
        denominator) and renormalises the denominator over the applicable layers (L0,
        L1, L8 → 3). Also detects the Karpathy LLM-wiki maintenance pattern
        (`kb_maintenance` - immutable sources, schema-as-product, ingest, query-as-filing,
        periodic consolidation) as a Layer 0 read-side signal and always carries the
        gist pointer. When an `assess-archetype:` marker forces a classification the
        deterministic signals disagree with, the override still wins but the block sets
        `override_contradicts_signals` + `contradiction_details` (and records the marker's
        `override_source` file) so `keyhole_signals` can fire the visible
        `override_contradicts_signals` finding rather than swallowing the override
        silently. The IO-free `classify_archetype` is the tested core; `analyze_archetype`
        gathers the inputs. Imports `doc_graph` (extensions) and `git_churn`
        (`tracked_files`), never an orchestrator. Structured as an extensible dispatch
        (one archetype today, YAGNI on a general framework). Read by `assess_core`
        (writes the `archetype` block to `run-context.json`), the `assess-layer-scorer`
        agent (N/A scoring + denominator), and the `assess-findings` skill (renders N/A
        + renormalised headline). Co-changes with `badge.py`/`assess_finalize.py` (the
        denominator) and its test `tests/test_archetype.py`.
        
        **`badge.py`**
        Shields.io endpoint badge for the wiki (`.assess/badge.json`). The shipped badge
        is deterministic by default: `assess_core` always writes the findings-count form
        (`fallback_badge`, "2 findings · 0 stale markers", colour banded from the counts)
        and stamps a `link` to `assess-report.md`, so a badge-clicker lands on the full
        report. The LLM-derived headline (`score_badge`, "7.0/8 · AI-Native",
        renormalised over its `denominator` - 8 for software, the applicable-layer count
        for a knowledge-base archetype, e.g. "2.5/3 · Knowledge Base · Solid") is *not*
        written to the badge; it appears inside `assess-report.md`, so the badge never
        claims a grade a deterministic run cannot reproduce. Pure threshold functions,
        fixture-tested. Also exposes `maturity_band`
        (the same score/denominator fraction mapped to the named tier ladder), the
        single source of truth `assess_finalize` reconciles the LLM's `maturity_label`
        against. Both producers accept an optional `run_id` provenance stamp.
        
        **`evidence_check.py`**
        Deterministic re-check of the evidence a layer verdict cites (issue #360). The
        scorer is a model and can cite a file that is not there or a wiring that does not
        exist; `evidence_check` re-checks each cited fact with `exists()` or a literal
        substring search, no model. Input is a flat array of entries, each with `layer`,
        `kind` and the kind's arguments: `path_exists` / `path_absent` take `path`;
        `referenced_in` / `not_referenced_in` take `needle` and `path` (one file, or a
        directory searched recursively); `file_contains` takes `path` (one file) and
        `needle`. Every `path` is relative to the repository root; one that resolves
        outside it, or cannot be resolved, is rejected. The reference search reads files in
        1 MiB chunks and does not enter `.git/` or `.assess/` (the tool's own previous
        output) when walking a directory; naming `.assess/` directly still searches it.
        The reference kinds reject a `path` inside `.git/` (literally or through a
        symlink), and a symlink met in the walk that leads into `.git/` or `.assess/` is
        skipped like one out of the root, while `file_contains`, a claim
        about one named file, may read one (e.g. `.git/config`).
        A symlink out of the root, or a dangling one, is not repository content and is
        skipped; a symlinked file inside the root is read at its target. Every check fails
        closed: a `referenced_in`, `not_referenced_in` or `file_contains` claim is rejected
        as incomplete when anything it needed could not be read (an unreadable file or
        directory, a FIFO, socket or device, a symlinked directory inside the root that
        the walk did not search), since the unread part could hold the reference; a needle
        that cannot be encoded (a lone surrogate) is rejected, not raised on. `layer` is
        carried through unchecked. `check_evidence` splits the list into `evidence` (verified,
        returned as given) and `evidence_rejected` (copies carrying a `reason`); unknown
        keys pass through. The reference search is the public
        `is_referenced_in(repo_root, needle, path)`, so a check outside this module can
        reuse it. CLI, run from `skills/assess/scripts`:
        `uv run python -m lib.evidence_check <repo_root> <evidence.json> --json <out.json>`
        (exit 0 all verified, 1 any rejected, 2 an evidence file that cannot be read or is not a UTF-8 JSON array, a `repo_root` that is not a
        directory, or a `--json` file that cannot be written; a missing root would otherwise verify every `path_absent` claim). Stdlib only, imports no
        orchestrator. Add a case in `tests/test_evidence_check.py` alongside any new kind
        or change to a check rule.
        
        `assess_finalize.py` re-runs `check_evidence` on the finalize input's optional
        `evidence` list before any write (issue #362), with each `path` resolved against
        the parent of `.assess/`. The rule is per layer: a layer whose entries are all
        rejected refuses finalize (`FinalizeValidationError`, naming each entry by kind,
        path and needle); a layer with at least one verified entry keeps its verdict,
        and each rejected entry of it is printed to stderr as a warning. An input with no
        `evidence` key is not checked; a non-list value, or an entry naming no layer 0-8,
        is refused. An entry that names its layer but is otherwise malformed (unknown
        kind, missing path or needle) is rejected by `check_evidence` and counts under
        the per-layer rule like any other rejected entry.
        
        **`instruction_claims.py`**
        Verifies the checkable claims an agent instruction file makes (issues #368, #369), no
        model. `scan_instruction_claims(repo_root, files)` reads each graded instruction
        file (the keys of `instruction_files`; two keys resolving to one file are read
        once), splits prose into sentences per paragraph (fenced code skipped, a wrapped
        sentence reported at the line it starts on, a heading its own block) and extracts three kinds: `enforcement`
        (a backticked shell script, or any script under `scripts/`, `bin/`, `tools/`,
        `ci/` or `hack/`, in a sentence with "enforced", "runs in", "checked by" or "CI";
        verified when the path occurs in any CI configuration or in a task runner CI
        calls through such as `Makefile` or `package.json`; skipped when the repo has no
        CI configuration, since nothing can confirm or refute it) and `pin` ("pinned in"
        a backticked file plus exactly one dotted version in the sentence, verified when
        the file exists and contains the version as a substring) and `count` (a
        sentence of the form `<integer> <noun> ... <link> <backticked glob>`, where the
        link is a `COUNT_LINK_WORDS` word: in, under, matching, across, beneath, within,
        inside; the pattern is globbed from the repo root and matching files counted;
        verified when the difference is at most the larger of 10% or 2, a failure adding
        `claimed` and `actual`; no noun table, so a sentence with no pattern, no wildcard,
        two integers, two patterns, a year as its number, a pattern that is not
        path-shaped (`**kwargs`) or one that is absolute or holds `..` is skipped, as is
        a Windows drive or UNC path, and a claim whose wildcard-free directory is
        missing, whose glob cannot be evaluated, whose subtree cannot be read, or whose
        pattern matches only directories (or, when not recursive, mixes files and
        directories), since that is unverifiable rather than false; an integer followed
        by a size or time unit or governed by a comparator ("below 500 lines", "at most
        10") is a threshold, not a count (`COUNT_NOT_A_COUNT`, a closed list that only
        removes claims); below the fixed prefix, `doc_graph.is_excluded_path` trees such as `.assess/`
        and `node_modules/` are not counted). Each failure carries a `reason`. The
        enforcement and pin checks use
        `evidence_check.is_referenced_in`, so the search is the same fail-closed one.
        The core writes the result as the run-context block `instruction_claims`
        (`{total, verified, failed, failures[{file, line, kind, path, reason, ...}]}`, zeros when
        nothing matched); failures feed Layer 0 evidence and a Lying Signals row. A new
        claim kind is one extractor in `_EXTRACTORS` and one verifier in `_VERIFIERS`
        (which returns the extra failure fields). Tests: `tests/test_instruction_claims.py`.
        
        **`anomaly_detector.py`**
        Inspects a run-context dict for suspicious results (e.g. zero files scored, implausible
        CCN) and returns typed `Anomaly` records. Detail strings are sanitised (counts and
        grades only, no paths or code) so they are safe to include in self-feedback issues.
        
        **`coverage_report.py`**
        Parses an *existing* coverage report into the shape the `test_pressure` scan's
        `coverage_data=` param consumes (`{_overall, per_file: {relpath: line_rate}}`).
        Two formats: Cobertura `coverage.xml` (`_overall` from the root `line-rate`,
        per-file from each `<class>` element's `filename`/`line-rate`; one `iter("class")`
        walk handles both the flat and nested `<packages>` schemas) and `lcov.info`
        (per-file `LH/LF`, overall `sum(LH)/sum(LF)`; `SF:` paths, absolute or `./`-prefixed,
        are normalised to repo-relative POSIX keys so `test_focus` lookups match). `/assess` never runs the suite, so a
        report the project already generated is the only honest line-coverage source - the
        parser reads it without taking a coverage.py runtime dependency. `detect_coverage_report`
        searches the repo root, `./coverage/`, and `./.coverage/` (a `.coverage` SQLite *file*
        is out of scope - reading it needs the coverage.py lib). Honest-degrade is the hard
        contract: a missing or malformed report returns `None`, never raises, never blocks the
        assessment; `assess_core.py` records provenance ("none found" vs. the file/format read)
        separately. Stdlib only, imports no orchestrator. Add fixtures + cases in
        `tests/test_coverage_report.py` alongside any change to a parse rule.
        
        **`sibling_tests.py`**
        The one sibling-test resolver. Holds the test-file naming idioms (`<stem>_test`,
        `.test`, `.spec`, `_spec`, `test_<stem>`, `<stem>Test`, `<stem>Tests`, with a
        hyphenated stem also matched as underscores), the adjacent test directories
        (`__tests__/` / `tests/` / `test/` / `spec/`), and the is-this-a-test rule, plus a
        layered probe: `find_colocated_test` (beside the source or in an adjacent test
        directory), `sibling_test_match` (then a `tests/` / `test/` / `spec/` tree at any
        ancestor mirroring the source path, then a conventionally named test anywhere in
        the repository - a parallel tree such as `app/unit-tests/` or Dart's
        `test/unit/` - then a flat tree within two components),
        and `has_sibling_test` (the yes/no/unknown verdict, dropping a flat-only match
        on a bare name more than one hot file shares). Three consumers read it and must
        agree in one run: the hotspot page's `Has test file` row
        (`assess_core._has_sibling_test`), the E2 test-to-code map
        (`keyhole_signals._find_sibling_test`, co-location layer only, since E2 means
        co-located and co-committed), and the `test_focus` signal. The parallel-tree
        (basename) tier reads a `TestIndex` built once per run by `build_test_index`
        (`git ls-files`, or a walk pruned of `doc_graph.EXCLUDE_DIRS` outside git): a
        test belongs to the same-named source sharing the deepest common directory with
        it, a tie between sources (two `index.js` equally close) credits none, and a
        root-only common ancestor credits nothing. Tracked files deleted from disk are
        left out. A walk past 200,000 files, or one that cannot read a directory, yields
        an empty index (fail closed: the missed files may hold a rival source). The module
        docstring names two limits: an untracked parallel test is invisible to this tier
        while the path probes see untracked files, and a helper named like a test
        (`test_utils.py`) can credit a lone `utils.py`. Imports `git_churn` and `doc_graph`;
        existence checks bounded to 16 ancestor levels; never raises.
        `tests/test_sibling_tests.py` pins the three-way agreement.
        
        **`test_focus.py`**
        Cross-joins four inputs - the hotspot risk band (position in
        `complexity_stats.top_hotspots`), the parsed coverage report, the
        `test_pressure` cheap heuristics, and an optional `repo_root` - into one ranked
        focus list. `compute_test_focus` classifies each top-10 hot file
        (`no_covering_test` / `covered_but_hollow` / `covered_clean` /
        `unknown_no_coverage` / `unsupported` / `sibling_test_only`), filters out
        `covered_clean`, and ranks by risk band then signal severity (less tested ranks
        higher: `no_covering_test` > `covered_but_hollow` > `unsupported` >
        `sibling_test_only`). Test-file evidence comes from `sibling_tests.has_sibling_test`,
        so the focus table and the hotspot pages agree. With a `repo_root`, a file with a
        test file but no coverage record - no report at all, or a partial report that
        omits it - is `sibling_test_only` (test file present, coverage unmeasured; never
        a covered bucket); a file with no report and no sibling or parallel-tree test
        fil
      • review_reality.py 11.7 KB
        """Review reality: whether merged pull requests were actually reviewed.
        
        Layer 7 asks whether every change gets design-level feedback. A required-review
        rule that every merge bypasses reads as Present and is hollow. This scan samples
        the ``DEFAULT_LIMIT`` most recently opened merged pull requests through ``gh``
        (``gh pr list`` orders by creation, not merge) and reports, as
        counts and shares only, how many carried a review from someone other than the
        author, a comment from a review bot, and an author who merged their own change,
        next to whether the default branch requires an approving review.
        
        Block (``review_reality`` in run-context.json)::
        
            {"available": True, "merged_count": int,
             "oldest_merged_days_ago": int | None,  # age of the oldest sampled merge
             "reviewed_share": float | None,      # a review by an account other than the author
             "approved_share": float | None,      # an APPROVED review by an account other than the author
             "bot_review_share": float | None,    # a comment by a bot other than github-actions
             "self_merged_share": float | None,   # author and merger the same account
             "review_required": bool | None,      # ruleset pull_request rule or classic protection,
                                                  # each with required_approving_review_count >= 1
             "hollow_required_review": bool | None,  # review_required and reviewed_share < 0.2
             "required_approval_bypassed": bool | None}  # review_required and approved_share < 0.2
        
        The two flags answer different questions. ``hollow_required_review``: did anyone
        other than the author look at the change at all (an advisory bot's review
        counts). ``required_approval_bypassed``: did the approval the rule demands
        happen; it fires on a repo where an AI reviewer comments on every pull request
        while merges land without an approval. Both are null below ``MIN_SAMPLE``
        merged pull requests. The rules are read as they stand now and the sample may
        predate them, so ``oldest_merged_days_ago`` lets a reader weigh a rule added
        recently against older merges.
        
        Shares are floats in [0, 1], None when nothing was sampled. ``review_required``
        is None when the rules could not be read (a refused protection read on a branch
        no ruleset covers), and ``hollow_required_review`` follows it. ``bot_review_share``
        is None when a commenter's account type could not be learned and that decides
        whether a sampled pull request counts.
        
        Bot classification. ``gh pr list`` gives a comment author a ``login`` only, with
        a bot's ``[bot]`` suffix dropped. An author object that carries ``is_bot`` or
        ``type`` is classified from it; a login ending ``[bot]`` is a bot; any other
        login is probed once with ``gh api users/<login>[bot]`` (a GitHub App's bot
        account; ``[`` cannot appear in a person's login): an answer of type ``Bot``
        is a bot, a 404 is a person, anything else leaves it unknown, as does a
        comment with no author login. At most
        ``MAX_LOGIN_PROBES`` distinct logins are probed. ``github-actions`` comments are
        status comments and never count; neither does anything in ``reviews``.
        
        Privacy: no title, login or user name is written to the block - only counts,
        shares and booleans. A failed pull-request read degrades the whole block to
        ``{"available": False, "reason"}`` (``no_access`` on HTTP 403). GitHub access
        goes through ``gh_cli``.
        """
        from __future__ import annotations
        
        from datetime import datetime, timezone
        from pathlib import Path
        from typing import Any
        from urllib.parse import quote
        
        from lib.gh_cli import GhUnavailable, gh_api, gh_json, open_github, unavailable
        
        # Merged pull requests sampled per run.
        DEFAULT_LIMIT = 30
        
        # Under this share of reviewed merges, a required-review rule is hollow.
        HOLLOW_THRESHOLD = 0.2
        
        # Fewer merged pull requests than this are too few to call a rule bypassed:
        # both flags are null below it.
        MIN_SAMPLE = 5
        
        # Distinct unmarked comment logins probed for a bot account per run.
        MAX_LOGIN_PROBES = 20
        
        # Status-comment bot; its comments are not review.
        _STATUS_BOTS = frozenset({"github-actions", "github-actions[bot]"})
        
        # The issue's field list plus mergedAt (for the sample's age). reviewDecision is
        # fetched because the issue names it but is not scored: it reflects the current
        # rule, not whether anyone other than the author reviewed.
        PR_FIELDS = "author,mergedBy,mergedAt,reviews,reviewDecision,comments"
        
        
        def _login(actor: Any) -> str | None:
            if isinstance(actor, dict) and isinstance(actor.get("login"), str) and actor["login"]:
                return actor["login"].lower()
            return None
        
        
        def _share(count: int, total: int) -> float | None:
            return round(count / total, 3) if total else None
        
        
        class _BotClassifier:
            """Decides whether a comment author is a bot; caches one probe per login."""
        
            def __init__(self) -> None:
                self._known: dict[str, bool | None] = {}
                self._probes = 0
        
            def is_bot(self, actor: Any) -> bool | None:
                login = _login(actor)
                if login is None:
                    # No author login (a deleted account, a malformed entry): unknown.
                    return None
                if isinstance(actor.get("is_bot"), bool):
                    return actor["is_bot"]
                if isinstance(actor.get("type"), str):
                    return actor["type"] == "Bot"
                if login.endswith("[bot]"):
                    return True
                if login not in self._known:
                    self._known[login] = self._probe(login)
                return self._known[login]
        
            def _probe(self, login: str) -> bool | None:
                if self._probes >= MAX_LOGIN_PROBES:
                    return None
                self._probes += 1
                try:
                    user = gh_api(f"users/{quote(login + '[bot]', safe='')}")
                except GhUnavailable as e:
                    return False if e.reason.startswith("not_found") else None
                return isinstance(user, dict) and user.get("type") == "Bot"
        
        
        def _has_bot_comment(pr: dict, bots: _BotClassifier) -> bool | None:
            """True on a bot comment, False on none, None when an unknown author decides."""
            unknown = False
            for comment in pr.get("comments") or []:
                actor = comment.get("author") if isinstance(comment, dict) else None
                if _login(actor) in _STATUS_BOTS:
                    continue
                verdict = bots.is_bot(actor)
                if verdict:
                    return True
                if verdict is None:
                    unknown = True
            return None if unknown else False
        
        
        def _reviewed_by_other(pr: dict, approving: bool = False) -> bool:
            """A review by an account other than the author; ``approving`` counts only
            reviews in the ``APPROVED`` state."""
            author = _login(pr.get("author"))
            for review in pr.get("reviews") or []:
                if not isinstance(review, dict):
                    continue
                if approving and review.get("state") != "APPROVED":
                    continue
                reviewer = _login(review.get("author"))
                if reviewer is not None and reviewer != author:
                    return True
            return False
        
        
        def _self_merged(pr: dict) -> bool:
            author, merger = _login(pr.get("author")), _login(pr.get("mergedBy"))
            return author is not None and author == merger
        
        
        def _approvals(params: Any) -> int:
            n = params.get("required_approving_review_count") if isinstance(params, dict) else None
            return n if isinstance(n, int) else 0
        
        
        def _ruleset_requires(slug: str, ref: str) -> bool | None:
            try:
                rules = gh_api(f"repos/{slug}/rules/branches/{ref}")
            except GhUnavailable:
                return None
            if not isinstance(rules, list):
                return None
            return any(
                isinstance(r, dict) and r.get("type") == "pull_request"
                and _approvals(r.get("parameters")) >= 1
                for r in rules
            )
        
        
        def _protection_requires(slug: str, ref: str) -> bool | None:
            try:
                protection = gh_api(f"repos/{slug}/branches/{ref}/protection")
            except GhUnavailable as e:
                # "Branch not protected" is a clean no; any other failure is unknown.
                return False if "not protected" in e.reason.lower() else None
            if not isinstance(protection, dict):
                return None
            return _approvals(protection.get("required_pull_request_reviews")) >= 1
        
        
        def review_required(slug: str) -> bool | None:
            """Whether the default branch requires an approving review.
        
            True when the effective branch rules (rulesets, including inherited ones)
            carry a ``pull_request`` rule, or classic protection sets
            ``required_approving_review_count``, of 1 or more. False when both were read
            and neither does. None when neither says True and one could not be read.
            The protection read is skipped once the rules say True.
            """
            try:
                repo = gh_api(f"repos/{slug}")
            except GhUnavailable:
                return None
            branch = repo.get("default_branch") if isinstance(repo, dict) else None
            if not isinstance(branch, str) or not branch:
                return None
            ref = quote(branch, safe="")
            by_ruleset = _ruleset_requires(slug, ref)
            if by_ruleset:
                return True
            by_protection = _protection_requires(slug, ref)
            if by_protection:
                return True
            if by_ruleset is None or by_protection is None:
                return None
            return False
        
        
        def _flag(required: bool | None, share: float | None, total: int) -> bool | None:
            """True when review is required and ``share`` is under the threshold.
        
            False when review is not required; None when the requirement is unknown or
            fewer than ``MIN_SAMPLE`` merges were sampled (too few to call a bypass)."""
            if required is False:
                return False
            if required is None or share is None or total < MIN_SAMPLE:
                return None
            return share < HOLLOW_THRESHOLD
        
        
        def _days_ago(stamp: Any, now: datetime) -> int | None:
            if not isinstance(stamp, str) or not stamp:
                return None
            try:
                when = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
            except ValueError:
                return None
            if when.tzinfo is None:
                when = when.replace(tzinfo=timezone.utc)
            return max(0, (now - when).days)
        
        
        def summarize(prs: list[dict], required: bool | None,
                      bots: _BotClassifier | None = None,
                      now: datetime | None = None) -> dict[str, Any]:
            """The available block from sampled pull requests (pure except bot probes)."""
            bots = bots or _BotClassifier()
            now = now or datetime.now(timezone.utc)
            total = len(prs)
            reviewed = sum(_reviewed_by_other(pr) for pr in prs)
            approved = sum(_reviewed_by_other(pr, approving=True) for pr in prs)
            self_merged = sum(_self_merged(pr) for pr in prs)
            verdicts = [_has_bot_comment(pr, bots) for pr in prs]
            bot_share = None if None in verdicts else _share(sum(bool(v) for v in verdicts), total)
            reviewed_share = _share(reviewed, total)
            approved_share = _share(approved, total)
            ages = [d for d in (_days_ago(pr.get("mergedAt"), now) for pr in prs) if d is not None]
            return {
                "available": True,
                "merged_count": total,
                "oldest_merged_days_ago": max(ages) if ages else None,
                "reviewed_share": reviewed_share,
                "approved_share": approved_share,
                "bot_review_share": bot_share,
                "self_merged_share": _share(self_merged, total),
                "review_required": required,
                "hollow_required_review": _flag(required, reviewed_share, total),
                "required_approval_bypassed": _flag(required, approved_share, total),
            }
        
        
        def scan_review_reality(repo_root: Path, limit: int = DEFAULT_LIMIT) -> dict[str, Any]:
            """Build the ``review_reality`` run-context block (see module docstring)."""
            try:
                repo = open_github(repo_root)
                got = gh_json(["pr", "list", "--repo", repo.slug, "--state", "merged",
                               "--limit", str(limit), "--json", PR_FIELDS])
            except GhUnavailable as e:
                return unavailable(e.reason)
            if not isinstance(got, list):
                return unavailable("gh_bad_json: `gh pr list` did not return a list")
            prs = [pr for pr in got if isinstance(pr, dict)][:limit]
            return summarize(prs, review_required(repo.slug))
        
      • sibling_tests.py 15.7 KB
        """The one home for test-file naming conventions and the sibling-test probe.
        
        Three consumers ask "does this file have a test file?" and must answer the same
        way in one run: the hotspot wiki page (`assess_core._has_sibling_test`, the
        ``Has test file`` row), the E2 test-to-code map (`keyhole_signals`), and the
        test-focus signal (`test_focus`). Each used to carry its own copy of the idiom
        list, and the copies drifted: a Java ``src/FooTest.java`` credited ``Foo.java``
        on the hotspot page while the focus table called the same file ``unsupported``.
        The conventions live here once so a new idiom lands in every consumer at once.
        
        Layers, narrowest first:
        
        - :func:`sibling_test_names` / :func:`is_test_path` - pure name rules.
        - :func:`find_colocated_test` - the test beside the source or in an adjacent
          ``__tests__/`` / ``tests/`` / ``test/`` / ``spec/`` directory. E2 uses this
          layer alone: its evidence is "co-located AND co-committed", so a far-away
          mirror tree is out of its scope by definition.
        - :func:`sibling_test_match` - co-location, then a ``tests/`` / ``test/`` /
          ``spec/`` tree at any ancestor mirroring the source path (``MATCH_DIRECT``),
          then a conventionally named test anywhere in the repository that shares a
          directory with the source (``MATCH_BASENAME``: parallel trees such as
          ``app/unit-tests/`` or Dart's ``test/unit/`` that do not mirror the path),
          then a bounded flat tree holding the bare name (``MATCH_FLAT``, weaker).
        - :func:`has_sibling_test` - the yes/no/unknown verdict the hotspot page and the
          focus signal both read, with a flat-only match dropped for a bare name that
          more than one hot file shares (:func:`shared_name_keys`).
        
        The basename tier reads a :class:`TestIndex` of the repository's files, built
        once per run by :func:`build_test_index` (``git ls-files`` when the root is a
        git repository, a pruned walk otherwise; built-in excluded trees such as
        ``node_modules`` and ``tests/fixtures`` skipped). A test found this way belongs
        to the same-named source whose directory shares the deepest common ancestor
        with it; a tie across sources (two ``index.js`` equally close) credits none, and
        a common ancestor of only the repository root credits nothing. A non-git walk
        past :data:`MAX_INDEX_FILES`, or one that cannot read a directory, yields an
        empty index, so the tier credits nothing.
        
        Two stated limits of the basename tier:
        
        - Existence differs by tier. The co-located, mirrored and flat tiers probe a
          named path with ``is_file()``, so an untracked test beside the source counts.
          The basename tier needs a repository-wide listing, and in a git repository
          that listing is ``git ls-files``: it leaves out build output and untracked
          scratch, which a disk walk would index as evidence. An untracked parallel
          test is therefore invisible to it.
        - A helper module whose name matches a test convention (``test_utils.py`` in
          ``pkg/b/helpers/``) is indexed as a test with no same-named source to compete
          for it, so it credits a lone ``utils.py`` elsewhere under the same directory.
          In a single-package layout (everything under one ``src/``) that directory is
          the whole package, and the rivals count carries the precision alone. The
          credit is ``sibling_test_only``, which sends the file to mutation testing,
          where a helper that tests nothing shows up as all-surviving mutants.
        
        Inward-only: stdlib plus ``lib.git_churn`` / ``lib.doc_graph``, imports no
        orchestrator. Beyond the index, file I/O is existence checks (``is_file`` /
        ``is_dir``) bounded by :data:`MAX_ANCESTOR_LEVELS`. Never raises.
        """
        from __future__ import annotations
        
        import os
        import re
        from collections.abc import Callable, Iterable
        from dataclasses import dataclass, field
        from pathlib import Path
        
        from lib.doc_graph import EXCLUDE_DIRS, is_excluded_path
        from lib.git_churn import tracked_files
        
        # Test-file name builders keyed off a source file's stem + suffix (``.ext``
        # including the dot, or empty). A cheap precision heuristic, not a build graph.
        TEST_SIBLING_BUILDERS: tuple[Callable[[str, str], str], ...] = (
            lambda stem, ext: f"{stem}_test{ext}",    # Go, Python (pytest co-located)
            lambda stem, ext: f"{stem}.test{ext}",    # JS/TS (jest)
            lambda stem, ext: f"{stem}.spec{ext}",    # JS/TS/Angular (jasmine/jest)
            lambda stem, ext: f"{stem}_spec{ext}",    # Ruby (rspec), some JS
            lambda stem, ext: f"test_{stem}{ext}",    # Python (unittest / pytest)
            lambda stem, ext: f"{stem}Test{ext}",     # Java/Kotlin/C# (JUnit)
            lambda stem, ext: f"{stem}Tests{ext}",    # C#/Swift (XCTest, MSTest)
        )
        # Directories beside a source file that hold its tests. Probed for every
        # builder name and for the bare source name.
        ADJACENT_TEST_DIRS: tuple[str, ...] = ("__tests__", "tests", "test", "spec")
        # Directories that hold a parallel test tree at some ancestor. ``__tests__`` is a
        # JS co-location idiom, never a repo-level tree, so it is not one of them.
        TREE_TEST_DIRS: tuple[str, ...] = ("tests", "test", "spec")
        # Stem markers meaning the file IS a test.
        IS_TEST_RE = re.compile(r"(^test_|_test$|\.test$|\.spec$|_spec$|Tests?$)")
        # Bound on how many ancestor directories the tree walk visits.
        MAX_ANCESTOR_LEVELS = 16
        # A flat tree (test named by bare file name, no path mirrored) is only trusted
        # within this many components of the source directory: beside it (1) or beside
        # its top-level package directory (2: ``skills/assess/tests`` for
        # ``skills/assess/scripts/lib/``). Further up a bare-name match carries no path
        # relationship: a root ``tests/test_mod.py`` would credit every ``mod.py``.
        MAX_FLAT_BELOW = 2
        
        # Bound on the files a non-git walk indexes. A walk that reaches it yields an
        # empty index: the files it dropped may include a rival same-named source, so a
        # partial index could credit a test the complete one calls a tie. The basename
        # tier then credits nothing (``git ls-files`` is uncapped).
        MAX_INDEX_FILES = 200_000
        
        MATCH_DIRECT = "direct"  # co-located, mirrored, or the file is itself a test
        MATCH_BASENAME = "basename"  # a same-named test elsewhere, closest source wins
        MATCH_FLAT = "flat"  # only a bounded flat tree held the bare name
        
        
        @dataclass(frozen=True)
        class TestIndex:
            """The repository's files split for the basename tier: test files keyed by
            file name, other files keyed by :func:`name_key`, each value the directory
            parts of every repo-relative path carrying that name."""
        
            __test__ = False  # not a pytest class, despite the name
        
            tests_by_name: dict[str, list[tuple[str, ...]]] = field(default_factory=dict)
            sources_by_key: dict[str, list[tuple[str, ...]]] = field(default_factory=dict)
        
        
        def sibling_test_names(name: str) -> list[str]:
            """Conventional test-file names for a source file name, one per builder. A
            hyphenated stem also yields its underscore spelling, since a Python test for
            ``complexity-treemap.py`` has to be importable as
            ``test_complexity_treemap.py``."""
            p = Path(name)
            stem, ext = p.stem, p.suffix
            if not stem:
                return []
            stems = [stem] + ([stem.replace("-", "_")] if "-" in stem else [])
            return [build(s, ext) for s in stems for build in TEST_SIBLING_BUILDERS]
        
        
        def is_test_path(rel_path: str) -> bool:
            """True when the file is itself a test: its stem follows a test naming
            convention, or it lives under a ``__tests__/`` directory."""
            p = Path(rel_path)
            return bool(IS_TEST_RE.search(p.stem)) or "__tests__" in p.parts[:-1]
        
        
        def name_key(rel_path: str) -> str:
            """Bare file name with hyphens folded to underscores: two files with the
            same key resolve to the same conventional test names."""
            return Path(rel_path).name.replace("-", "_")
        
        
        def shared_name_keys(paths: Iterable[str]) -> frozenset[str]:
            """Name keys carried by more than one path: a flat-tree match on such a
            name cannot say which of them the test belongs to."""
            counts: dict[str, int] = {}
            for path in paths:
                key = name_key(path)
                counts[key] = counts.get(key, 0) + 1
            return frozenset(k for k, n in counts.items() if n > 1)
        
        
        def _repo_files(repo_root: Path) -> list[Path] | None:
            """Repo-relative file paths: the git-tracked files under ``repo_root`` when
            it is in a git repository and still on disk, else a walk pruned of
            :data:`EXCLUDE_DIRS`; ``None`` when that walk finds more than
            :data:`MAX_INDEX_FILES` files. A directory the walk cannot read raises."""
            root = repo_root.resolve()
            tracked = tracked_files(root)
            if tracked is not None:
                out: list[Path] = []
                for path in tracked:
                    try:
                        rel = path.relative_to(root)
                    except ValueError:
                        continue  # tracked, but outside the assessed root
                    if (root / rel).is_file():  # a deletion not yet staged is gone
                        out.append(rel)
                return out
            walked: list[Path] = []
            def fail_closed(error: OSError) -> None:
                raise error  # an unread subtree may hold a rival source
        
            for dirpath, dirnames, filenames in os.walk(root, onerror=fail_closed):
                dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDE_DIRS)
                rel_dir = Path(dirpath).relative_to(root)
                for name in sorted(filenames):
                    if len(walked) >= MAX_INDEX_FILES:
                        return None  # truncated: fail closed
                    walked.append(rel_dir / name)
            return walked
        
        
        def build_test_index(repo_root: Path) -> TestIndex:
            """Index the repository once for the basename tier. An empty index when the
            root or any directory under it cannot be read, or the walk passed
            :data:`MAX_INDEX_FILES`. Never raises."""
            index = TestIndex()
            try:
                files = _repo_files(Path(repo_root))
            except (OSError, ValueError):
                return index
            for rel in files or ():
                if is_excluded_path(rel):
                    continue
                dirs = rel.parts[:-1]
                if is_test_path(rel.as_posix()):
                    index.tests_by_name.setdefault(rel.name, []).append(dirs)
                else:
                    index.sources_by_key.setdefault(name_key(rel.as_posix()), []).append(dirs)
            return index
        
        
        def _common_depth(a: tuple[str, ...], b: tuple[str, ...]) -> int:
            depth = 0
            for x, y in zip(a, b):
                if x != y:
                    break
                depth += 1
            return depth
        
        
        def _basename_match(index: TestIndex, rel_path: str) -> bool:
            """True when some conventionally named test in the index belongs to this
            source: they share at least one directory, and no other same-named source
            shares as deep a common ancestor with that test."""
            src_dirs = Path(rel_path).parts[:-1]
            peers = list(index.sources_by_key.get(name_key(rel_path), []))
            if src_dirs not in peers:
                peers.append(src_dirs)  # an untracked source still competes for its test
            for name in sibling_test_names(Path(rel_path).name):
                for test_dirs in index.tests_by_name.get(name, []):
                    depth = _common_depth(src_dirs, test_dirs)
                    if depth == 0:
                        continue  # only the root in common: no path relationship
                    rivals = sum(1 for p in peers if _common_depth(p, test_dirs) >= depth)
                    if rivals == 1:  # this source alone is the closest
                        return True
            return False
        
        
        def find_colocated_test(repo_root: Path, rel_path: str) -> Path | None:
            """The co-located test file for a source path, or ``None``: a builder name
            beside the source, or a builder name or the bare source name in an adjacent
            test directory. Does not check whether ``rel_path`` is itself a test."""
            try:
                src = repo_root / rel_path
                directory = src.parent
                names = sibling_test_names(src.name)
                for name in names:
                    if (directory / name).is_file():
                        return directory / name
                for sub in ADJACENT_TEST_DIRS:
                    test_dir = directory / sub
                    if not test_dir.is_dir():
                        continue
                    for name in [*names, src.name]:
                        if (test_dir / name).is_file():
                            return test_dir / name
            except (OSError, ValueError):
                return None
            return None
        
        
        def _tree_dirs_for(rel_dir: Path) -> list[tuple[Path, bool]]:
            """Repo-relative tree directories that may hold a test for a source in
            ``rel_dir``, most local first, each paired with whether it is a flat probe.
            At each ancestor up to the root: a test tree mirroring the source's path
            below that ancestor, the same with the first component (a ``src/``-style
            root) dropped, and - within ``MAX_FLAT_BELOW`` components - the flat tree.
            The adjacent directories are left to :func:`find_colocated_test`."""
            dirs: dict[Path, bool] = {rel_dir / d: False for d in ADJACENT_TEST_DIRS}
            parts = rel_dir.parts
            for depth in range(len(parts), -1, -1)[:MAX_ANCESTOR_LEVELS]:
                ancestor = Path(*parts[:depth])
                below = parts[depth:]
                for tree in TREE_TEST_DIRS:
                    base = ancestor / tree
                    if below:
                        dirs.setdefault(base.joinpath(*below), False)
                        if len(below) > 1:
                            dirs.setdefault(base.joinpath(*below[1:]), False)
                    if len(below) <= MAX_FLAT_BELOW:
                        dirs.setdefault(base, True)
            adjacent = {rel_dir / d for d in ADJACENT_TEST_DIRS}
            return [(d, flat) for d, flat in dirs.items() if d not in adjacent]
        
        
        def sibling_test_match(
            repo_root: Path, rel_path: str, index: TestIndex | None = None,
        ) -> str | None:
            """How a conventionally named test for ``rel_path`` was found, or ``None``.
        
            ``MATCH_DIRECT``: the file is itself a test, a test is co-located, or a test
            tree at an ancestor mirrors the source path. ``MATCH_BASENAME``: a test in
            the repository index (``index``, built here when not passed) shares a
            directory with the source and no same-named source sits closer to it.
            ``MATCH_FLAT``: only a bounded flat tree holds a builder name - weaker
            evidence the caller disambiguates with :func:`shared_name_keys`. A source
            not on disk (a stale stats entry for a deleted file) is never credited.
            Never raises."""
            try:
                source = repo_root / rel_path
                if not source.is_file():
                    return None
                if is_test_path(rel_path):
                    return MATCH_DIRECT
                if find_colocated_test(repo_root, rel_path) is not None:
                    return MATCH_DIRECT
                rel_dir = Path(rel_path).parent
                if ".." in rel_dir.parts or rel_dir.is_absolute():
                    return None
                names = sibling_test_names(source.name)
                flat_hit = False
                for rel, is_flat in _tree_dirs_for(rel_dir):
                    if is_flat and flat_hit:
                        continue  # already have the weak match; only a direct one helps
                    directory = repo_root / rel
                    if not directory.is_dir():
                        continue
                    if any((directory / n).is_file() for n in names):
                        if not is_flat:
                            return MATCH_DIRECT
                        flat_hit = True
                if index is None:
                    index = build_test_index(repo_root)
                if _basename_match(index, Path(rel_path).as_posix()):
                    return MATCH_BASENAME
                return MATCH_FLAT if flat_hit else None
            except (OSError, ValueError):
                return None
        
        
        def has_sibling_test(
            repo_root: Path, rel_path: str, shared_names: frozenset[str] = frozenset(),
            index: TestIndex | None = None,
        ) -> bool | None:
            """Does this file have a test file? ``None`` when the file is not on disk
            (honestly unknown), ``True`` for a direct or basename match or a flat match
            on a name no other considered file shares, otherwise ``False``. Callers
            probing several files pass one ``index`` from :func:`build_test_index`."""
            try:
                if not (repo_root / rel_path).is_file():
                    return None
            except (OSError, ValueError):
                return None
            match = sibling_test_match(repo_root, rel_path, index)
            if match in (MATCH_DIRECT, MATCH_BASENAME):
                return True
            return match == MATCH_FLAT and name_key(rel_path) not in shared_names
        
      • stats_diff.py 3.2 KB
        """Compare current complexity stats against a prior run.
        
        Identifies hotspot transitions:
            graduated:  was in prior top_hotspots, absent from current
            regressed:  in both, but ccn or commits got worse
            new:        in current top_hotspots, absent from prior
            persistent: in both, roughly unchanged
        
        No LLM calls. Pure set operations + arithmetic.
        """
        from __future__ import annotations
        
        import json
        from dataclasses import dataclass, field
        from pathlib import Path
        
        
        @dataclass(frozen=True)
        class HotspotTransition:
            path: str
            ccn_delta: int = 0
            commits_delta: int = 0
            loc_delta: int = 0
        
        
        @dataclass
        class StatsDiff:
            graduated: list[HotspotTransition] = field(default_factory=list)
            regressed: list[HotspotTransition] = field(default_factory=list)
            new: list[HotspotTransition] = field(default_factory=list)
            persistent: list[HotspotTransition] = field(default_factory=list)
        
            def summary(self) -> dict[str, int]:
                return {
                    "graduated": len(self.graduated),
                    "regressed": len(self.regressed),
                    "new": len(self.new),
                    "persistent": len(self.persistent),
                }
        
        
        def load_stats(path: Path) -> dict | None:
            """Load stats JSON from path, or None if file doesn't exist."""
            if not path.exists():
                return None
            return json.loads(path.read_text(encoding="utf-8"))
        
        
        def hotspot_commits(h: dict) -> int:
            """Commit count for a hotspot entry.
        
            Reads `commits` (current field name), falling back to the legacy `churn`
            key so a prior snapshot written by an older plugin still compares cleanly.
            """
            val = h.get("commits")
            if val is None:
                val = h.get("churn", 0)
            return int(val or 0)
        
        
        def diff_stats(*, prior: dict | None, current: dict) -> StatsDiff:
            """Compute hotspot transitions between two stats snapshots."""
            diff = StatsDiff()
        
            current_hotspots = {h["path"]: h for h in current.get("top_hotspots", [])}
        
            if prior is None:
                diff.new = [HotspotTransition(path=p) for p in current_hotspots]
                return diff
        
            prior_hotspots = {h["path"]: h for h in prior.get("top_hotspots", [])}
        
            for path in prior_hotspots:
                if path not in current_hotspots:
                    diff.graduated.append(HotspotTransition(path=path))
        
            for path, current_h in current_hotspots.items():
                if path not in prior_hotspots:
                    diff.new.append(HotspotTransition(path=path))
                    continue
        
                prior_h = prior_hotspots[path]
                ccn_delta = current_h.get("ccn", 0) - prior_h.get("ccn", 0)
                commits_delta = hotspot_commits(current_h) - hotspot_commits(prior_h)
                loc_delta = current_h.get("loc", 0) - prior_h.get("loc", 0)
        
                transition = HotspotTransition(
                    path=path,
                    ccn_delta=ccn_delta,
                    commits_delta=commits_delta,
                    loc_delta=loc_delta,
                )
        
                # Regressed: higher cyclomatic complexity, OR grew by >50 LOC across >2 commits.
                # The compound branch is a churn proxy - a single large refactor isn't treated as regression.
                if ccn_delta > 0 or (loc_delta > 50 and commits_delta > 2):
                    diff.regressed.append(transition)
                else:
                    diff.persistent.append(transition)
        
            return diff
        
      • structure_drift.py 29.6 KB
        """Structure-drift signals: declared ownership vs where the code actually lives.
        
        Ownership is *declared* in two maps an LLM contributor reads as authoritative
        boundaries - a GitHub ``CODEOWNERS`` (glob -> owner) and a boundary-declaring
        ``ARCHITECTURE.md`` / seam ``README.md`` (prose -> "module X owns these paths").
        A declaration is a self-description under no pressure to stay true: a directory
        gets renamed, a module's files scatter, a pattern is typo'd - and nothing forces
        the map to follow. The map is then a *lying map of ownership*, the same defect as
        a stale doc (a lying map of behaviour) or an aged TODO (a lying map of intent).
        This module converts that drift into a deterministic signal so the honest action
        (fix the map, or the layout) becomes the cheap one.
        
        **Tier 0** (this module, task 9) is the cheapest, zero-threshold cut: a declared
        pattern or path that matches *zero* tracked files on disk. It is the
        enumerate-both-sides shape ``doc_graph.py`` uses for broken links - side A is the
        declared boundaries (every CODEOWNERS glob + every ARCHITECTURE.md path
        reference), side B is the tracked file set, and the finding is the declared
        patterns whose match set is empty. Binary, no statistics: a pattern matches or it
        does not. A pattern that matches only excluded files counts as empty (the
        excludes are not part of the navigable repo a contributor reasons over).
        
        The parse half is entirely ``ownership_parser`` (task 8): the same CODEOWNERS
        glob resolution, the same architecture-doc discovery and path-reference
        resolution, the same ``EXCLUDE_DIRS`` / tracked-file conventions. This module only
        joins the two sides and reports the empty set - it re-implements no parsing.
        
        Determinism is a contract: the same repo must produce byte-identical output, so
        ``empty_ownership_patterns`` (and every list) is sorted by a stable key and no
        set/dict iteration order leaks out. The module degrades to ``available: False``
        with a reason when no ownership map of either kind exists - it never crashes the
        assessment.
        
        **Tier 1** (this module, task 10) is the next cut up from Tier 0's binary
        existence test: not "does the declared boundary still match *any* file?" but
        "do the files a boundary groups together still belong together?". Three lenses
        each induce a *grouping* of the repo's files - the **declared** one (an owner /
        architecture module groups the files it claims), the **static** one (an import-
        graph community groups modules that depend on each other), and the **historical**
        one (files that keep co-changing in the same commit). Where they disagree about
        which files belong together is the signal: a declared boundary the import graph
        or the commit log has quietly split or fused.
        
        The correctness property is **label invariance**. A grouping is not its
        community *names* - relabel the communities, reorder them, swap which is "A" and
        which is "B", and nothing about which files belong together has changed. A
        partition therefore *is* its co-membership relation ``{(a, b) | same_group(a,
        b)}``; two partitions are equal iff their relations are equal. Tier 1 reports
        disagreement as set operations over canonical file-pairs ``(min(a, b), max(a,
        b))``, so the metric is invariant to any community relabeling by construction -
        the relation never carries a label to permute. This is the whole correctness
        contract of the tier, and the suite pins it directly (build communities, relabel
        them, assert identical metrics).
        
        A repo legitimately groups some directories together by design - a lib module
        and its test, a packager and the thing it packages. Those known-good seams are an
        allowlist subtracted from the *denominator* (correct by construction: a seam can
        only shrink the disagreement set, never inflate it), so owned cohesion never
        reads as drift. The tier degrades to ``available: False`` when there is no
        ownership map to ground the human grouping, or when the static / historical
        lenses are unavailable - it never crashes the assessment.
        """
        from __future__ import annotations
        
        from itertools import combinations
        from pathlib import Path
        
        from lib.ownership_parser import (
            _discover_arch_docs,
            _extract_path_refs,
            _FENCE_RE,
            _HEADER_RE,
            _resolve_ref,
            _tracked_rel_paths,
            find_empty_globs,
            parse_architecture_md,
            parse_codeowners,
        )
        from lib.git_churn import tracked_files
        
        
        def _empty_codeowners_patterns(repo_root: Path) -> list[dict]:
            """CODEOWNERS globs that match zero tracked, non-excluded files.
        
            Pure reuse of task 8: ``parse_codeowners`` resolves each glob against the
            tracked file set (a pattern matching only excluded files resolves to an empty
            set there too), and ``find_empty_globs`` flags the empties. Returns
            ``[{pattern, declared_in, owners}]`` - ``owners`` is empty (CODEOWNERS owner
            tokens are not retained by the parser) and present only so the architecture
            and CODEOWNERS rows share one shape.
            """
            codeowners = parse_codeowners(repo_root)
            return [
                {"pattern": e["pattern"], "declared_in": e["declared_in"], "owners": []}
                for e in find_empty_globs(codeowners)
            ]
        
        
        def _empty_architecture_refs(repo_root: Path) -> list[dict]:
            """Architecture-doc path references that resolve to zero tracked files.
        
            ``parse_architecture_md`` (task 8) drops references that resolve to nothing -
            it keeps a declared module's *real* files only - so the stale reference we
            want to flag is exactly the one it discards. Rather than re-implement that
            parse, we re-walk the same architecture docs (``_discover_arch_docs``) with
            the same building blocks (``_FENCE_RE`` strip, ``_HEADER_RE`` sectioning,
            ``_extract_path_refs``, ``_resolve_ref``) and keep the references whose
            resolution is empty - a declared boundary the filesystem has left behind (a
            renamed or deleted directory, a typo'd path).
        
            The reference text is the ``pattern``; ``declared_in`` is the ``<doc>::<header>``
            boundary that named it, so two docs declaring the same stale path don't merge.
            ``owners`` is empty (architecture docs carry no owner tokens). Returns one row
            per (boundary, reference); a reference repeated within a section is reported
            once.
            """
            docs = _discover_arch_docs(repo_root)
            if not docs:
                return []
        
            tracked = tracked_files(repo_root)
            rel_paths = set(_tracked_rel_paths(repo_root, tracked))
        
            out: list[dict] = []
            for doc in docs:
                try:
                    text = doc.read_text(encoding="utf-8", errors="ignore")
                except OSError:
                    # Best-effort: an unreadable doc is skipped, never aborts the scan
                    # (the same honest-degrade the parser applies).
                    continue
                doc_rel = doc.relative_to(repo_root).as_posix()
                out.extend(_empty_refs_in_doc(text, doc_rel, repo_root, rel_paths))
            return out
        
        
        def _empty_refs_in_doc(
            text: str, doc_rel: str, repo_root: Path, rel_paths: set[str],
        ) -> list[dict]:
            """Path references in one architecture doc that resolve to no tracked file.
        
            Mirrors ``ownership_parser._parse_one_arch_doc``: strip fenced code (a fence
            is a sample listing, not a boundary), section the body by markdown header, and
            attribute each reference to its section's ``<doc_rel>::<header>`` boundary.
            References before the first header belong to the doc itself
            (``<doc_rel>::<doc>``). A reference is flagged only when ``_resolve_ref``
            returns an empty set against the tracked file universe.
            """
            body = _FENCE_RE.sub("\n", text)
            current = f"{doc_rel}::{Path(doc_rel).name}"
            buffer: list[str] = []
            rows: list[dict] = []
        
            def flush(boundary: str, lines: list[str]) -> None:
                if not lines:
                    return
                segment = "\n".join(lines)
                # Sort the references so a section's empty rows emit in a stable order
                # regardless of set iteration; the caller sorts the whole list again.
                for ref in sorted(_extract_path_refs(segment)):
                    if not _resolve_ref(ref, repo_root, rel_paths):
                        rows.append({
                            "pattern": ref, "declared_in": boundary, "owners": [],
                        })
        
            for line in body.splitlines():
                m = _HEADER_RE.match(line)
                if m:
                    flush(current, buffer)
                    buffer = []
                    current = f"{doc_rel}::{m.group(2).strip()}"
                else:
                    buffer.append(line)
            flush(current, buffer)
            return rows
        
        
        def _count_declared_and_matched(repo_root: Path) -> tuple[int, int]:
            """Total declared patterns/paths and how many match at least one tracked file.
        
            Counts both sides' declarations: every CODEOWNERS glob, and every distinct
            architecture-doc path reference (per declaring boundary). ``matched`` is the
            declarations whose resolution is non-empty. Reuses the same parse/resolve path
            as the empty-set detectors so the two never disagree on what "declared" means.
            """
            codeowners = parse_codeowners(repo_root)
            declared = len(codeowners)
            matched = sum(1 for files in codeowners.values() if files)
        
            docs = _discover_arch_docs(repo_root)
            if docs:
                tracked = tracked_files(repo_root)
                rel_paths = set(_tracked_rel_paths(repo_root, tracked))
                for doc in docs:
                    try:
                        text = doc.read_text(encoding="utf-8", errors="ignore")
                    except OSError:
                        continue
                    d, m = _declared_refs_in_doc(text, repo_root, rel_paths)
                    declared += d
                    matched += m
            return declared, matched
        
        
        def _declared_refs_in_doc(
            text: str, repo_root: Path, rel_paths: set[str],
        ) -> tuple[int, int]:
            """(declared, matched) path-reference counts for one architecture doc.
        
            Same sectioning as ``_empty_refs_in_doc`` - one declaration per (boundary,
            distinct reference) - but counts resolution outcomes instead of collecting the
            empties, so totals and the empty list are derived from the identical walk. The
            boundary name is irrelevant to a count, so sectioning here only flushes the
            buffer at each header rather than tracking the current boundary key.
            """
            body = _FENCE_RE.sub("\n", text)
            buffer: list[str] = []
            declared = 0
            matched = 0
        
            def flush(lines: list[str]) -> None:
                nonlocal declared, matched
                if not lines:
                    return
                segment = "\n".join(lines)
                for ref in _extract_path_refs(segment):
                    declared += 1
                    if _resolve_ref(ref, repo_root, rel_paths):
                        matched += 1
        
            for line in body.splitlines():
                if _HEADER_RE.match(line):
                    flush(buffer)
                    buffer = []
                else:
                    buffer.append(line)
            flush(buffer)
            return declared, matched
        
        
        def detect_path_existence_drift(
            repo_root: Path, extra_exclude_dirs: set[str] | None = None,
        ) -> dict:
            """Tier 0 structure drift: declared ownership patterns that match zero files.
        
            The cheapest, zero-threshold drift cut. Enumerates both sides - side A every
            declared boundary (CODEOWNERS globs + ARCHITECTURE.md path references), side B
            the tracked, non-excluded file set - and reports the declarations whose match
            set is empty. Binary, no statistics; a pattern matching only excluded files
            counts as empty.
        
            Returns a JSON-serialisable run-context ``structure_drift`` block::
        
                {
                  available, reason,
                  tier_0_available,
                  empty_ownership_patterns: [{pattern, declared_in, owners}],
                  total_patterns, matched_patterns, coverage_ratio,
                  # legacy-shape mirrors for the orchestrator's enumerate-both-sides view:
                  empty_globs: [{pattern, file, owner}],
                  declared_paths, matched_paths,
                }
        
            Degrades to ``available: False`` with reason ``"no ownership map"`` when no
            CODEOWNERS and no boundary doc exists (no map to drift against). Every list is
            sorted by ``(pattern, declared_in)`` so the same repo yields byte-identical
            output. ``extra_exclude_dirs`` is accepted for signature parity with the other
            signals; the underlying parser applies the built-in ``EXCLUDE_DIRS`` already.
            """
            repo_root = repo_root.resolve()
        
            codeowners = parse_codeowners(repo_root)
            arch_docs = _discover_arch_docs(repo_root)
            if not codeowners and not arch_docs:
                return {
                    "available": False,
                    "reason": "no ownership map",
                    "tier_0_available": False,
                    "empty_ownership_patterns": [],
                    "total_patterns": 0,
                    "matched_patterns": 0,
                    "coverage_ratio": 0.0,
                    "empty_globs": [],
                    "declared_paths": 0,
                    "matched_paths": 0,
                }
        
            empties = _empty_codeowners_patterns(repo_root) + _empty_architecture_refs(repo_root)
            empties.sort(key=lambda e: (e["pattern"], e["declared_in"]))
        
            total, matched = _count_declared_and_matched(repo_root)
            coverage = round(matched / total, 3) if total else 0.0
        
            return {
                "available": True,
                "reason": "",
                "tier_0_available": True,
                "empty_ownership_patterns": empties,
                "total_patterns": total,
                "matched_patterns": matched,
                "coverage_ratio": coverage,
                # The orchestrator's enumerate-both-sides view (#59c) reads an
                # ``empty_globs`` list and a declared/matched count pair; mirror the
                # canonical fields into that shape so wiring stays a rename, not a reshape.
                "empty_globs": [
                    {"pattern": e["pattern"], "file": e["declared_in"],
                     "owner": (e["owners"][0] if e["owners"] else "")}
                    for e in empties
                ],
                "declared_paths": total,
                "matched_paths": matched,
            }
        
        
        # ===========================================================================
        # Tier 1 - equivalence-relation grouping disagreement
        # ===========================================================================
        #
        # A grouping of files is reported as its co-membership relation: the set of
        # canonical pairs ``(min(a, b), max(a, b))`` whose two files share a group. This
        # is the label-invariant representation - relabel or reorder the groups and the
        # pair set is unchanged - so all disagreement is set algebra over pairs and never
        # touches a community name.
        
        Pair = tuple[Path, Path]
        
        
        def _canonical_pair(a: Path, b: Path) -> Pair:
            """Order a file pair so ``(a, b)`` and ``(b, a)`` collapse to one key.
        
            Canonicalising on the POSIX path string makes the pair the same object
            regardless of which side a producer happened to list first - the property
            that lets the three relations be compared as plain sets.
            """
            return (a, b) if a.as_posix() <= b.as_posix() else (b, a)
        
        
        def _pairs_within(files: set[Path]) -> set[Pair]:
            """Every canonical same-group pair among a set of files.
        
            A group of *n* files contributes the ``n*(n-1)/2`` unordered pairs that
            declare those files co-grouped. A singleton group contributes nothing (no
            pair to disagree about).
            """
            return {_canonical_pair(a, b) for a, b in combinations(sorted(files), 2)}
        
        
        def human_grouping_relation(ownership_map: dict[str, set[Path]]) -> set[Pair]:
            """The declared grouping as its co-membership relation over file pairs.
        
            ``ownership_map`` is the ``{declared_boundary: {file_paths}}`` shape both
            ``parse_codeowners`` and ``parse_architecture_md`` produce: an owner or an
            architecture module mapped to the tracked files it claims. Each boundary
            asserts its files belong together, so it contributes every same-group pair;
            the relation is the union across boundaries. The result is label-invariant -
            it carries the *pairs*, never the boundary keys - so two ownership maps that
            group the same files identically yield the same relation even if every
            boundary were renamed.
            """
            relation: set[Pair] = set()
            for files in ownership_map.values():
                relation |= _pairs_within(files)
            return relation
        
        
        def static_grouping_relation(
            repo_root: Path, communities: list[set[str]],
        ) -> set[Pair]:
            """The import-graph community grouping as a relation over file pairs.
        
            ``communities`` is ``structure_graph._detect_communities()``'s output: a list
            of sets of *dotted module names* (e.g. ``{"lib.doc_graph", "lib.git_churn"}``).
            Each community asserts its modules belong together; we resolve every module
            name to its repo-relative source file (via the same package-root resolution
            ``structure_graph`` uses) and contribute the same-group pairs over the files.
            A module that resolves to no tracked file is dropped, so a community of one
            resolvable module contributes nothing. Label-invariant by construction: the
            relation never records which community a pair came from.
            """
            module_to_path = _build_module_path_map(repo_root)
            if not module_to_path:
                return set()
            relation: set[Pair] = set()
            for community in communities:
                files = {
                    module_to_path[m] for m in community if m in module_to_path
                }
                relation |= _pairs_within(files)
            return relation
        
        
        def _build_module_path_map(repo_root: Path) -> dict[str, Path]:
            """Map every importable dotted module name to its repo-relative source file.
        
            Reuses ``structure_graph``'s package discovery and module->file resolution so
            a community's dotted names map back to the exact tracked paths the human and
            co-change relations are expressed in. Degrades to an empty map when grimp /
            networkx is unavailable or no package is found - the caller then yields an
            empty static relation rather than crashing.
            """
            try:
                from lib.structure_graph import (
                    _build_grimp_graph,
                    _module_file,
                    discover_packages,
                )
            except ImportError:  # pragma: no cover - exercised only on a broken env
                return {}
        
            repo_root = repo_root.resolve()
            package_dirs = discover_packages(repo_root)
            if not package_dirs:
                return {}
            try:
                import_graph, _names, roots = _build_grimp_graph(package_dirs)
            except Exception:  # pragma: no cover - grimp parse failure on odd trees
                return {}
        
            mapping: dict[str, Path] = {}
            for module in import_graph.modules:
                src = _module_file(module, roots)
                if src is None:
                    continue
                try:
                    rel = src.resolve().relative_to(repo_root)
                except ValueError:
                    continue
                mapping[module] = rel
            return mapping
        
        
        def cochange_grouping_relation(
            coupling_pairs: list[dict], threshold_pct: float = 5.0,
        ) -> set[Pair]:
            """The historical co-change grouping as a relation over file pairs.
        
            ``coupling_pairs`` is ``change_coupling.change_coupling_pairs()``'s output:
            ``[{file_a, file_b, co_change_count, support_pct}]``. Unlike the human and
            static groupings (which assert transitive membership - everything in a group
            is co-grouped), co-change is *already* a pairwise relation: a pair is grouped
            iff it co-changed in at least ``threshold_pct`` percent of commits in the
            window. So no transitive closure is taken - each surviving pair maps straight
            to a canonical relation member. The threshold filters incidental single-commit
            coincidences from genuine coupling.
            """
            relation: set[Pair] = set()
            for entry in coupling_pairs:
                if float(entry.get("support_pct", 0.0)) < threshold_pct:
                    continue
                relation.add(
                    _canonical_pair(Path(entry["file_a"]), Path(entry["file_b"]))
                )
            return relation
        
        
        def compute_grouping_disagreement(
            human_rel: set[Pair], static_rel: set[Pair], cochange_rel: set[Pair],
        ) -> dict:
            """Six set-operation metrics over the three grouping relations.
        
            Each metric is a set difference or intersection of two relations - pure pair
            algebra, so every value is invariant to how any lens labelled its groups:
        
              - ``human_grouped_static_splits`` (human - static): the declared boundary
                groups these files but the import graph splits them into different
                communities - a boundary the dependency structure no longer backs.
              - ``human_split_static_fuses`` (static - human): the import graph groups
                them but no declared boundary does - cohesion the ownership map misses.
              - ``human_grouped_never_cochange`` (human - cochange): declared together but
                the commit log never couples them above threshold - a boundary history
                does not exercise as a unit.
              - ``human_split_but_cochange`` (cochange - human): they keep co-changing but
                no declared boundary groups them - a hidden seam the map omits.
              - ``human_static_agree`` (human & static): declared and dependency lenses
                agree these belong together.
              - ``human_cochange_agree`` (human & cochange): declared and historical
                lenses agree.
        
            Every pair list is serialised as ``[{file_a, file_b}]`` sorted by
            ``(file_a, file_b)`` so the same relations always yield byte-identical output.
            Counts accompany each list so a caller (the orchestrator) can read magnitudes
            without re-counting.
            """
            sets = {
                "human_grouped_static_splits": human_rel - static_rel,
                "human_split_static_fuses": static_rel - human_rel,
                "human_grouped_never_cochange": human_rel - cochange_rel,
                "human_split_but_cochange": cochange_rel - human_rel,
                "human_static_agree": human_rel & static_rel,
                "human_cochange_agree": human_rel & cochange_rel,
            }
            out: dict = {}
            for name, pairs in sets.items():
                out[name] = _serialize_pairs(pairs)
                out[f"{name}_count"] = len(pairs)
            return out
        
        
        def _serialize_pairs(pairs: set[Pair]) -> list[dict]:
            """Pairs as a sorted ``[{file_a, file_b}]`` list (no set order leaks out)."""
            rows = [
                {"file_a": a.as_posix(), "file_b": b.as_posix()} for a, b in pairs
            ]
            rows.sort(key=lambda r: (r["file_a"], r["file_b"]))
            return rows
        
        
        # Directory-prefix seam pairs whose two trees move together *by design* in this
        # repo - owned cohesion, not entanglement. A canonical file-pair is on the
        # allowlist when one file sits under the first prefix and the other under the
        # second (in either order). These are the two seams the lib README's co-change
        # seam map documents: each deterministic ``lib/`` module is pinned by a test in
        # ``skills/assess/tests`` ("Add a test alongside any change to a deterministic
        # module"), and the standalone build under ``scripts`` vendors and transforms the
        # very ``skills`` it packages. Allowlisting them subtracts the pair from the
        # disagreement *denominator* so the intended boundary never reads as drift.
        SEAM_ALLOWLIST: tuple[tuple[str, str], ...] = (
            ("skills/assess/scripts/lib", "skills/assess/tests"),
            ("scripts", "skills"),
        )
        
        
        def _under(path_str: str, prefix: str) -> bool:
            """True if a POSIX path string is the prefix dir or sits beneath it."""
            return path_str == prefix or path_str.startswith(prefix + "/")
        
        
        def _pair_on_seam(pair_row: dict, seam: tuple[str, str]) -> bool:
            """True if a serialised pair straddles a seam's two directory prefixes."""
            lo, hi = seam
            a, b = pair_row["file_a"], pair_row["file_b"]
            return (_under(a, lo) and _under(b, hi)) or (_under(a, hi) and _under(b, lo))
        
        
        def apply_seam_allowlist(
            disagreement: dict, allowlist: tuple[tuple[str, str], ...] = SEAM_ALLOWLIST,
        ) -> dict:
            """Drop allowlisted known-good seams from every disagreement pair list.
        
            A seam on the allowlist names two directory trees that co-change by design.
            Subtracting its pairs from the *denominator* is correct by construction - the
            operation can only remove a pair, never add one, so an allowlist can never
            manufacture drift, only suppress an owned boundary that would otherwise read
            as one. Returns a new dict with each ``*_count`` recomputed to match the
            filtered list; the ``agree`` lists are filtered too so a seam pair is not
            double-counted as both agreement and (suppressed) disagreement.
            """
            out: dict = {}
            for key, value in disagreement.items():
                if key.endswith("_count"):
                    continue  # recomputed from the filtered list below
                kept = [
                    row for row in value
                    if not any(_pair_on_seam(row, seam) for seam in allowlist)
                ]
                out[key] = kept
                out[f"{key}_count"] = len(kept)
            return out
        
        
        def detect_grouping_disagreement(
            repo_root: Path,
            communities: list[set[str]] | None = None,
            coupling_pairs: list[dict] | None = None,
            cochange_threshold_pct: float = 5.0,
            allowlist: tuple[tuple[str, str], ...] = SEAM_ALLOWLIST,
        ) -> dict:
            """Tier 1 structure drift: where the three grouping lenses disagree.
        
            Builds the declared grouping from the repo's ownership map (CODEOWNERS +
            architecture docs), the static grouping from the import-graph communities,
            and the historical grouping from the co-change pairs; reports their pairwise
            disagreement as label-invariant set operations, then subtracts the known-good
            architectural seams.
        
            The static and historical inputs are accepted as arguments so the
            orchestrator (task 11) can pass the communities and coupling pairs it already
            computes for the A2/B1 signals rather than re-running grimp and ``git log``.
            When omitted they are computed here so the function is usable standalone; a
            lens that cannot be computed (no Python packages, no git history) yields an
            empty relation and simply contributes no disagreement.
        
            Returns a JSON-serialisable dict mirroring Tier 0's degradation contract::
        
                {
                  available, reason, tier_1_available,
                  human_grouped_static_splits: [{file_a, file_b}], ..._count,
                  human_split_static_fuses, human_grouped_never_cochange,
                  human_split_but_cochange, human_static_agree, human_cochange_agree,
                  (each with a sibling ``*_count``),
                }
        
            Degrades to ``available: False`` reason ``"no ownership map"`` when neither a
            CODEOWNERS nor a boundary doc exists - with no declared grouping to ground
            against, there is nothing to disagree with. Every list is sorted so the same
            repo yields byte-identical output.
            """
            repo_root = repo_root.resolve()
        
            ownership_map = _human_ownership_map(repo_root)
            if not ownership_map:
                return _tier1_unavailable("no ownership map")
        
            human_rel = human_grouping_relation(ownership_map)
        
            if communities is None:
                communities = _compute_communities(repo_root)
            static_rel = static_grouping_relation(repo_root, communities)
        
            if coupling_pairs is None:
                coupling_pairs = _compute_coupling_pairs(repo_root)
            cochange_rel = cochange_grouping_relation(
                coupling_pairs, cochange_threshold_pct,
            )
        
            disagreement = compute_grouping_disagreement(
                human_rel, static_rel, cochange_rel,
            )
            filtered = apply_seam_allowlist(disagreement, allowlist)
        
            return {
                "available": True,
                "reason": "",
                "tier_1_available": True,
                **filtered,
            }
        
        
        def _tier1_unavailable(reason: str) -> dict:
            """The Tier 1 degraded block: every pair list empty, every count zero."""
            names = (
                "human_grouped_static_splits",
                "human_split_static_fuses",
                "human_grouped_never_cochange",
                "human_split_but_cochange",
                "human_static_agree",
                "human_cochange_agree",
            )
            block: dict = {
                "available": False,
                "reason": reason,
                "tier_1_available": False,
            }
            for name in names:
                block[name] = []
                block[f"{name}_count"] = 0
            return block
        
        
        def _human_ownership_map(repo_root: Path) -> dict[str, set[Path]]:
            """The combined declared grouping (CODEOWNERS + architecture docs).
        
            Unions the two parser outputs into one ``{boundary: {files}}`` map. A glob
            that matches nothing contributes an empty file set (so it adds no pair); the
            Tier 0 signal is the place that flags those empties.
            """
            combined: dict[str, set[Path]] = {}
            for pattern, files in parse_codeowners(repo_root).items():
                combined.setdefault(f"CODEOWNERS::{pattern}", set()).update(files)
            for module, files in parse_architecture_md(repo_root).items():
                combined.setdefault(module, set()).update(files)
            return {k: v for k, v in combined.items() if v}
        
        
        def _compute_communities(repo_root: Path) -> list[set[str]]:
            """Import-graph communities for the repo, or ``[]`` when unavailable.
        
            Builds the same grimp digraph ``structure_graph.analyze_structure`` builds and
            runs its community detection. Returns ``[]`` (no static lens) when grimp /
            networkx is missing or no Python package is found, so the caller's static
            relation is simply empty.
            """
            try:
                from lib.structure_graph import (
                    _build_grimp_graph,
                    _detect_communities,
                    _NETWORKX_AVAILABLE,
                    discover_packages,
                    nx,
                )
            except ImportError:  # pragma: no cover - exercised only on a broken env
                return []
            if not _NETWORKX_AVAILABLE:
                return []
        
            package_dirs = discover_packages(repo_root)
            if not package_dirs:
                return []
            try:
                import_graph, _names, _roots = _build_grimp_graph(package_dirs)
            except Exception:  # pragma: no cover - grimp parse failure on odd trees
                return []
        
            modules = sorted(import_graph.modules)
            graph = nx.DiGraph()
            graph.add_nodes_from(modules)
            for m in modules:
                for dep in import_graph.find_modules_directly_imported_by(m):
                    if dep in graph:
                        graph.add_edge(m, dep)
            return _detect_communities(graph.to_undirected())
        
        
        def _compute_coupling_pairs(repo_root: Path) -> list[dict]:
            """Co-change pairs for the repo, or ``[]`` when there is no git history."""
            try:
                from lib.change_coupling import (
                    change_coupling_pairs,
                    parse_commit_file_sets,
                )
            except ImportError:  # pragma: no cover - exercised only on a broken env
                return []
            return change_coupling_pairs(parse_commit_file_sets(repo_root))
        
      • structure_graph.py 20.6 KB
        """Static dependency-structure analysis for the keyhole-readiness signals.
        
        The keyhole is the binding constraint once a codebase outgrows a single
        context window: every actor -- the agent's window and the human reviewing a
        diff -- sees a narrow slice by construction. A change is safe only when the
        *unit being changed plus the contracts at its boundary* fit inside that slice.
        This module measures the static, language-specific (Python-first) half of that
        question off the import graph:
        
          - **A1 comprehension footprint** -> for a unit X, ``size(X) +
            public_surface(direct deps of X) + surface X exposes to dependents``.
            DIRECT dependencies only -- transitive closure would explode the metric
            for anything depending on common utilities and flag the whole repo. A unit
            whose footprint exceeds the **keyhole budget** is one no agent can change
            completely from inside the window.
          - **A2 blob vs modular** -> strongly-connected components (a cycle of length
            > 1 is a definitional blob) plus a Newman modularity score *Q*. High Q =
            cohesive clusters with sparse cross-talk; low / negative Q = either a blob
            (everything coupled) or confetti (a hundred tiny packages cross-talking).
          - **A3 contracts** -> the fraction of cross-package inbound edges that land
            on a package's *front door* (its ``__init__`` / public API) versus
            **burrow** into internals. Deep-reaching imports mean there is no real
            contract and refactors leak.
          - **A4 breakup candidates** -> a package whose internal modules fall into
            well-separated sub-clusters is several packages wearing one coat; the
            sub-clusters *are* the proposed cut-lines.
        
        Core dependencies are ``grimp`` (Python import-graph) and ``networkx``
        (community detection / SCCs). Mirroring ``doc_graph``, the module degrades to
        an ``available=False`` result rather than crashing when either is missing --
        the assessment never blocks. The analysis is purely static (AST-level import
        parsing via grimp; no code execution) and deterministic, so it is reproducible
        run to run.
        """
        from __future__ import annotations
        
        import ast
        import sys
        from contextlib import contextmanager
        from dataclasses import dataclass, field
        from pathlib import Path
        
        try:  # grimp + networkx are the core deps; degrade rather than crash if absent.
            import grimp
        
            _GRIMP_AVAILABLE = True
        except ImportError:  # pragma: no cover - exercised only on a broken env
            grimp = None  # type: ignore[assignment]
            _GRIMP_AVAILABLE = False
        
        try:
            import networkx as nx
        
            _NETWORKX_AVAILABLE = True
        except ImportError:  # pragma: no cover - exercised only on a broken env
            nx = None  # type: ignore[assignment]
            _NETWORKX_AVAILABLE = False
        
        from lib.assess_config import DEFAULT_KEYHOLE_BUDGET
        
        # Directories never worth walking for packages. Mirrors doc_graph's EXCLUDE_DIRS
        # (kept local so this module pulls in no heavy deps) -- build artefacts, vendor
        # trees, virtualenvs and /assess's own output are not the repo's source.
        EXCLUDE_DIRS = {
            ".git", "node_modules", "dist", "build", "target", "vendor",
            ".venv", "venv", "__pycache__", ".gradle", ".idea", ".mvn",
            "worktree", ".understand-anything", ".obsidian", ".taskmaster",
            ".claude", ".next", ".nuxt", ".output", ".svelte-kit", ".astro",
            "out", "coverage", "htmlcov", "Pods", "DerivedData", "flutter_assets",
            ".assess", "tests", "test",
        }
        
        # Below this many internal modules a package is too small to be worth proposing
        # a split for -- two or three modules are a unit, not a hidden multi-package.
        MIN_PACKAGE_MODULES_FOR_BREAKUP = 4
        
        # A package's internal community split only counts as a real seam (a breakup
        # candidate) when the sub-clusters are this well separated. Below it the
        # package is cohesive and the "clusters" are an artefact of sparse edges.
        BREAKUP_MODULARITY_THRESHOLD = 0.25
        
        # networkx's greedy_modularity_communities is good for small graphs; louvain
        # scales better. Switch over at this node count (matches the PRD guidance).
        GREEDY_MAX_NODES = 500
        
        # Caps so a pathological repo can't bloat run-context.json.
        MAX_FOOTPRINTS = 200
        MAX_BURROW_EDGES = 100
        MAX_SCCS = 50
        
        
        @dataclass
        class StructureGraphResult:
            available: bool = True
            reason: str = ""
            keyhole_budget: int = DEFAULT_KEYHOLE_BUDGET
            # A1: [{module, size, dep_surface, exposed_surface, total, over_budget}]
            footprints: list[dict] = field(default_factory=list)
            # A2: strongly-connected components of length > 1 (import cycles = blobs).
            sccs: list[list[str]] = field(default_factory=list)
            # A2: Newman modularity Q in [-0.5, 1] over the module graph.
            modularity_q: float = 0.0
            # A3: fraction of cross-package inbound edges landing on a front door.
            front_door_ratio: float = 1.0
            # A3: the burrowing edges (imports that reach into another package's
            # internals instead of its public API). [{importer, imported}].
            internal_burrow_edges: list[dict] = field(default_factory=list)
            # A4: [{package, clusters: [[mod, ...], ...], num_clusters, modularity_q}].
            breakup_candidates: list[dict] = field(default_factory=list)
            module_count: int = 0
            edge_count: int = 0
        
            def as_dict(self) -> dict:
                return {
                    "available": self.available,
                    "reason": self.reason,
                    "keyhole_budget": self.keyhole_budget,
                    "footprints": self.footprints,
                    "sccs": self.sccs,
                    "modularity_q": round(self.modularity_q, 4),
                    "front_door_ratio": round(self.front_door_ratio, 4),
                    "internal_burrow_edges": self.internal_burrow_edges,
                    "breakup_candidates": self.breakup_candidates,
                    "module_count": self.module_count,
                    "edge_count": self.edge_count,
                }
        
        
        # --------------------------------------------------------------------------
        # Package discovery + grimp graph construction
        # --------------------------------------------------------------------------
        
        def _is_excluded(path: Path, repo_root: Path, extra: set[str]) -> bool:
            try:
                rel = path.relative_to(repo_root)
            except ValueError:
                return True
            return any(part in EXCLUDE_DIRS or part in extra for part in rel.parts)
        
        
        def discover_packages(
            repo_root: Path, extra_exclude_dirs: set[str] | None = None,
        ) -> list[Path]:
            """Return the top-level importable package directories under repo_root.
        
            A package directory contains an ``__init__.py``. We keep only *top-level*
            packages -- a directory whose parent is not itself a package -- because
            grimp is given the package root and walks down from there. ``repo_root``
            itself counts if it is a package (the integration case: pointing the
            analysis straight at ``scripts/lib``).
            """
            repo_root = repo_root.resolve()
            extra = extra_exclude_dirs or set()
            init_dirs: set[Path] = set()
            # repo_root itself may be a package.
            if (repo_root / "__init__.py").is_file():
                init_dirs.add(repo_root)
            for init in repo_root.rglob("__init__.py"):
                if not init.is_file():
                    continue
                if _is_excluded(init.parent, repo_root, extra):
                    continue
                init_dirs.add(init.parent.resolve())
            # Keep only roots: a package whose parent is also a package is a subpackage.
            return sorted(d for d in init_dirs if d.parent not in init_dirs)
        
        
        @contextmanager
        def _syspath_prepended(paths: list[Path]):
            """Temporarily prepend `paths` to sys.path, restoring it afterwards.
        
            grimp locates a package by importable name via sys.path; we add each
            package's parent so ``grimp.build_graph("lib")`` resolves. Restored in a
            finally so a scan never leaves the interpreter's import state mutated.
            """
            added = [str(p) for p in paths]
            original = list(sys.path)
            for p in added:
                if p not in sys.path:
                    sys.path.insert(0, p)
            try:
                yield
            finally:
                sys.path[:] = original
        
        
        def _module_file(module: str, roots: dict[str, Path]) -> Path | None:
            """Resolve a dotted module name to its source file via its package root.
        
            ``roots`` maps a top-level package name to its directory; the module's
            file lives under that directory's *parent* (the sys.path root), since the
            dotted name already includes the package as its first component.
            """
            top = module.split(".", 1)[0]
            root = roots.get(top)
            if root is None:
                return None
            base = root.parent
            rel = module.replace(".", "/")
            candidate = base / f"{rel}.py"
            if candidate.is_file():
                return candidate
            pkg_init = base / rel / "__init__.py"
            if pkg_init.is_file():
                return pkg_init
            return None
        
        
        # --------------------------------------------------------------------------
        # Source-surface measurement (size + public API)
        # --------------------------------------------------------------------------
        
        def _count_loc(path: Path | None) -> int:
            """Non-blank source lines in a module file (0 if unreadable / absent)."""
            if path is None:
                return 0
            try:
                text = path.read_text(encoding="utf-8", errors="ignore")
            except OSError:
                return 0
            return sum(1 for line in text.splitlines() if line.strip())
        
        
        def _public_surface(path: Path | None) -> int:
            """Count public top-level definitions (the API a module exposes).
        
            Public = a module-level ``def`` / ``async def`` / ``class`` whose name does
            not start with ``_``. This is the surface a dependent must comprehend to
            use the module -- the contract, not the implementation.
            """
            if path is None:
                return 0
            try:
                tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
            except (OSError, SyntaxError):
                return 0
            count = 0
            for node in tree.body:
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
                    if not node.name.startswith("_"):
                        count += 1
            return count
        
        
        # --------------------------------------------------------------------------
        # A1 -- comprehension footprint
        # --------------------------------------------------------------------------
        
        def compute_footprint(
            module: str,
            direct_deps: list[str],
            surfaces: dict[str, int],
            sizes: dict[str, int],
            keyhole_budget: int,
        ) -> dict:
            """A1 footprint for one module: size + direct-dep surface + own surface.
        
            ``direct_deps`` is the module's DIRECT imports only -- no transitive
            closure. A depends on B depends on C must NOT pull C's surface into A's
            footprint, or every module transitively touching a common utility would
            blow the budget and the whole repo would be flagged.
            """
            size = sizes.get(module, 0)
            dep_surface = sum(surfaces.get(dep, 0) for dep in direct_deps)
            exposed_surface = surfaces.get(module, 0)
            total = size + dep_surface + exposed_surface
            return {
                "module": module,
                "size": size,
                "dep_surface": dep_surface,
                "exposed_surface": exposed_surface,
                "total": total,
                "over_budget": total > keyhole_budget,
            }
        
        
        # --------------------------------------------------------------------------
        # A2 -- blob vs modular
        # --------------------------------------------------------------------------
        
        def _detect_communities(undirected) -> list[set]:
            """Community partition of an undirected graph (greedy / louvain by size)."""
            if undirected.number_of_nodes() == 0:
                return []
            from networkx.algorithms.community import (
                greedy_modularity_communities,
                louvain_communities,
            )
            if undirected.number_of_edges() == 0:
                # No edges: every node is its own community.
                return [{n} for n in undirected.nodes()]
            if undirected.number_of_nodes() < GREEDY_MAX_NODES:
                return [set(c) for c in greedy_modularity_communities(undirected)]
            # louvain needs a deterministic seed to stay reproducible run to run.
            return [set(c) for c in louvain_communities(undirected, seed=1)]
        
        
        def _modularity_q(undirected, communities: list[set]) -> float:
            """Newman Q for a partition, clamped to the theoretical [-0.5, 1] range."""
            if not communities or undirected.number_of_edges() == 0:
                return 0.0
            from networkx.algorithms.community import modularity
            try:
                q = modularity(undirected, communities)
            except (ZeroDivisionError, KeyError):  # pragma: no cover - defensive
                return 0.0
            return max(-0.5, min(1.0, q))
        
        
        def compute_modularity(graph) -> tuple[list[list[str]], float]:
            """A2: import cycles (SCCs len > 1) and the Newman modularity Q.
        
            Returns ``(sccs, q)`` where ``sccs`` is the list of strongly-connected
            components of length > 1 (each a definitional blob -- a cycle through which
            a change in any member can reach every other), and ``q`` is the modularity
            of the best community partition of the undirected projection.
            """
            sccs = sorted(
                (sorted(c) for c in nx.strongly_connected_components(graph) if len(c) > 1),
                key=lambda c: (-len(c), c),
            )
            undirected = graph.to_undirected()
            communities = _detect_communities(undirected)
            q = _modularity_q(undirected, communities)
            return sccs, q
        
        
        # --------------------------------------------------------------------------
        # A3 -- contracts (front door vs burrow)
        # --------------------------------------------------------------------------
        
        def _top_package(module: str) -> str:
            return module.split(".", 1)[0]
        
        
        def compute_front_door_ratio(
            graph, packages: set[str],
        ) -> tuple[float, list[dict]]:
            """A3: fraction of cross-package edges landing on a front door.
        
            Only *cross-package* edges carry a contract -- intra-package edges are
            internal cohesion. An edge into another package is a **front door** when
            its target is a package module itself (importing the ``__init__`` / public
            API) and a **burrow** when it reaches a plain submodule inside that package.
        
            A vacuous graph (no cross-package edges) is treated as fully contracted
            (ratio 1.0) -- there are no boundaries being violated.
            """
            front, burrow = 0, 0
            burrow_edges: list[dict] = []
            for importer, imported in graph.edges():
                if _top_package(importer) == _top_package(imported):
                    continue  # intra-package: not a contract edge
                if imported in packages:
                    front += 1
                else:
                    burrow += 1
                    burrow_edges.append({"importer": importer, "imported": imported})
            total = front + burrow
            ratio = 1.0 if total == 0 else front / total
            burrow_edges.sort(key=lambda e: (e["importer"], e["imported"]))
            return ratio, burrow_edges
        
        
        # --------------------------------------------------------------------------
        # A4 -- breakup candidates
        # --------------------------------------------------------------------------
        
        def find_breakup_candidates(
            package: str, internal_graph,
        ) -> dict | None:
            """A4: propose cut-lines for a package that is several packages in one.
        
            ``internal_graph`` holds only the package's own modules and the edges
            between them. A package is a breakup candidate when its internal modules
            fall into two or more well-separated communities -- the sub-clusters are
            the proposed cut-lines. Returns ``None`` when the package is too small to
            bother splitting, or when its modules form one cohesive cluster (the
            community split is weak: Q below the threshold).
            """
            if internal_graph.number_of_nodes() < MIN_PACKAGE_MODULES_FOR_BREAKUP:
                return None
            undirected = internal_graph.to_undirected()
            communities = _detect_communities(undirected)
            communities = [c for c in communities if c]
            if len(communities) < 2:
                return None
            q = _modularity_q(undirected, communities)
            if q < BREAKUP_MODULARITY_THRESHOLD:
                return None  # cohesive enough: one coat, one package
            clusters = sorted(
                (sorted(c) for c in communities), key=lambda c: (-len(c), c),
            )
            return {
                "package": package,
                "clusters": clusters,
                "num_clusters": len(clusters),
                "modularity_q": round(q, 4),
            }
        
        
        # --------------------------------------------------------------------------
        # Entry point
        # --------------------------------------------------------------------------
        
        def _build_grimp_graph(package_dirs: list[Path]):
            """Build the grimp import graph for the discovered packages.
        
            Returns ``(import_graph, package_names, roots)`` or raises on failure.
            ``roots`` maps each top-level package name to its source directory, used to
            resolve module names back to files for surface measurement.
            """
            names = [d.name for d in package_dirs]
            roots = {d.name: d for d in package_dirs}
            parents = list({d.parent for d in package_dirs})
            with _syspath_prepended(parents):
                # cache_dir=None: never write grimp's cache into the target repo.
                import_graph = grimp.build_graph(*names, cache_dir=None)
            return import_graph, names, roots
        
        
        def analyze_structure(
            repo_root: Path,
            keyhole_budget: int | None = None,
            extra_exclude_dirs: set[str] | None = None,
            scope: Path | None = None,
        ) -> StructureGraphResult:
            """Run the A1-A4 static structure analysis over the repo's Python packages.
        
            Degrades gracefully (``available=False``) when grimp or networkx is missing
            or no importable Python package is found. The result is JSON-serialisable
            via ``as_dict()`` and deterministic for a given source tree.
        
            ``scope`` (an absolute path under ``repo_root``) confines the analysis to
            packages within a subtree for ``/assess <path>`` monorepo scoping, so a
            scoped run carries no structure signal from a sibling directory. Omit it for
            a whole-repo run.
            """
            repo_root = Path(repo_root).resolve()
            budget = keyhole_budget if keyhole_budget is not None else DEFAULT_KEYHOLE_BUDGET
        
            if not _GRIMP_AVAILABLE or not _NETWORKX_AVAILABLE:
                missing = "grimp" if not _GRIMP_AVAILABLE else "networkx"
                return StructureGraphResult(
                    available=False,
                    reason=f"{missing} not installed; static structure not assessed",
                    keyhole_budget=budget,
                )
        
            package_dirs = discover_packages(repo_root, extra_exclude_dirs)
            if scope is not None:
                scope_abs = scope.resolve()
                package_dirs = [
                    p for p in package_dirs if p.resolve().is_relative_to(scope_abs)
                ]
            if not package_dirs:
                return StructureGraphResult(
                    available=True,
                    reason="no importable Python packages found",
                    keyhole_budget=budget,
                )
        
            try:
                import_graph, package_names, roots = _build_grimp_graph(package_dirs)
            except Exception as e:  # pragma: no cover - grimp parse failure on odd trees
                return StructureGraphResult(
                    available=False,
                    reason=f"grimp failed to build import graph ({e})",
                    keyhole_budget=budget,
                )
        
            modules = sorted(import_graph.modules)
            package_set = set(package_names) | {
                m for m in modules
                if (mf := _module_file(m, roots)) is not None and mf.name == "__init__.py"
            }
        
            # Measure each module's size and public surface once.
            sizes: dict[str, int] = {}
            surfaces: dict[str, int] = {}
            for m in modules:
                f = _module_file(m, roots)
                sizes[m] = _count_loc(f)
                surfaces[m] = _public_surface(f)
        
            # Build the networkx digraph (nodes = modules, edges = direct imports).
            graph = nx.DiGraph()
            graph.add_nodes_from(modules)
            for m in modules:
                for dep in import_graph.find_modules_directly_imported_by(m):
                    if dep in graph:  # ignore imports of external / unknown modules
                        graph.add_edge(m, dep)
        
            # A1 footprints (direct deps only).
            footprints = [
                compute_footprint(
                    m, sorted(graph.successors(m)), surfaces, sizes, budget,
                )
                for m in modules
            ]
            footprints.sort(key=lambda fp: (-fp["total"], fp["module"]))
        
            # A2 SCCs + modularity.
            sccs, q = compute_modularity(graph)
        
            # A3 front-door ratio.
            front_door_ratio, burrow_edges = compute_front_door_ratio(graph, package_set)
        
            # A4 breakup candidates -- one analysis per top-level package.
            breakup: list[dict] = []
            for pkg in sorted(package_names):
                members = [
                    m for m in modules if m == pkg or m.startswith(pkg + ".")
                ]
                internal = graph.subgraph(members)
                candidate = find_breakup_candidates(pkg, internal)
                if candidate is not None:
                    breakup.append(candidate)
        
            return StructureGraphResult(
                available=True,
                keyhole_budget=budget,
                footprints=footprints[:MAX_FOOTPRINTS],
                sccs=sccs[:MAX_SCCS],
                modularity_q=q,
                front_door_ratio=front_door_ratio,
                internal_burrow_edges=burrow_edges[:MAX_BURROW_EDGES],
                breakup_candidates=breakup,
                module_count=graph.number_of_nodes(),
                edge_count=graph.number_of_edges(),
            )
        
      • test_focus.py 15.8 KB
        """Compose existing signals into a single ranked test-focus block.
        
        `/assess` already surfaces three independent truths about a file: how risky it is
        (complexity x churn -> the hotspot band), whether a test covers it (the parsed
        coverage report), and whether the test that covers it looks hollow (the cheap
        heuristics). On their own each is a separate list the reader has to cross-join in
        their head. This module does that cross-join deterministically and emits one
        ranked list answering the only question that matters for write-side safety:
        *which risky files most need test work, and which kind?*
        
        `compute_test_focus` is the SINGLE source the report table (the focus block) and
        the mutation offer both read - the contract is here, not duplicated downstream.
        It takes four inputs as parameters (the ranked hot files, the parsed coverage
        report, the hollow-test heuristics, and an optional ``repo_root``, plus an
        optional prebuilt repository ``index``) and returns a plain dict. It imports no
        orchestrator and never raises. Its one file-system probe is the sibling-test
        check in `lib/sibling_tests.py`, run only when ``repo_root`` is passed and a hot
        file lacks a coverage record: one repository index (``build_test_index``, built
        on first use when not passed) plus existence checks for at most ten hot files at a fixed ancestor depth; without
        ``repo_root`` it does no file I/O at all.
        
        Signal per file (most to least actionable):
          - ``no_covering_test``      - a coverage report exists and it records this file
                                        at a 0 line rate, or omits it with no test file
                                        found: a risky file with no test.
          - ``covered_but_hollow``    - a test covers it, but it trips a hollow-test
                                        heuristic (asserts internals, untested boundary,
                                        duplicate truth).
          - ``unsupported``           - no coverage report and no sibling or
                                        parallel-tree test file found (``repo_root``
                                        given): the core cannot tell
                                        whether a test exists, so it says so rather
                                        than claim ``no_covering_test``.
          - ``sibling_test_only``     - a test file maps to it but no coverage record
                                        does (no report, or a partial report that omits
                                        the file): a test file is present, coverage is
                                        unmeasured. Carries any hollow kinds it tripped.
          - ``unknown_no_coverage``   - no coverage report and no ``repo_root`` to look
                                        for a test file: we *cannot* say it is covered,
                                        so we do not pretend it is clean.
          - ``covered_clean``         - covered, no hollow hit. Not a focus target;
                                        filtered out of the output.
        
        Test-file evidence comes from `lib/sibling_tests.has_sibling_test`, the same
        resolver behind the hotspot page's ``Has test file`` row, so the two never
        disagree in one run. The evidence is a file's existence, so the core never
        spells it as coverage.
        
        Mutation scope: `mutation_scope` takes the paths of the entries that carry test
        evidence (``covered_but_hollow``, ``sibling_test_only``). Mutating a file with no
        test yields all survivors and measures the missing test, not an existing one's
        strength, so ``unsupported`` / ``no_covering_test`` / ``unknown_no_coverage``
        entries stay in the table but out of the mutation pass. A hot file that is
        itself a test (`sibling_tests.is_test_path`) keeps its ``sibling_test_only`` row
        but never enters the scope: nothing tests a test file, so mutating it measures
        nothing and would come back as an ``untrusted_hotspot``.
        
        Honest degradation is the hard contract: ``coverage_data is None`` never yields
        ``covered_clean`` for an untested file and records ``coverage_present: False``.
        A risky file we know nothing about is surfaced, not silently blessed as clean.
        
        Inward-only imports: stdlib and `lib.sibling_tests`; imported by the orchestrator
        (`assess_core.py`), never importing one itself.
        """
        from __future__ import annotations
        
        from collections.abc import Callable
        from dataclasses import asdict, dataclass, field
        from pathlib import Path
        from typing import Any
        
        from lib.sibling_tests import (
            TestIndex,
            build_test_index,
            has_sibling_test,
            is_test_path,
            shared_name_keys,
        )
        
        # Risk bands by position in the ranked top_hotspots list. Index 0-2 are the
        # sharpest hotspots, 3-6 the next tier, 7-9 the tail; anything past the top 10 is
        # not a hotspot and is excluded entirely.
        _HIGH_MAX = 2
        _MEDIUM_MAX = 6
        _LOW_MAX = 9
        
        # Ranking weights. Risk band dominates; signal severity breaks ties within a band.
        _BAND_RANK = {"high": 3, "medium": 2, "low": 1}
        #
        # The scale ranks "less tested" higher: the list answers which risky files most
        # need test work. ``no_covering_test`` (no test) outranks ``covered_but_hollow``
        # (a weak test), and by the same rule ``unsupported`` (no test file found in any
        # conventional location) outranks ``sibling_test_only`` (a test file exists).
        # ``sibling_test_only`` and ``unknown_no_coverage`` share the bottom rank; they
        # never appear in the same block (one needs ``repo_root``, the other its absence).
        # The mutation pass does not read this order raw: `mutation_scope` keeps only
        # entries with test evidence, since mutating a file with no test measures the
        # missing test rather than the strength of an existing one.
        _SIGNAL_SEVERITY = {
            "no_covering_test": 4,
            "covered_but_hollow": 3,
            "unsupported": 2,
            "sibling_test_only": 1,
            "unknown_no_coverage": 1,
            "covered_clean": 0,
        }
        
        # Which suggested action each signal implies.
        _ACTION_BY_SIGNAL = {
            "no_covering_test": "add_tests",
            "unknown_no_coverage": "add_tests",
            "unsupported": "measure_coverage",
            "sibling_test_only": "measure_coverage",
            "covered_but_hollow": "strengthen_assertions",
            "covered_clean": "none",
        }
        
        # Signals whose file has test evidence - the only entries a mutation pass can
        # say anything about. Kept in ranked order by `mutation_scope`, which also drops
        # any hot file that is itself a test: it counts as its own test (so it reads
        # ``sibling_test_only``, never ``unsupported``), but no test exercises it, so
        # mutating it measures nothing.
        MUTATION_SCOPE_SIGNALS = frozenset({"covered_but_hollow", "sibling_test_only"})
        
        # The three hollow-test heuristic buckets, in report order. Each bucket entry
        # names the file it flags under either ``file`` (boundary / duplicate-truth, a
        # source file) or ``test_file`` (assertion-on-internal, a test file); we read
        # whichever is present so a source hot file matches against any of them.
        _HEURISTIC_BUCKETS = (
            "assertion_on_internal",
            "untested_boundaries",
            "duplicate_truth",
        )
        
        
        @dataclass
        class TestFocusEntry:
            """One ranked focus target: a hot file, its risk, its test signal, the
            hollow-heuristic kinds it tripped, and the suggested remediation."""
        
            path: str
            risk_band: str  # 'high' | 'medium' | 'low'
            # 'no_covering_test'|'covered_but_hollow'|'covered_clean'|'unknown_no_coverage'
            # |'unsupported'|'sibling_test_only'
            test_signal: str
            hollow_heuristic_kinds: list[str] = field(default_factory=list)
            # 'add_tests' | 'strengthen_assertions' | 'measure_coverage' | 'none'
            suggested_action: str = "none"
        
        
        def _entry_path(entry: Any) -> str | None:
            """Path of a top_hotspots entry, whether it is a dict (``{"path": ...}``) or a
            bare string. Anything else has no usable path."""
            if isinstance(entry, str):
                return entry or None
            if isinstance(entry, dict):
                path = entry.get("path")
                return path if isinstance(path, str) and path else None
            return None
        
        
        def _risk_band(index: int) -> str | None:
            """Band for a file's position in the ranked hotspot list, or ``None`` if it
            falls outside the top 10 (not a hotspot)."""
            if index <= _HIGH_MAX:
                return "high"
            if index <= _MEDIUM_MAX:
                return "medium"
            if index <= _LOW_MAX:
                return "low"
            return None
        
        
        def _is_covered(path: str, coverage_data: dict[str, Any]) -> bool:
            """True when the parsed coverage report carries a non-zero line rate for the
            file. Absent from the report, or a 0.0 rate, means no covering test."""
            per_file = coverage_data.get("per_file")
            if not isinstance(per_file, dict):
                return False
            rate = per_file.get(path)
            try:
                return rate is not None and float(rate) > 0.0
            except (TypeError, ValueError):
                return False
        
        
        def _has_record(path: str, coverage_data: dict[str, Any]) -> bool:
            """True when the parsed coverage report carries any entry for the file,
            even a zero rate: the report measured it."""
            per_file = coverage_data.get("per_file")
            return isinstance(per_file, dict) and path in per_file
        
        
        def _hollow_kinds(path: str, cheap_heuristics: dict[str, Any]) -> list[str]:
            """Heuristic buckets in which this file appears, in report order. Reads both
            the ``file`` and ``test_file`` keys so a source hot file matches whichever a
            bucket uses."""
            if not isinstance(cheap_heuristics, dict):
                return []
            kinds: list[str] = []
            for bucket in _HEURISTIC_BUCKETS:
                findings = cheap_heuristics.get(bucket)
                if not isinstance(findings, list):
                    continue
                for finding in findings:
                    if not isinstance(finding, dict):
                        continue
                    if finding.get("file") == path or finding.get("test_file") == path:
                        kinds.append(bucket)
                        break
            return kinds
        
        
        def _classify(
            path: str,
            coverage_present: bool,
            coverage_data: dict[str, Any] | None,
            cheap_heuristics: dict[str, Any],
            repo_root: Path | None = None,
            shared_names: frozenset[str] = frozenset(),
            get_index: Callable[[], TestIndex] | None = None,
        ) -> tuple[str, list[str]]:
            """Resolve a file's test signal and the hollow kinds it tripped.
        
            No coverage report and a ``repo_root``: a test file credits the file as
            ``sibling_test_only`` (with any hollow kinds it tripped); none ->
            ``unsupported``. No report and no ``repo_root`` -> ``unknown_no_coverage``
            (we never claim clean). A report present: covered + a hollow hit ->
            ``covered_but_hollow``; covered + clean -> ``covered_clean``; a 0 rate ->
            ``no_covering_test``; absent from the report -> ``sibling_test_only`` when a
            test file exists (a partial report is not evidence of no test), otherwise
            ``no_covering_test``. A flat-tree-only test match does not credit a file
            whose bare name another hot file shares (``shared_names``).
            """
            def has_test() -> bool:
                return repo_root is not None and bool(
                    has_sibling_test(repo_root, path, shared_names,
                                     get_index() if get_index is not None else None))
        
            if not coverage_present or coverage_data is None:
                if repo_root is None:
                    return "unknown_no_coverage", []
                if not has_test():
                    return "unsupported", []
                return "sibling_test_only", _hollow_kinds(path, cheap_heuristics)
            if not _is_covered(path, coverage_data):
                if not _has_record(path, coverage_data) and has_test():
                    return "sibling_test_only", _hollow_kinds(path, cheap_heuristics)
                return "no_covering_test", []
            kinds = _hollow_kinds(path, cheap_heuristics)
            if kinds:
                return "covered_but_hollow", kinds
            return "covered_clean", []
        
        
        def compute_test_focus(
            hot_files: Any,
            coverage_data: dict[str, Any] | None,
            cheap_heuristics: dict[str, Any] | None,
            *,
            repo_root: Path | None = None,
            index: TestIndex | None = None,
        ) -> dict[str, Any]:
            """Cross-join the hotspot, coverage, and hollow-test signals into one ranked
            focus block.
        
            Args:
                hot_files: the ranked ``complexity_stats.top_hotspots`` list (entries are
                    dicts with a ``path``, or bare path strings). Position sets the risk
                    band; only the top 10 are considered, the rest are not hotspots.
                coverage_data: the parsed ``{_overall, per_file}`` report from
                    ``load_coverage_data``, or ``None`` when no report was found.
                cheap_heuristics: the ``test_pressure`` block's ``cheap_heuristics`` dict
                    (``assertion_on_internal`` / ``untested_boundaries`` /
                    ``duplicate_truth`` buckets).
                repo_root: optional repository root. When given, a hot file with no
                    coverage record is checked for a test file instead of degrading
                    straight to ``unknown_no_coverage`` / ``no_covering_test``.
                index: optional repository index from ``build_test_index`` for that
                    probe, so a caller that already built one does not walk the tree
                    again. Absent, it is built on the first probe that reads it, and
                    never when the coverage report records every hot file.
        
            Returns:
                ``{available, coverage_present, entries, total_focus_targets}`` where
                ``entries`` is the ranked list of focus targets (``covered_clean``
                filtered out), each a ``TestFocusEntry`` as a dict.
            """
            coverage_present = coverage_data is not None
            heuristics = cheap_heuristics if isinstance(cheap_heuristics, dict) else {}
            entries: list[TestFocusEntry] = []
        
            items = hot_files if isinstance(hot_files, list) else []
            # Bare names carried by more than one considered hot file: a flat tests/
            # match on such a name is ambiguous and credits none of them.
            shared_names = shared_name_keys(
                p for p in (_entry_path(i) for i in items[: _LOW_MAX + 1]) if p is not None)
            root = Path(repo_root) if repo_root is not None else None
            # One repository index for every hot file's parallel-tree (basename) probe,
            # built on first use: a report that records every hot file never needs it.
            built: list[TestIndex] = [index] if index is not None else []
        
            def get_index() -> TestIndex:
                if not built and root is not None:
                    built.append(build_test_index(root))
                return built[0] if built else TestIndex()
        
            for position, item in enumerate(items):
                band = _risk_band(position)
                if band is None:
                    break  # past the top 10 - no longer a hotspot
                path = _entry_path(item)
                if path is None:
                    continue
                signal, kinds = _classify(
                    path, coverage_present, coverage_data, heuristics,
                    root, shared_names, get_index,
                )
                if signal == "covered_clean":
                    continue  # not a focus target
                entries.append(
                    TestFocusEntry(
                        path=path,
                        risk_band=band,
                        test_signal=signal,
                        hollow_heuristic_kinds=kinds,
                        suggested_action=_ACTION_BY_SIGNAL[signal],
                    )
                )
        
            # Rank by risk band first, then signal severity within a band. Python's sort
            # is stable, so files tied on both keys keep their original hotspot order.
            entries.sort(
                key=lambda e: (_BAND_RANK[e.risk_band], _SIGNAL_SEVERITY[e.test_signal]),
                reverse=True,
            )
        
            return {
                "available": True,
                "coverage_present": coverage_present,
                "entries": [asdict(e) for e in entries],
                "total_focus_targets": len(entries),
            }
        
        
        def mutation_scope(test_focus: Any) -> list[str]:
            """Paths the bounded mutation pass should mutate, in ranked order: the
            ``test_focus`` entries whose signal is in ``MUTATION_SCOPE_SIGNALS`` (the
            file has test evidence), minus any path that is itself a test file. Accepts
            the block dict or its ``entries`` list; anything malformed yields ``[]``."""
            entries = test_focus.get("entries") if isinstance(test_focus, dict) else test_focus
            if not isinstance(entries, list):
                return []
            return [
                e["path"] for e in entries
                if isinstance(e, dict) and isinstance(e.get("path"), str) and e["path"]
                and e.get("test_signal") in MUTATION_SCOPE_SIGNALS
                and not is_test_path(e["path"])
            ]
        
      • treemap_render.py 12.7 KB
        """Shared treemap layout + SVG primitives.
        
        The code heatmap (``complexity-treemap.py``) and the docs-staleness heatmap
        (``docs-staleness-treemap.py``) use the *same visual grammar* -- size, hue,
        saturation, squarified layout, hover tooltips -- but with opposite risk models
        (code red = "hard to change safely"; docs red = "actively misleading"). So the
        mechanical parts live here and each script keeps only its own colour mapping.
        
        Heavy deps (matplotlib/squarify/numpy) are imported here, so this module must
        only be imported by the treemap scripts -- never by the deterministic core,
        which runs with networkx alone.
        """
        from __future__ import annotations
        
        import html
        from dataclasses import dataclass, field
        from pathlib import Path
        
        import numpy as np
        
        # `squarify` is imported lazily inside layout() so light consumers of the colour
        # helpers (rgba_to_hex / blend_to_grey / adaptive_cap) - e.g. the doc-graph
        # renderer - don't have to depend on it.
        
        
        @dataclass
        class Node:
            name: str
            size: int = 0
            color: tuple = (0.5, 0.5, 0.5, 1.0)
            loc: int = 0
            # Estimated token count (chars/4). The code heatmap sizes blocks by this so
            # the layout reflects context-window burden, not line count; ``loc`` stays
            # the file's real line count for the tooltip. 0 means "no token signal"
            # (the docs heatmap, which sizes by loc and renders its own tooltip2).
            est_tokens: int = 0
            metric: float = 0.0
            aux_metric: float = 0.0
            aux_label: str = ""
            rel_path: str = ""
            children: list["Node"] = field(default_factory=list)
            is_file: bool = False
            # Optional display overrides used by the docs heatmap. When unset the code
            # heatmap's default formatting applies, so its output is unchanged.
            tooltip2: str = ""
            label_size_text: str = ""
            label_metric_text: str = ""
            # Survivor-density overlay (code heatmap only). "" = no overlay, "diag" =
            # diagonal hatch (>30% mutants survive), "cross" = cross-hatch (>50%).
            # A hatched block reads as "covered but unpinned" so it stops rendering as
            # safe green. Unset everywhere else, so other heatmaps are unchanged.
            hatch: str = ""
        
        
        def build_tree(files_with_color, root: Path,
                       aux_data: dict[Path, int] | None = None,
                       aux_label: str = "",
                       node_overrides: dict[Path, dict] | None = None,
                       size_by: dict[Path, int] | None = None) -> Node:
            """Build the directory tree of Nodes. `files_with_color` is a list of
            (path, size, metric, source, color). `node_overrides` optionally maps a
            file path to a dict of extra Node fields (tooltip2, label_* ...).
        
            `size_by` optionally overrides the block *area* per file (path -> size)
            while `size` from the tuple is preserved as the node's `loc`. The code
            heatmap passes estimated token counts here so blocks are sized by
            context-window burden; `loc` stays the real line count for the tooltip.
            When `size_by` is None the area is the tuple's size (unchanged - the docs
            heatmap path)."""
            rootnode = Node(name=root.name)
            by_path: dict[Path, Node] = {root: rootnode}
            for path, size, metric, _src, color in files_with_color:
                try:
                    rel = path.relative_to(root)
                except ValueError:
                    continue
                parent = rootnode
                cur = root
                for part in rel.parts[:-1]:
                    cur = cur / part
                    if cur not in by_path:
                        n = Node(name=part)
                        by_path[cur] = n
                        parent.children.append(n)
                    parent = by_path[cur]
                aux_val = float(aux_data.get(path, 0)) if aux_data else 0.0
                area = size_by.get(path, size) if size_by else size
                est_tokens = size_by.get(path, 0) if size_by else 0
                leaf = Node(
                    name=rel.parts[-1], size=area, color=color,
                    loc=size, est_tokens=est_tokens, metric=metric, aux_metric=aux_val,
                    aux_label=aux_label, rel_path=str(rel), is_file=True,
                )
                if node_overrides and path in node_overrides:
                    for k, v in node_overrides[path].items():
                        setattr(leaf, k, v)
                parent.children.append(leaf)
        
            def roll(n: Node) -> int:
                if n.is_file:
                    return n.size
                n.size = sum(roll(c) for c in n.children)
                return n.size
            roll(rootnode)
            return rootnode
        
        
        def layout(node: Node, x: float, y: float, w: float, h: float,
                   out: list) -> None:
            import squarify
            if node.is_file:
                out.append((x, y, w, h, node))
                return
            kids = sorted([c for c in node.children if c.size > 0],
                          key=lambda c: -c.size)
            if not kids or w <= 0 or h <= 0:
                return
            sizes = [c.size for c in kids]
            norm = squarify.normalize_sizes(sizes, w, h)
            placed = squarify.squarify(norm, x, y, w, h)
            for child, r in zip(kids, placed):
                layout(child, r["x"], r["y"], r["dx"], r["dy"], out)
        
        
        def rgba_to_hex(rgba: tuple) -> str:
            r, g, b = rgba[0], rgba[1], rgba[2]
            return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
        
        
        GREY = (0.82, 0.82, 0.84)  # cool light grey for "stable" / no recent churn
        
        
        def blend_to_grey(rgba: tuple, factor: float) -> tuple:
            """factor=0 returns full grey, factor=1 returns the original colour."""
            factor = max(0.0, min(1.0, factor))
            return tuple(
                GREY[i] + (rgba[i] - GREY[i]) * factor for i in range(3)
            ) + (1.0,)
        
        
        def adaptive_cap(values: list[float]) -> tuple[float, str]:
            """Pick a sensible cap: max for well-behaved data, p95 for outlier-heavy."""
            if not values:
                return 1.0, "max"
            mx = float(max(values))
            if mx == 0:
                return 1.0, "max"
            p95 = float(np.percentile(values, 95))
            if p95 > 0 and mx > 5 * p95:
                return p95, "p95 (outlier-suppressed)"
            return mx, "max"
        
        
        # Extra band drawn below the treemap to key the survivor-density overlay.
        SURVIVOR_LEGEND_H = 84.0
        
        # SVG <pattern> defs for the survivor-density overlay. Dark, semi-transparent
        # strokes read on any OrRd fill without relying on hue, so the hatch is
        # distinguishable by texture alone (colour-blind safe). "diag" = single
        # diagonal hatch (>30% survivors), "cross" = cross-hatch (>50%, severe).
        _SURVIVOR_DEFS = (
            '<defs>'
            '<pattern id="survivor-diag" patternUnits="userSpaceOnUse" '
            'width="7" height="7" patternTransform="rotate(45)">'
            '<line x1="0" y1="0" x2="0" y2="7" stroke="#1a1a1a" '
            'stroke-width="1" stroke-opacity="0.55"/></pattern>'
            '<pattern id="survivor-cross" patternUnits="userSpaceOnUse" '
            'width="6" height="6">'
            '<path d="M0,0 l6,6 M6,0 l-6,6" stroke="#1a1a1a" '
            'stroke-width="1" stroke-opacity="0.6"/></pattern>'
            '</defs>'
        )
        
        
        def _survivor_legend_parts(W: float, H: float) -> list[str]:
            """Legend band keyed under the treemap, explaining the hatch overlay.
            Drawn only when the overlay is active, so a run with no survivor data
            keeps the original full-canvas treemap untouched."""
            rows = [
                ("survivor-diag",
                 "&gt;30% survivor density - covered but unpinned "
                 "(tests run this code without constraining it)"),
                ("survivor-cross",
                 "&gt;50% survivor density - severe; most mutations survive the suite"),
            ]
            sw = 16.0
            parts = [
                f'<line x1="0" y1="{H:.1f}" x2="{W:.1f}" y2="{H:.1f}" '
                f'stroke="#cccccc" stroke-width="1"/>',
                f'<text x="14" y="{H + 18:.1f}" font-size="13" text-anchor="start" '
                f'font-weight="bold">Survivor-density overlay (hatched = covered but '
                f'unpinned)</text>',
            ]
            row_y = H + 34.0
            for pid, label in rows:
                cy = row_y + sw / 2
                parts.append(
                    f'<rect x="14" y="{row_y:.1f}" width="{sw:.0f}" height="{sw:.0f}" '
                    f'fill="#f2f2f2" stroke="#888888" stroke-width="0.5"/>'
                )
                parts.append(
                    f'<rect x="14" y="{row_y:.1f}" width="{sw:.0f}" height="{sw:.0f}" '
                    f'fill="url(#{pid})" stroke="none" pointer-events="none"/>'
                )
                parts.append(
                    f'<text x="{14 + sw + 10:.0f}" y="{cy:.1f}" font-size="12" '
                    f'text-anchor="start">{label}</text>'
                )
                row_y += sw + 6.0
            return parts
        
        
        # Default accessible name/description for the code heatmap. Passed as the root
        # <svg>'s <title>/<desc> (a11y metadata) so a screen reader announces what the
        # image is and how its channels encode; kept as defaults on write_svg so a
        # future consumer (e.g. a docs heatmap) can override without touching callers.
        DEFAULT_SVG_TITLE = "Complexity Hotspot Heatmap"
        DEFAULT_SVG_DESC = (
            "Treemap showing code complexity by file size, hue indicates cyclomatic "
            "complexity, saturation indicates git churn"
        )
        
        
        def write_svg(rects: list, root: Path, W: float, H: float,
                      out_path: Path, show_labels: bool,
                      metric_label: str,
                      show_survivor_legend: bool = False,
                      svg_title: str = DEFAULT_SVG_TITLE,
                      svg_desc: str = DEFAULT_SVG_DESC) -> None:
            label_threshold = (W * H) / 200
            has_hatch = any(node.hatch for _x, _y, _w, _h, node in rects)
            total_h = H + (SURVIVOR_LEGEND_H if show_survivor_legend else 0.0)
            parts: list[str] = [
                '<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
                f'<svg xmlns="http://www.w3.org/2000/svg" '
                f'viewBox="0 0 {W:.0f} {total_h:.0f}" '
                f'width="{W:.0f}" height="{total_h:.0f}" '
                f'preserveAspectRatio="xMidYMid meet" role="img">',
                # A11y: <title>/<desc> as the first children of the root <svg> give the
                # image an accessible name and description (SVG accessibility contract).
                f'<title>{html.escape(svg_title)}</title>',
                f'<desc>{html.escape(svg_desc)}</desc>',
                '<style>',
                '  rect:hover { stroke: #000; stroke-width: 1.5; }',
                '  text { font-family: -apple-system, BlinkMacSystemFont, '
                '"Segoe UI", sans-serif; fill: #1a1a1a; '
                'pointer-events: none; text-anchor: middle; '
                'dominant-baseline: middle; }',
                '</style>',
            ]
            if has_hatch or show_survivor_legend:
                parts.append(_SURVIVOR_DEFS)
        
            for x, y, w, h, node in rects:
                rel = node.rel_path or node.name
                if node.tooltip2:
                    line2 = node.tooltip2
                elif node.est_tokens:
                    # Code heatmap: block area is estimated tokens, so lead with that
                    # and keep the familiar LOC one hover away (PRD: nothing lost).
                    line2 = (f"{node.est_tokens:,} est. tokens · {node.loc} loc "
                             f"· {metric_label} {node.metric:.0f}")
                    if node.aux_label:
                        line2 += f" · {node.aux_label} {node.aux_metric:.0f}"
                else:
                    line2 = f"{node.loc} loc · {metric_label} {node.metric:.0f}"
                    if node.aux_label:
                        line2 += f" · {node.aux_label} {node.aux_metric:.0f}"
                tooltip = html.escape(f"{rel}\n{line2}", quote=False)
                parts.append(
                    f'<rect x="{x:.2f}" y="{y:.2f}" '
                    f'width="{w:.2f}" height="{h:.2f}" '
                    f'fill="{rgba_to_hex(node.color)}" '
                    f'stroke="white" stroke-width="0.5">'
                    f'<title>{tooltip}</title></rect>'
                )
        
            # Hatch overlays sit on top of every base rect. pointer-events="none" keeps
            # the underlying block's hover tooltip working through the overlay.
            for x, y, w, h, node in rects:
                if not node.hatch:
                    continue
                parts.append(
                    f'<rect x="{x:.2f}" y="{y:.2f}" '
                    f'width="{w:.2f}" height="{h:.2f}" '
                    f'fill="url(#survivor-{node.hatch})" stroke="none" '
                    f'pointer-events="none"/>'
                )
        
            if show_labels:
                for x, y, w, h, node in rects:
                    if w * h <= label_threshold:
                        continue
                    fs = max(7, min(int(min(w, h) / 6), 18))
                    cx, cy = x + w / 2, y + h / 2
                    name = html.escape(node.name)
                    if node.label_size_text:
                        size_text = node.label_size_text
                    elif node.est_tokens:
                        size_text = f"{node.est_tokens:,} est. tokens"
                    else:
                        size_text = f"{node.loc} loc"
                    metric_text = node.label_metric_text or f"{metric_label} {node.metric:.0f}"
                    parts.append(
                        f'<text x="{cx:.1f}" y="{cy - fs:.1f}" '
                        f'font-size="{fs}">{name}</text>'
                        f'<text x="{cx:.1f}" y="{cy + 2:.1f}" '
                        f'font-size="{max(6, fs - 2)}" fill="#444">'
                        f'{html.escape(size_text)}</text>'
                        f'<text x="{cx:.1f}" y="{cy + fs + 4:.1f}" '
                        f'font-size="{max(6, fs - 2)}" fill="#444">'
                        f'{html.escape(metric_text)}</text>'
                    )
        
            if show_survivor_legend:
                parts.extend(_survivor_legend_parts(W, H))
        
            parts.append('</svg>')
            out_path.write_text("\n".join(parts), encoding="utf-8")
        
      • understanding_analysis.py 11 KB
        """Signals B4 + D2: where understanding lives, and the velocity clock.
        
        The keyhole question the rest of `/assess` can't answer on its own: for a
        complex module that no single context window can hold, *does anyone still
        understand it*? Classic ownership ("who wrote the most lines") is the wrong
        lens in the AI era - a directory churned by hundreds of agent sessions has
        enormous activity and effectively zero retained understanding. So instead of an
        owner we compute, per module:
        
          - **human anchor** (B4) - has a confirmed human substantively authored it?
            Someone who can be asked. (Straight from
            ``change_coupling.authorship_analysis`` - reused, never re-derived, so the
            conservative agent/human classification stays defined one way.)
          - **intent source** (B4) - is there an externalised spec/doc stating what the
            code *should* do? Computed from doc->code association (a doc whose directory
            is an ancestor of the module), the same path-proximity edges
            ``doc_staleness`` / the C-signal join use. Presence, not freshness: a stale
            doc still externalises intent (its *staleness* is signal C's lying-map
            finding, not this one's).
          - **authorship class** (B4) - human / agent / mixed / unknown, passed through
            from the authorship analysis.
          - **days_since_comprehension_event** (D2, the velocity clock) - calendar age
            is dead under AI velocity; "legacy" means *orphaned understanding*, which
            can happen on day one. So age is measured from the last
            **comprehension-event**: a human-authored commit touching the module (the
            git-log-reachable, deterministic instance of "a human-originated act that
            demonstrates understanding"). Fully automated commits don't count. ``None``
            when no human-authored commit is found (indeterminate, not "fresh").
        
        The primary finding is **orphaned understanding**: high complexity ∧ no human
        anchor ∧ no intent source - code agents wrote that no human understands and no
        spec explains. The worst case, and the direct feed for the velocity clock.
        
        This module is **standalone**: it consumes the JSON-serialisable outputs of
        ``authorship_analysis`` (per path), ``analyze_doc_staleness``, and the
        complexity treemap (``complexity-stats.json``), and returns a JSON-serialisable
        dict. It never imports or edits ``assess_core`` - wiring the ``understanding``
        block into ``run-context.json`` is a separate task's job.
        """
        from __future__ import annotations
        
        import datetime as _dt
        import subprocess
        from pathlib import Path, PurePosixPath
        
        # Reuse the shared, deliberately conservative agent-detection primitives from
        # the change-coupling module rather than re-implementing them: agent/human
        # classification must be defined exactly once (PRD Open Question 6 - never
        # libel a human author), and the velocity clock's "human-authored commit" test
        # has to agree with what `authorship_analysis` already called a human.
        from lib.change_coupling import (
            GIT_TIMEOUT_SECONDS,
            _identity_is_agent,
            repo_top,
        )
        
        # McCabe's classic "moderate risk" line, used as the floor for "high
        # complexity". We gate the orphaned-understanding finding on the *higher* of
        # this floor and the repo's own 95th-percentile CCN, so a genuinely simple repo
        # never sprouts findings while a complex repo self-calibrates to its worst ~5%.
        # Same value and rationale as the C-signal join (``doc_complexity_join``), kept
        # consistent so "high complexity" means one thing across both findings.
        MIN_HIGH_CCN = 10.0
        
        # Advice for a human, never an instruction to a tool. The orphaned-understanding
        # remedy is to put a person back in the loop before the next change - not to
        # auto-generate a doc (that manufactures lying maps; see the C-signal guard).
        _ORPHANED_RECOMMENDATION = (
            "Assign a human anchor before further change. This is complex code with no "
            "confirmed human author and no spec stating what it should do - agents can "
            "keep changing it, but no one retains the understanding to review them. Do "
            "NOT auto-generate a doc to clear this; a synthetic summary is a lying map."
        )
        
        
        def _extract_file_ccn(complexity_stats: dict) -> dict[str, float]:
            """Build path -> max-CCN from the per-file lists the stats expose.
        
            ``complexity-stats.json`` carries per-file CCN in its ranked lists
            (``top_complex`` / ``top_hotspots`` / ``top_large``) and, optionally, a full
            ``files`` list; we union them and keep the highest CCN seen per path. Mirrors
            the C-signal join's extraction so both findings read complexity identically.
            """
            ccn: dict[str, float] = {}
            for key in ("files", "top_complex", "top_hotspots", "top_large"):
                for entry in complexity_stats.get(key) or []:
                    path = entry.get("path")
                    if path is None or entry.get("ccn") is None:
                        continue
                    value = float(entry["ccn"])
                    if value > ccn.get(path, float("-inf")):
                        ccn[path] = value
            return ccn
        
        
        def _high_ccn_threshold(complexity_stats: dict) -> float:
            """The CCN at or above which a module counts as 'high complexity'."""
            p95 = float((complexity_stats.get("ccn") or {}).get("p95", 0.0) or 0.0)
            return max(p95, MIN_HIGH_CCN)
        
        
        def _doc_dirs(doc_staleness: dict) -> list[tuple[str, ...]]:
            """Directory parts of every doc, only when the staleness signal is available.
        
            A repo-root doc yields ``()`` (it is an ancestor of everything), matching the
            doc->code association the C-signal join and ``doc_staleness`` use.
            """
            if not doc_staleness.get("available", False):
                return []
            return [
                PurePosixPath(doc["path"]).parent.parts
                for doc in doc_staleness.get("docs", [])
                if doc.get("path")
            ]
        
        
        def _has_intent_source(code_path: str, doc_dirs: list[tuple[str, ...]]) -> bool:
            """True if any doc's directory is an ancestor of ``code_path``.
        
            Same path-proximity rule as the doc-staleness association: a doc covers code
            in its own directory and below. A repo-root doc (``()``) therefore covers
            everything - intentionally, so this stays consistent with how the C-signal
            join decides a unit is documented; the orphaned-understanding finding is
            deliberately conservative (it should fire only when there is *no* externalised
            intent anywhere up the tree).
            """
            code_parts = PurePosixPath(code_path).parts
            return any(code_parts[: len(d)] == d for d in doc_dirs)
        
        
        def _days_since_last_human_commit(repo_top: str, path: str) -> int | None:
            """Velocity clock (D2): days since the last human-authored commit on ``path``.
        
            A comprehension-event is a human-originated act demonstrating understanding;
            the deterministic, git-log-reachable instance is a commit whose *author* is a
            confirmed human (same human/agent test ``authorship_analysis`` uses). ``git
            log`` lists newest-first, so the first human-authored commit we hit is the
            most recent. Returns ``None`` when git is unavailable or no human-authored
            commit exists (indeterminate - never silently treated as "fresh").
            """
            fmt = "\x1e%ct\x1f%an\x1f%ae"
            try:
                raw = subprocess.run(
                    ["git", "-C", repo_top, "log", "--no-merges", f"--format={fmt}", "--", path],
                    capture_output=True, text=True, check=True, timeout=GIT_TIMEOUT_SECONDS,
                ).stdout
            except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
                return None
        
            now = _dt.datetime.now().timestamp()
            for chunk in raw.split("\x1e"):
                chunk = chunk.strip()
                if not chunk:
                    continue
                parts = chunk.split("\x1f")
                parts += [""] * (3 - len(parts))
                ct, an, ae = parts[:3]
                author_agent = _identity_is_agent(ae, an)
                author_human = bool(ae.strip()) and "@" in ae and not author_agent
                if not author_human:
                    continue
                try:
                    ts = int(ct)
                except ValueError:
                    continue
                return max(0, int((now - ts) // 86400))
            return None
        
        
        def analyze_understanding(
            repo_root: Path,
            authorship_by_path: dict[str, dict],
            doc_staleness: dict,
            complexity_stats: dict,
        ) -> dict:
            """Signals B4 + D2: per-module understanding signals and the orphaned finding.
        
            Args:
                repo_root: repository root, used only for the velocity-clock git query.
                authorship_by_path: ``{path: authorship_analysis(...) result}`` - each
                    value carries ``human_anchor`` / ``authorship_class`` from
                    ``change_coupling.authorship_analysis``. The set of keys defines the
                    modules analysed.
                doc_staleness: the dict returned by ``analyze_doc_staleness`` (its
                    ``docs[].path`` list drives intent-source detection).
                complexity_stats: the ``complexity-stats.json`` sidecar (per-file CCN in
                    its ranked lists; CCN percentiles under ``ccn``).
        
            Returns a JSON-serialisable dict::
        
                {
                  "available": bool,             # there was authorship data to analyse
                  "high_ccn_threshold": float,   # CCN gate used for the finding
                  "modules": [ {path, human_anchor, intent_source, authorship_class,
                                days_since_comprehension_event, finding, recommendation} ],
                  "orphaned_understanding": [ ...paths ],
                }
        
            ``finding`` is ``"orphaned_understanding"`` when a module is high-complexity
            ∧ has no human anchor ∧ has no intent source, else ``None``. The module list
            covers every path in ``authorship_by_path`` (full understanding picture for
            the report), not only the flagged ones. Suitable as-is for
            ``run-context.json``'s ``understanding`` block (a later task's job to place).
            """
            repo_root = Path(repo_root)
            top = repo_top(repo_root)
        
            file_ccn = _extract_file_ccn(complexity_stats)
            threshold = _high_ccn_threshold(complexity_stats)
            doc_dirs = _doc_dirs(doc_staleness)
        
            modules: list[dict] = []
            orphaned: list[str] = []
        
            for path in sorted(authorship_by_path):
                record = authorship_by_path[path] or {}
                human_anchor = bool(record.get("human_anchor", False))
                authorship_class = record.get("authorship_class", "unknown")
                intent_source = _has_intent_source(path, doc_dirs)
                is_high_complexity = file_ccn.get(path, 0.0) >= threshold
        
                days_since = (
                    _days_since_last_human_commit(top, path)
                    if top is not None
                    else None
                )
        
                finding: str | None = None
                if is_high_complexity and not human_anchor and not intent_source:
                    finding = "orphaned_understanding"
                    orphaned.append(path)
        
                modules.append({
                    "path": path,
                    "human_anchor": human_anchor,
                    "intent_source": intent_source,
                    "authorship_class": authorship_class,
                    "days_since_comprehension_event": days_since,
                    "finding": finding,
                    "recommendation": _ORPHANED_RECOMMENDATION if finding else None,
                })
        
            return {
                "available": bool(authorship_by_path),
                "high_ccn_threshold": round(threshold, 2),
                "modules": modules,
                "orphaned_understanding": sorted(orphaned),
            }
        
      • vault_queries.py 9.5 KB
        """Vault-native navigation edges: Obsidian Bases (`.base`) and Dataview queries.
        
        In an Obsidian vault the primary navigation surface is often *not* static
        ``[[wikilinks]]`` / ``[text](path)`` links. Notes are surfaced dynamically by a
        ``.base`` view (Obsidian Bases) or a ```` ```dataview ```` query block: a hub
        declares a *query* - "every note in folder ``_jira``", "every note tagged
        ``#project``" - and Obsidian materialises the edges at view time. A doc graph
        that only reads static links scores such a vault as massively orphaned even
        though every note is reachable in the app (issue #176).
        
        This module recognises those query hubs as **edge sources**, statically and
        deterministically - no running Obsidian, no live plugin, nothing a CI run
        couldn't reproduce from the committed files alone. We parse the query for the
        predicates we can resolve from the repo on disk:
        
          - **folder** - ``inFolder("_jira")`` (Bases) / ``FROM "_jira"`` (Dataview):
            the hub connects to every note under that folder.
          - **tag** - ``FROM #project`` / ``tags.contains("project")`` /
            ``hasTag("project")``: the hub connects to every note carrying that tag in
            its YAML frontmatter.
          - **frontmatter field** - ``status == "open"`` (Bases) / ``WHERE status =
            "open"`` (Dataview): the hub connects to notes whose frontmatter matches.
        
        Selection is **union across predicate types** on purpose: for a navigability
        read, over-linking a few extra notes is far less harmful than leaving a
        genuinely-reachable note scored as an orphan, and a union can never be emptied
        by an unresolvable predicate (e.g. a ``file.ext == "md"`` guard, which we drop
        anyway). The cost is that a hub combining ``inFolder(...)`` *and* a frontmatter
        filter links the union rather than the intersection Obsidian would show - a
        documented, deliberate over-approximation in the safe direction.
        
        The module is pure: it parses text and resolves predicates against a
        caller-supplied doc list and frontmatter accessor. Filesystem discovery and
        exclude handling stay in ``lib.doc_graph`` (which owns the repo walk), so this
        module imports no sibling that imports it back.
        """
        from __future__ import annotations
        
        import re
        from collections.abc import Callable, Iterable
        from dataclasses import dataclass, field
        from pathlib import Path
        
        
        # A ```` ```dataview ```` fenced block. ``dataviewjs`` (arbitrary JS, not
        # statically resolvable) is excluded: ``\bdataview\b`` won't match ``dataviewjs``
        # because there is no word boundary between ``dataview`` and ``js``.
        _DATAVIEW_BLOCK_RE = re.compile(r"```+[ \t]*dataview\b(.*?)```+",
                                        re.DOTALL | re.IGNORECASE)
        
        # Folder predicates: Bases ``inFolder("X")`` / ``file.inFolder('X')``.
        _INFOLDER_RE = re.compile(r"inFolder\(\s*[\"']([^\"']+)[\"']\s*\)", re.IGNORECASE)
        # Dataview source clause: ``FROM "folder" or #tag and "other"``. ``FROM`` can
        # trail the query type on the same line (``LIST FROM "x"``), so it is matched
        # inline (not anchored to line start); the clause is the rest of that line.
        _FROM_RE = re.compile(r"(?i)\bFROM\b([^\n]*)")
        _QUOTED_RE = re.compile(r"[\"']([^\"']+)[\"']")
        _HASHTAG_RE = re.compile(r"#([A-Za-z0-9][\w/-]*)")
        # Tag predicates: ``hasTag("project")`` / ``tags.contains("project")``.
        _HASTAG_FN_RE = re.compile(
            r"(?:hasTag\(|tags\.contains\()\s*[\"']#?([\w/-]+)[\"']", re.IGNORECASE)
        # Frontmatter-field equality: ``status == "open"`` / ``note.status = "open"``.
        _FIELD_EQ_RE = re.compile(
            r"(?P<ns>\b\w+\.)?(?P<key>[A-Za-z_][\w-]*)\s*={1,2}\s*[\"'](?P<val>[^\"']+)[\"']")
        
        # Keys that are query keywords or file-metadata accessors, never a user's
        # frontmatter field - dropped so a ``file.ext == "md"`` guard can't manufacture
        # a phantom field predicate that selects nothing.
        _FIELD_KEY_SKIP = {
            "infolder", "hastag", "contains", "ext", "from", "where", "and", "or",
            "name", "tags", "file", "note",
        }
        
        # Leading-frontmatter block: ``---\n ... \n---`` at the very top of a note.
        _FRONTMATTER_RE = re.compile(r"\A?---[ \t]*\n(.*?)\n---[ \t]*(?:\n|$)",
                                     re.DOTALL)
        _FM_LIST_ITEM_RE = re.compile(r"\s*-\s+(.*)$")
        _FM_KV_RE = re.compile(r"([A-Za-z_][\w-]*)\s*:\s*(.*)$")
        _FM_TAG_TOKEN_RE = re.compile(r"[#\w/-]+")
        
        # Frontmatter tags are stashed under this synthetic key so a scalar field named
        # "tags" can't collide with the parsed tag set.
        TAGS_KEY = "__tags__"
        
        
        @dataclass
        class VaultQuery:
            """A resolved dynamic-navigation query: the predicates we can match on disk."""
            folders: set[str] = field(default_factory=set)
            tags: set[str] = field(default_factory=set)
            fields: list[tuple[str, str]] = field(default_factory=list)
        
            def is_empty(self) -> bool:
                return not (self.folders or self.tags or self.fields)
        
        
        def parse_frontmatter(text: str) -> dict[str, object]:
            """Parse a note's leading YAML frontmatter into a flat dict.
        
            Lightweight and dependency-free (no yaml import): scalar ``key: value``
            lines become string entries; ``tags`` - inline (``tags: [a, b]`` /
            ``tags: a, b``) or block (``tags:\\n  - a``) - is collected into a lowercased
            set under ``TAGS_KEY``. Anything it can't parse is skipped, never raised:
            frontmatter parsing must not break the graph build.
            """
            m = _FRONTMATTER_RE.match(text)
            if not m:
                return {}
            data: dict[str, object] = {}
            tags: set[str] = set()
            current_list_key: str | None = None
            for raw in m.group(1).splitlines():
                line = raw.rstrip()
                if not line.strip():
                    continue
                item = _FM_LIST_ITEM_RE.match(line)
                if item and current_list_key == "tags":
                    tags.add(item.group(1).strip().strip("\"'").lstrip("#").lower())
                    continue
                if item:
                    continue
                kv = _FM_KV_RE.match(line)
                if not kv:
                    continue
                key = kv.group(1).lower()
                val = kv.group(2).strip()
                current_list_key = key if val == "" else None
                if val == "":
                    continue
                if key == "tags":
                    for tok in _FM_TAG_TOKEN_RE.findall(val):
                        tags.add(tok.lstrip("#").lower())
                else:
                    data[key] = val.strip("\"'")
            if tags:
                data[TAGS_KEY] = tags
            return data
        
        
        def _parse_query_text(text: str) -> VaultQuery:
            """Extract folder / tag / frontmatter-field predicates from query text."""
            folders: set[str] = set()
            tags: set[str] = set()
            fields: list[tuple[str, str]] = []
            for m in _INFOLDER_RE.finditer(text):
                folders.add(m.group(1))
            for m in _FROM_RE.finditer(text):
                clause = m.group(1)
                for q in _QUOTED_RE.finditer(clause):
                    folders.add(q.group(1))
                for t in _HASHTAG_RE.finditer(clause):
                    tags.add(t.group(1).lower())
            for m in _HASTAG_FN_RE.finditer(text):
                tags.add(m.group(1).lower())
            for m in _FIELD_EQ_RE.finditer(text):
                ns = (m.group("ns") or "").lower()
                if ns.startswith("file."):  # file metadata, not a frontmatter field
                    continue
                key = m.group("key").lower()
                if key in _FIELD_KEY_SKIP:
                    continue
                fields.append((key, m.group("val")))
            return VaultQuery(folders=folders, tags=tags, fields=fields)
        
        
        def parse_base_queries(text: str) -> list[VaultQuery]:
            """Parse a ``.base`` file into its navigation query.
        
            A ``.base`` may declare several views, each with its own filter; we fold the
            whole file into one query (folders/tags unioned) because, for navigability,
            the base surfaces every note any of its views selects.
            """
            q = _parse_query_text(text)
            return [q] if not q.is_empty() else []
        
        
        def parse_dataview_queries(text: str) -> list[VaultQuery]:
            """Parse every ```` ```dataview ```` block in a note into its query."""
            out: list[VaultQuery] = []
            for m in _DATAVIEW_BLOCK_RE.finditer(text):
                q = _parse_query_text(m.group(1))
                if not q.is_empty():
                    out.append(q)
            return out
        
        
        def _under_folder(rel: Path, folder: str) -> bool:
            """True if `rel` lives under `folder` (a repo-relative folder path).
        
            An empty / root folder (``""`` or ``"/"``, e.g. a Dataview ``FROM "/"``)
            selects the whole vault - a legitimate "all notes" navigation surface.
            """
            norm = folder.strip().strip("/").replace("\\", "/")
            if norm == "":
                return True
            fparts = tuple(p for p in norm.split("/") if p)
            return rel.parts[:len(fparts)] == fparts
        
        
        def select_notes(
            query: VaultQuery,
            doc_rels: Iterable[tuple[Path, Path]],
            frontmatter_of: Callable[[Path], dict[str, object]],
        ) -> set[Path]:
            """Notes a query selects, as a set of absolute doc paths.
        
            `doc_rels` is ``(absolute_path, repo_relative_path)`` per candidate note;
            `frontmatter_of` lazily yields a note's parsed frontmatter. Selection is the
            **union** of the folder, tag and field predicates (see the module docstring
            for why union, not intersection).
            """
            docs = list(doc_rels)
            selected: set[Path] = set()
            if query.folders:
                for d, r in docs:
                    if any(_under_folder(r, f) for f in query.folders):
                        selected.add(d)
            if query.tags:
                for d, _r in docs:
                    doc_tags = frontmatter_of(d).get(TAGS_KEY)
                    if isinstance(doc_tags, set) and doc_tags & query.tags:
                        selected.add(d)
            if query.fields:
                for d, _r in docs:
                    fm = frontmatter_of(d)
                    for key, val in query.fields:
                        fv = fm.get(key)
                        if isinstance(fv, str) and fv.lower() == val.lower():
                            selected.add(d)
                            break
            return selected
        
      • wiki_writer.py 29.3 KB
        """Render and write the .assess/ wiki files from templates.
        
        No LLM calls. Pure string formatting + file IO. Deterministic.
        """
        from __future__ import annotations
        
        import hashlib
        import re
        from dataclasses import dataclass
        from datetime import datetime
        from pathlib import Path
        
        
        # Templates live alongside the scripts/lib/ package, one directory up under templates/
        _TEMPLATES_DIR = Path(__file__).resolve().parents[2] / "templates"
        
        
        # Default "## Suggested actions" body for a hotspot page that has not (yet) been
        # finalized with file-specific LLM actions. Worded as a deliberate pointer, not a
        # TODO: a page that is never finalized - a hotspot flagged outside the run's Top 3,
        # which assess_finalize is only required to fill for the Top 3 - still reads as
        # intentional rather than as unfinished work. assess_finalize overwrites this
        # section for the pages it's handed concrete actions; the heading is unchanged so
        # that rewrite contract still holds (issue #165).
        UNFINALIZED_ACTIONS_POINTER = (
            "This file is flagged but outside this run's Top 3. "
            "See the report's Top 3 Actions, or run a focused /assess pass "
            "for file-specific guidance."
        )
        
        
        def _growth_profile_line(accretion: dict | None) -> str:
            """One briefing line naming a hotspot's monotonic-growth profile, or "".
        
            ``accretion`` is the per-file accretion-ratchet entry for *this* hotspot
            (the serialized AccretionFile dict: ``net_additions`` / ``commit_count`` /
            ``time_span_months``), plus a ``reliable`` flag threaded down from the scan.
            A file absent from the accretion data (no entry, or None) earns no line -
            growth that wasn't flagged as pure accretion is normal development, not a
            ratchet. When the underlying git history is degenerate (shallow/squashed
            clone) the count is still reported but disclaimed, since the scan can't see
            the full sequence.
            """
            if not accretion:
                return ""
            net = accretion.get("net_additions", 0)
            commits = accretion.get("commit_count", 0)
            months = round(accretion.get("time_span_months", 0))
            line = (
                f"Growth profile: monotonic "
                f"(+{net} LOC, 0 net reductions over {commits} commits in {months} months)."
            )
            if accretion.get("reliable") is False:
                line += " (history may be incomplete - shallow/squashed repo)"
            return line
        
        
        @dataclass(frozen=True)
        class HotspotEntry:
            path: str
            first_flagged: str
            last_seen: str
            status: str   # active | new | graduated | regressed | persistent
            # `ccn` and `loc` are `None` when the file's current metrics are not
            # carried in the latest stats sidecar (e.g. a graduated file that fell
            # off every top-N list). The wiki renders `None` as "-" - the file
            # may still be sized, we just don't have current numbers. Zero is
            # reserved for "actually zero LOC" and must never stand in for
            # "unknown" - that misleads reviewers into thinking the file was
            # emptied (issue #52 Bug 1).
            ccn: int | None
            loc: int | None
        
        
        @dataclass(frozen=True)
        class LogEntry:
            run_date: str
            files_scored: int
            readiness_score: float
            maturity_label: str
            # Optional[str]: None means no instruction file was found at any known
            # location (the schema convention in CLAUDE.md). The log template renders it
            # via str.format, so a None prints as "None" - unchanged from prior runtime
            # behaviour; only the annotation is corrected to match the data.
            instructions_grade: str | None
            graduated_count: int
            regressed_count: int
            new_count: int
            persistent_count: int
            top_action: str
            # Plugin version that produced this entry. Always rendered in the
            # heading so the log doubles as a version history and two runs on the
            # same calendar day stay distinguishable. Optional only for
            # backwards-compat with callers that don't pass it yet; new code
            # should always set it (issue #52 Bug 2).
            plugin_version: str | None = None
            report_link: str = "./assess-report.md"
            # Run provenance (issue: assess-obey-thyself). When set, each appended entry
            # carries a non-rendering HTML-comment stamp so a machine can trace the log
            # line back to the run-context.json that produced it. Optional for
            # backwards-compat with callers that don't pass it yet.
            run_id: str | None = None
            schema_version: str | None = None
        
        
        def _run_id_comment(run_id: str | None, schema_version: str | None) -> str:
            """An HTML-comment provenance line stamping a wiki artifact with its run.
        
            Returns "" when no run_id is supplied so legacy callers (and every test that
            doesn't thread a run_id) produce byte-identical output. HTML comments don't
            render in Markdown, so the stamp is invisible to a human reading the wiki but
            lets a machine trace a page back to the run that wrote it.
            """
            if not run_id:
                return ""
            version = schema_version or "unknown"
            return f"<!-- assess:run_id={run_id} artifact_schema_version={version} -->\n"
        
        
        def slug_for_path(path: str) -> str:
            """Convert a file path into a safe, collision-resistant filename slug.
        
            The slug is the normalized path (alphanumeric joined by hyphens) followed
            by a short hash of the original path. The hash ensures distinct paths
            that normalize identically (e.g., `src/foo-bar.py` vs `src/foo/bar.py`)
            don't overwrite each other's hotspot pages.
            """
            readable = re.sub(r"[^a-zA-Z0-9]+", "-", path).strip("-").lower()
            digest = hashlib.sha256(path.encode("utf-8")).hexdigest()[:8]
            return f"{readable}-{digest}"
        
        
        def _load_template(name: str) -> str:
            return (_TEMPLATES_DIR / name).read_text(encoding="utf-8")
        
        
        def write_index(
            assess_dir: Path, entries: list[HotspotEntry], *, last_updated: str,
            run_id: str | None = None, schema_version: str | None = None,
            scope: str | None = None,
        ) -> None:
            """(Re)write index.md from the current set of hotspot entries.
        
            ``run_id`` / ``schema_version`` (when supplied) prepend a non-rendering
            HTML-comment provenance stamp; omitted, output is byte-identical to before.
        
            ``scope`` (the repo-relative subtree of a ``/assess <path>`` run) adds a
            scope line under the title so the wiki page names what subtree it covers;
            None (a whole-repo run) leaves the body byte-identical to before.
            """
            rows = []
            for e in entries:
                # `None` -> "-" so an unknown metric never reads as "the file was
                # emptied." Real zeros (rare for tracked source code) still render
                # as `0`.
                ccn_cell = "-" if e.ccn is None else str(e.ccn)
                loc_cell = "-" if e.loc is None else str(e.loc)
                rows.append(
                    f"| `{e.path}` | {e.first_flagged} | {e.last_seen} | {e.status} | {ccn_cell} | {loc_cell} |"
                )
            content = _load_template("index.md.template").format(
                last_updated=last_updated,
                hotspot_rows="\n".join(rows) if rows else "| _no hotspots tracked yet_ | | | | | |",
            )
            if scope:
                # Insert a scope line right after the H1 title so a reader (and any
                # committed diff) sees the page is subtree-scoped, not whole-repo.
                content = content.replace(
                    "# Assess Wiki Index\n",
                    f"# Assess Wiki Index\n\n_Scope: `{scope}`_\n",
                    1,
                )
            (assess_dir / "index.md").write_text(
                _run_id_comment(run_id, schema_version) + content, encoding="utf-8"
            )
        
        
        def _short_run_id(run_id: str) -> str:
            """The unique tail of a run id: the random suffix of the orchestrator's
            ``YYYYMMDDHHMMSS-<8 hex>`` form (the date is already in the heading), or the
            whole id when it has no ``-`` separator."""
            return run_id.rsplit("-", 1)[-1] or run_id
        
        
        def _build_log_heading(
            *, run_date: str, plugin_version: str | None, existing: str,
            run_id: str | None = None,
        ) -> str:
            """Build a unique `## ...` heading for a new log.md entry.
        
            Two collisions to defend against (issue #52 Bug 2):
        
            1. The plugin version is always rendered when present (so the log
               doubles as a version history). Two same-day runs at different
               versions are naturally distinguished.
            2. If the same `## YYYY-MM-DD (vX.Y.Z)` heading already exists in
               the file, append the current local time `HH:MM` so anchor links
               don't collide and markdownlint MD024 stays quiet. Using local
               time matches `run_date` (which is also local), so a reader
               doesn't see a timezone mismatch.
        
            When the entry carries a ``run_id`` its short form is always rendered
            (``## YYYY-MM-DD (vX.Y.Z, run <id>)``): ``HH:MM`` cannot separate runs that
            share a minute (#317), and the run id is unique per run. Two distinct ids can
            still share the short suffix, so on a clash the full run id is rendered, and
            a ``#N`` counter follows if even that heading exists. Without a run id the
            legacy behaviour above is unchanged.
            """
            parts = []
            if plugin_version:
                parts.append(f"v{plugin_version}")
            if run_id:
                parts.append(f"run {_short_run_id(run_id)}")
            base = f"## {run_date} ({', '.join(parts)})" if parts else f"## {run_date}"
            if base not in existing:
                return base
            if run_id:
                # A short id is unique per run in practice, but two ids can share an
                # 8-hex suffix: fall back to the full run id, then a counter, so the
                # heading is unique by construction rather than by probability.
                full = base.replace(f"run {_short_run_id(run_id)}", f"run {run_id}", 1)
                candidate, n = full, 2
                while _heading_exists(candidate, existing):
                    candidate = f"{full[:-1]} #{n})"
                    n += 1
                return candidate
            # Already an entry with this exact heading - disambiguate with time.
            stamp = datetime.now().strftime("%H:%M")
            return f"{base[:-1]} {stamp})" if parts else f"{base} {stamp}"
        
        
        def _heading_exists(heading: str, existing: str) -> bool:
            """True when ``heading`` is already a whole line of ``existing``."""
            return heading in existing.splitlines()
        
        
        # --- log.md integrity chain (issue: assess-obey-thyself, task 11) -------------
        #
        # Each appended log entry carries a non-rendering chain marker
        # ``<!-- chain:<hash> -->`` where ``hash = sha256(prev_chain_hash + entry_text)``
        # truncated to 16 hex chars, and ``prev`` is the literal string ``"genesis"`` for
        # the first entry. The chain lets a run verify that no earlier entry has been
        # edited after the fact: tampering with entry N breaks the recomputation at N.
        # This is the guardrail against a *lying history* - the log is meant to be an
        # append-only record, and an unpressured record silently drifts (CLAUDE.md north
        # star: a self-description under no pressure to stay true). The marker is an HTML
        # comment, so it is invisible to a human reading the rendered Markdown.
        _GENESIS = "genesis"
        _CHAIN_LINE_RE = re.compile(r"<!-- chain:([0-9a-f]{16}) -->\n?")
        _LOG_HEADER = "# Assess Log\n\n"
        
        
        def _chain_hash(prev: str, entry_text: str) -> str:
            """The chained checksum for an entry: sha256(prev + entry_text)[:16].
        
            Deterministic for identical ``(prev, entry_text)`` - the same content always
            yields the same marker, so a clean re-run reproduces the chain byte-for-byte.
            """
            return hashlib.sha256((prev + entry_text).encode("utf-8")).hexdigest()[:16]
        
        
        def _parse_log_entries(text: str) -> list[tuple[str, str | None]]:
            """Split a log.md body into ``(entry_text, stored_chain_hash)`` pairs.
        
            ``entry_text`` excludes the trailing chain-marker line so it hashes exactly as
            it was written. An entry with no marker (a legacy log predating the chain, or
            a hand-authored tail) yields a ``None`` stored hash - unverifiable, not a break.
            """
            body = text[len(_LOG_HEADER):] if text.startswith(_LOG_HEADER) else text
            entries: list[tuple[str, str | None]] = []
            pos = 0
            for m in _CHAIN_LINE_RE.finditer(body):
                entries.append((body[pos:m.start()], m.group(1)))
                pos = m.end()
            tail = body[pos:]
            if tail.strip():
                entries.append((tail, None))
            return entries
        
        
        def _chain_tail(text: str) -> tuple[str, str]:
            """Return ``(prev_hash, trailing)`` for chaining a new entry onto ``text``.
        
            ``prev_hash`` is the last *stored* chain marker's hash (or ``"genesis"`` when
            there is none). ``trailing`` is any unchained text after that last marker -
            ``""`` for a normal chained log, but the whole body for a legacy log that has
            no markers yet. A new entry hashes ``prev_hash`` over ``trailing + entry_text``
            because ``_parse_log_entries`` groups everything between two markers into one
            entry: the trailing legacy text has no delimiter of its own, so on verify it
            is read as part of the next entry, and append must hash it the same way.
            """
            # Strip the file header exactly as _parse_log_entries does, so the trailing
            # span append hashes matches the entry content verify re-reads.
            body = text[len(_LOG_HEADER):] if text.startswith(_LOG_HEADER) else text
            prev = _GENESIS
            end = 0
            for m in _CHAIN_LINE_RE.finditer(body):
                prev = m.group(1)
                end = m.end()
            return prev, body[end:]
        
        
        def _verify_chain_text(text: str) -> tuple[bool, int | None]:
            """Verify the integrity chain of an in-memory log body.
        
            Returns ``(valid, broken_at_n)``: ``broken_at_n`` is the 1-based index of the
            first entry whose stored hash disagrees with a recomputation from the prior
            hash plus its content, or ``None`` when the chain is intact. Unchained (legacy)
            entries can't be verified, so they advance the running hash from their content
            without being flagged - a chained entry appended after them still validates.
            """
            prev = _GENESIS
            for n, (content, stored) in enumerate(_parse_log_entries(text), start=1):
                if stored is None:
                    prev = _chain_hash(prev, content)
                    continue
                if stored != _chain_hash(prev, content):
                    return False, n
                prev = stored
            return True, None
        
        
        def verify_log_chain(assess_dir: Path) -> tuple[bool, int | None]:
            """Verify the log.md integrity chain on disk. See ``_verify_chain_text``.
        
            A missing log (genesis / fresh install) is vacuously valid - there is no prior
            entry to contradict.
            """
            log_path = assess_dir / "log.md"
            if not log_path.exists():
                return True, None
            return _verify_chain_text(log_path.read_text(encoding="utf-8"))
        
        
        # --- log entry targeting and re-chain (issue #355) ----------------------------
        #
        # The core writes each entry with placeholders the LLM finalize fills later. An
        # entry that still carries LOG_PLACEHOLDER belongs to a run that was never
        # finalized. Entries are addressed by the ``assess:run_id`` stamp they carry, and
        # any in-place change goes through ``rewrite_log_entry`` so the chain markers of
        # the changed entry and every later one are recomputed: an edit made by the tool
        # itself must not read as tampering on the next verify.
        LOG_PLACEHOLDER = "(LLM fills in)"
        _RUN_ID_STAMP_RE = re.compile(r"<!-- assess:run_id=(\S+) ")
        _HEADING_DATE_RE = re.compile(r"^## (\d{4}-\d{2}-\d{2})", re.MULTILINE)
        
        
        def log_entry_is_unfinalized(content: str) -> bool:
            """True when a log entry still carries the core's unfilled placeholders."""
            return LOG_PLACEHOLDER in content
        
        
        def log_entry_run_id(content: str) -> str | None:
            """The run id an entry's ``assess:run_id`` stamp names, or None (legacy)."""
            m = _RUN_ID_STAMP_RE.search(content)
            return m.group(1) if m else None
        
        
        def log_entry_owns_span(content: str, run_id: str) -> bool:
            """True when the entry text begins with ``run_id``'s own stamp.
        
            On a log written before the chain existed, the unchained legacy body and the
            first chained entry parse as one span (see ``_chain_tail``). Such a span
            carries the run's stamp but not at its start; removing or replacing it would
            take the whole legacy history with it, so callers that drop an entry require
            this to hold.
            """
            return content.startswith(f"<!-- assess:run_id={run_id} ")
        
        
        def log_entry_date(content: str) -> str | None:
            """The ``YYYY-MM-DD`` date of an entry's first ``## `` heading, or None."""
            m = _HEADING_DATE_RE.search(content)
            return m.group(1) if m else None
        
        
        def read_log_entries(assess_dir: Path) -> list[str]:
            """The entry texts of log.md in file order (chain markers stripped).
        
            An index into this list is what ``rewrite_log_entry`` takes. A legacy log
            with no chain markers reads as a single entry.
            """
            log_path = assess_dir / "log.md"
            if not log_path.exists():
                return []
            return [c for c, _ in _parse_log_entries(log_path.read_text(encoding="utf-8"))]
        
        
        def find_log_entry(assess_dir: Path, run_id: str) -> int | None:
            """Index of the log entry stamped with ``run_id``, or None."""
            for i, content in enumerate(read_log_entries(assess_dir)):
                if log_entry_run_id(content) == run_id:
                    return i
            return None
        
        
        def rewrite_log_entry(assess_dir: Path, index: int, new_content: str | None) -> None:
            """Replace (or, with ``None``, remove) log entry ``index`` and re-chain.
        
            The chain is recomputed from the entry's predecessor: the rewritten entry and
            every later one get fresh markers. Only entries whose stored marker verified
            before the rewrite are re-stamped; the walk stops at the first entry that was
            already broken, so a pre-existing break stays detectable rather than being
            blessed by the re-chain.
            """
            log_path = assess_dir / "log.md"
            text = log_path.read_text(encoding="utf-8")
            entries = _parse_log_entries(text)
            # Local validity before the rewrite: entry k verifies against entry k-1 alone.
            prev = _GENESIS
            was_valid: list[bool] = []
            for content, stored in entries:
                was_valid.append(stored is None or stored == _chain_hash(prev, content))
                prev = stored if stored is not None else _chain_hash(prev, content)
            if new_content is None:
                del entries[index]
                del was_valid[index]
            else:
                entries[index] = (new_content, entries[index][1])
            out: list[str] = []
            prev = _GENESIS
            rechaining = True
            for k, (content, stored) in enumerate(entries):
                if k >= index and rechaining:
                    if not was_valid[k]:
                        rechaining = False
                    elif stored is not None:
                        stored = _chain_hash(prev, content)
                out.append(content if stored is None else f"{content}<!-- chain:{stored} -->\n")
                prev = stored if stored is not None else _chain_hash(prev, content)
            header = _LOG_HEADER if text.startswith(_LOG_HEADER) else ""
            log_path.write_text(header + "".join(out), encoding="utf-8")
        
        
        def last_log_entry_is_unfinalized_run(assess_dir: Path, run_id: str) -> bool:
            """True when the log's last entry is ``run_id``'s own and still unfinalized.
        
            This is the condition under which ``supersede_unfinalized_log_entry`` acts,
            exposed so the core can learn before it writes the wiki that the previous
            run was never finalized (#356).
            """
            return _last_entry_is_unfinalized_run(read_log_entries(assess_dir), run_id)
        
        
        def _last_entry_is_unfinalized_run(entries: list[str], run_id: str) -> bool:
            if not entries:
                return False
            last = entries[-1]
            return log_entry_owns_span(last, run_id) and log_entry_is_unfinalized(last)
        
        
        def supersede_unfinalized_log_entry(assess_dir: Path, run_id: str) -> bool:
            """Remove the last log entry when it is ``run_id``'s and still unfinalized.
        
            The caller decides the run is superseded (same date, same measured commit);
            this only acts when the log's last entry is that run's and carries unfilled
            placeholders. A finalized entry, or any entry that is not the last, is never
            removed, and neither is a span that also holds unchained legacy history.
            Returns True when an entry was removed.
            """
            entries = read_log_entries(assess_dir)
            if not _last_entry_is_unfinalized_run(entries, run_id):
                return False
            rewrite_log_entry(assess_dir, len(entries) - 1, None)
            return True
        
        
        def append_log_entry(assess_dir: Path, entry: LogEntry) -> None:
            """Append a dated entry to log.md (create the file if absent).
        
            Before appending, the existing chain is verified; if a prior entry was edited
            the new entry leads with a one-time disclosure line so a reader can't miss that
            the history is compromised. Every appended entry then carries its own chain
            marker (see the chain block above).
            """
            log_path = assess_dir / "log.md"
            existing = log_path.read_text(encoding="utf-8") if log_path.exists() else ""
            heading = _build_log_heading(
                run_date=entry.run_date,
                plugin_version=entry.plugin_version,
                existing=existing,
                run_id=entry.run_id,
            )
            snippet = _load_template("log_entry.md.template").format(
                heading=heading,
                files_scored=entry.files_scored,
                readiness_score=entry.readiness_score,
                maturity_label=entry.maturity_label,
                instructions_grade=entry.instructions_grade,
                graduated_count=entry.graduated_count,
                regressed_count=entry.regressed_count,
                new_count=entry.new_count,
                persistent_count=entry.persistent_count,
                top_action=entry.top_action,
                report_link=entry.report_link,
            )
            # Verify the chain of what's already on disk. On a break, lead this entry with
            # a disclosure line - but only once: if the exact line is already in the log a
            # prior run already caught this break, so we don't spam it every run.
            valid, broken_at = _verify_chain_text(existing)
            disclosure = ""
            if not valid:
                line = (
                    f"> **Warning:** History integrity broken at entry {broken_at}. "
                    "Prior entries may have been modified."
                )
                if line not in existing:
                    disclosure = line + "\n\n"
            # Stamp this entry (not the whole file) so the log stays a per-run history:
            # each run's line carries its own run_id. "" when no run_id is set, keeping
            # the appended snippet byte-identical for legacy callers.
            entry_text = _run_id_comment(entry.run_id, entry.schema_version) + disclosure + snippet
            # Chain this entry to the prior one so a later edit is detectable. Hash over
            # any unchained trailing text (a legacy log's body) plus this entry, keyed off
            # the last stored marker - that is exactly the span verify re-reads as one
            # entry (see _chain_tail).
            prev_hash, trailing = _chain_tail(existing)
            chain = _chain_hash(prev_hash, trailing + entry_text)
            entry_text += f"<!-- chain:{chain} -->\n"
            base = existing if existing else _LOG_HEADER
            log_path.write_text(base + entry_text, encoding="utf-8")
        
        
        def write_hotspot_page(
            assess_dir: Path,
            *,
            path: str,
            first_flagged: str,
            last_seen: str,
            status: str,
            loc: int,
            ccn: int,
            commits: int,
            has_tests: bool | None,
            history_rows: str,
            briefing: str,
            actions: str,
            accretion_data: dict | None = None,
            run_id: str | None = None,
            schema_version: str | None = None,
        ) -> None:
            """(Re)write hotspots/<slug>.md.
        
            has_tests=None means "we don't know yet" - shown as "unknown" in the page.
            Test-to-code pairing is a deferred feature; honest reporting beats lying.
        
            ``accretion_data`` is the accretion-ratchet entry for *this* file (the
            serialized AccretionFile dict plus the scan's ``reliable`` flag), or None
            when the file isn't accreting. When present, one growth-profile line is
            appended to the briefing - no new section header - so the page names the
            monotonic-growth tendency right where an agent is briefed before editing.
            """
            hotspots_dir = assess_dir / "hotspots"
            hotspots_dir.mkdir(exist_ok=True)
            if has_tests is None:
                has_tests_str = "unknown"
            else:
                has_tests_str = "yes" if has_tests else "no"
            growth = _growth_profile_line(accretion_data)
            if growth:
                briefing = f"{briefing} {growth}"
            content = _load_template("hotspot.md.template").format(
                path=path,
                first_flagged=first_flagged,
                last_seen=last_seen,
                status=status,
                loc=loc,
                ccn=ccn,
                commits=commits,
                has_tests=has_tests_str,
                history_rows=history_rows,
                briefing=briefing,
                actions=actions,
            )
            (hotspots_dir / f"{slug_for_path(path)}.md").write_text(
                _run_id_comment(run_id, schema_version) + content, encoding="utf-8"
            )
        
        
        # --- orphan hotspot pruning (issue: assess-obey-thyself, task 9) --------------
        #
        # A hotspot page whose source file has been deleted is a lying map: it keeps
        # describing a file that no longer exists, and its status token still reads
        # "active" (or new/persistent/...). Rather than delete the page - the .assess/
        # wiki is a *compounding* history where past hotspots stay visible even after
        # they graduate - each run stamps an orphaned page RETIRED. History is preserved,
        # but the page no longer claims to describe a live file. This mirrors the
        # graduated-hotspot idiom (a page that survives after the file leaves the top
        # list) rather than the deletion idiom, which the wiki has none of.
        RETIRED_STATUS = "retired - file deleted"
        # A file first flagged by a run that was never finalized, then excluded by
        # `.assess/config.toml` before the superseding run (#356). The file may still be
        # on disk, so this is a separate wording; every retired status begins "retired".
        RETIRED_EXCLUDED_STATUS = "retired - excluded before finalize"
        _RETIRED_PREFIX = "retired"
        
        # The source path a hotspot page describes lives in its `# Hotspot: `<path>``
        # heading (there is no YAML frontmatter). The status lives in the italic
        # metadata line `_First flagged: .... Status: <status>._`.
        _HOTSPOT_PATH_RE = re.compile(r"^# Hotspot: `(?P<path>.+?)`", re.MULTILINE)
        _HOTSPOT_STATUS_RE = re.compile(r"(?P<prefix>Status: )(?P<status>.+?)(?P<suffix>\._)")
        
        
        def hotspot_page_source_path(content: str) -> str | None:
            """The source file path a hotspot page describes, from its heading, or None."""
            m = _HOTSPOT_PATH_RE.search(content)
            return m.group("path") if m else None
        
        
        def hotspot_page_status(content: str) -> str | None:
            """The status token a hotspot page carries in its metadata line, or None."""
            m = _HOTSPOT_STATUS_RE.search(content)
            return m.group("status") if m else None
        
        
        def prune_orphan_hotspots(assess_dir: Path, repo_root: Path) -> list[str]:
            """Stamp every hotspot page whose source file is absent from disk as retired.
        
            Returns the sorted list of source paths retired *this* call (already-retired
            pages and live-file pages are left untouched, so the operation is idempotent).
            A retired page keeps all its history; only its status token flips and a visible
            retirement banner is inserted, so no active page ever references a missing file.
            """
            hotspots_dir = assess_dir / "hotspots"
            if not hotspots_dir.is_dir():
                return []
            retired: list[str] = []
            for page in sorted(hotspots_dir.glob("*.md")):
                content = page.read_text(encoding="utf-8")
                path = hotspot_page_source_path(content)
                if path is None:
                    continue  # not a recognisable hotspot page - leave it alone
                if is_retired_status(hotspot_page_status(content)):
                    continue  # already retired (for any reason) - idempotent
                if (repo_root / path).exists():
                    continue  # source still on disk - a legitimate hotspot, untouched
                _stamp_retired(page, content, RETIRED_STATUS, (
                    "the source file was absent from disk at the latest "
                    "run (deleted, moved, or renamed). This page is preserved for history "
                    "and no longer describes a live file."
                ))
                retired.append(path)
            return sorted(retired)
        
        
        def retire_excluded_hotspots(
            assess_dir: Path, paths: list[str],
        ) -> tuple[list[str], list[str]]:
            """Stamp the pages of ``paths`` retired as excluded before finalize (#356).
        
            The caller picks the paths: excluded by config and first flagged only by a
            run that was never finalized. Returns ``(retired, unstamped)``, both sorted:
            the paths whose page this call retired, and the paths whose page exists but
            carries no status token to stamp (left as-is, so the caller can keep their
            first-flagged entries). A path with no page, or whose page is already
            retired, is in neither list.
            """
            retired: list[str] = []
            unstamped: list[str] = []
            for path in sorted(set(paths)):
                page = assess_dir / "hotspots" / f"{slug_for_path(path)}.md"
                if not page.exists():
                    continue
                content = page.read_text(encoding="utf-8")
                status = hotspot_page_status(content)
                if status is None:
                    unstamped.append(path)
                    continue
                if is_retired_status(status):
                    continue
                _stamp_retired(page, content, RETIRED_EXCLUDED_STATUS, (
                    "this file was first flagged by a run that was never finalized and "
                    "is now excluded by `.assess/config.toml`. This page is preserved for "
                    "history and no longer describes a live hotspot."
                ))
                retired.append(path)
            return retired, unstamped
        
        
        def is_retired_status(status: str | None) -> bool:
            """True for any retired status token: every one begins with ``retired``."""
            return status is not None and status.startswith(_RETIRED_PREFIX)
        
        
        def _stamp_retired(page: Path, content: str, status: str, reason: str) -> None:
            """Flip the page's status token to ``status`` and add a retirement banner."""
            banner = f"\n> **Retired:** {reason}"
            stamped = _HOTSPOT_STATUS_RE.sub(
                lambda m: f"{m.group('prefix')}{status}{m.group('suffix')}{banner}",
                content, count=1,
            )
            page.write_text(stamped, encoding="utf-8")
        
      • __init__.py 446 B
        """Deterministic core modules for /assess.
        
        Public surface:
            agent_instructions_grader: heuristic scoring of agent instruction files
                                       (CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules,
                                       .github/copilot-instructions.md)
            stats_diff:                compare current vs prior complexity stats
            wiki_writer:               render wiki MD files from templates
        """
        
        __version__ = "0.1.0"
        
    • assess_core.py 85 KB
      """Orchestrator for the deterministic core of /assess.
      
      Reads:
          {repo_root}/.assess/complexity-stats.json       (current run)
          {repo_root}/.assess/complexity-stats.prior.json (if it exists)
          {repo_root}/CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, .github/copilot-instructions.md (any that exist)
      
      Writes:
          {repo_root}/.assess/run-context.json   (everything the LLM needs)
          {repo_root}/.assess/index.md           (regenerated each run)
          {repo_root}/.assess/log.md             (appended each run)
          {repo_root}/.assess/hotspots/*.md      (one per top hotspot)
      
      Run:
          uv run assess_core.py <repo_root>
      
      The LLM still writes assess-report.md (the prose-heavy summary).
      The LLM reads run-context.json to ground that prose in deterministic data.
      """
      # /// script
      # requires-python = ">=3.11"
      # dependencies = [
      #     "networkx",
      #     "grimp",
      # ]
      # ///
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      import uuid
      from datetime import datetime
      from pathlib import Path
      from typing import Any
      
      # Make sibling lib package importable when run as a script
      sys.path.insert(0, str(Path(__file__).resolve().parent))
      
      from lib.accretion_ratchet import scan_accretion_ratchet
      from lib.agent_instructions_grader import (
          detect_alias,
          detect_skills_dir,
          grade_instructions,
          scan_sensitive_content,
      )
      from lib.agent_ops import scan_agent_ops
      from lib.anomaly_detector import detect_anomalies
      from lib.archetype import analyze_archetype
      from lib.badge import (
          concern_count_from_findings,
          fallback_badge,
          write_badge,
      )
      from lib.assess_config import (
          is_user_excluded, load_excludes, load_structure_config, load_working_notes_config,
      )
      from lib.change_coupling import build_rename_map
      from lib.config_drift import scan_config_drift
      from lib.coverage_report import detect_coverage_report, load_coverage_data
      from lib.decline_markers import build_decline_block
      from lib.gate_cost import estimate_gate_cost
      from lib.instruction_claims import scan_instruction_claims
      from lib.interactivity import build_offers_block
      from lib.doc_graph import build_doc_graph, is_repo_file
      from lib.gap_actions import build_gap_actions
      from lib.doc_staleness import analyze_doc_staleness, content_clock
      from lib.generated_files import matches_generated_name
      from lib.git_churn import ContentClock, git_commit_info, tracked_files
      from lib.keyhole_signals import integrate as integrate_keyhole_signals
      from lib.liveness_scan import scan_liveness
      from lib.promissory_markers import scan_promissory_markers
      from lib.review_reality import scan_review_reality
      from lib.structure_graph import analyze_structure
      from lib.stats_diff import StatsDiff, diff_stats, hotspot_commits, load_stats
      from lib.structure_drift import (
          SEAM_ALLOWLIST,
          detect_path_existence_drift,
      )
      from lib.sibling_tests import TestIndex, build_test_index, has_sibling_test, shared_name_keys
      from lib.test_focus import compute_test_focus, mutation_scope
      from lib.test_pressure import scan_test_pressure
      from lib.wiki_writer import (
          UNFINALIZED_ACTIONS_POINTER,
          HotspotEntry,
          LogEntry,
          append_log_entry,
          last_log_entry_is_unfinalized_run,
          prune_orphan_hotspots,
          retire_excluded_hotspots,
          supersede_unfinalized_log_entry,
          verify_log_chain,
          write_hotspot_page,
          write_index,
      )
      
      
      # Artifact schema version, stamped on every artifact the run produces
      # (run-context.json, the badge, the wiki pages, complexity-stats). Distinct from
      # the stats-layout `schema_version` (from #244) that versions the sidecar shape
      # for diff comparability: this one versions the run_id provenance envelope.
      # Bumped when the cross-artifact provenance schema changes shape in a way a
      # consumer must adapt to.
      # 1.1.0: run-context.json doc_graph gains link_only_orphan_rate /
      # link_only_reachability_pct, and its orphan_rate / reachability_pct now count
      # reference edges (#353). complexity-stats.json is unchanged, so its layout
      # STATS_SCHEMA_VERSION stays put and the cross-run diff stays armed.
      ARTIFACT_SCHEMA_VERSION = "1.1.0"
      
      
      def _new_run_id() -> str:
          """A unique id for this run: a sortable wall-clock stamp plus random suffix.
      
          ``YYYYMMDDHHMMSS-<8 hex>`` - the timestamp orders runs, the uuid suffix makes
          two runs in the same second still distinct. Stamped on every artifact so
          finalize can prove the finalize-input and run-context came from one run.
          """
          return f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}"
      
      
      # Known agent instruction file locations (relative to repo root).
      # The same heuristic grader applies to all of them.
      INSTRUCTION_FILE_PATHS = [
          # Canonical repo-root locations
          "CLAUDE.md",
          "AGENTS.md",
          "GEMINI.md",
          ".cursorrules",
          # Tool-specific locations under .github/
          ".github/copilot-instructions.md",
          ".github/claude-instructions.md",
          ".github/claude-review-instructions.md",
          # docs/ subdirectory variants used by some projects
          "docs/CLAUDE.md",
          "docs/AGENTS.md",
      ]
      
      # Grade ranking (best -> worst) for picking a top-level grade across multiple files.
      GRADE_RANK = {"A": 7, "A-": 6, "B+": 5, "B": 4, "C": 3, "D": 2, "F": 1}
      
      
      def _file_freshness_days(file_path: Path, clock: ContentClock) -> int:
          """Days since file_path's last content change in git. 0 if not in git.
      
          Same clock as `.doc_staleness` (author time, bulk mechanical commits
          skipped - issue #333), so a licence-header sweep cannot make a stale
          instruction file read as fresh.
          """
          days = clock.days(file_path)
          return days if days is not None else 0
      
      
      def _grade_instruction_files(
          repo_root: Path,
      ) -> tuple[dict[str, dict], str | None, list[str], list[dict], dict, dict]:
          """Scan all known instruction file locations and grade each one found.
      
          Returns: (files_dict, best_grade, untracked, dangling_refs, skills_info, sensitive)
              files_dict: keyed by filename, e.g. {"CLAUDE.md": {grade, score, ...}}.
              best_grade: best letter grade across all *tracked, present* files; None
                  if none found. None is distinct from "F": None means no committed
                  file exists ("create the file"), "F" means one exists but scored
                  poorly ("fix the file").
              untracked: instruction files that exist on disk but aren't part of the
                  repo (untracked / git-ignored / symlinked from outside). Surfaced as
                  a finding so a personal CLAUDE.md isn't silently credited *or*
                  silently ignored - the agent should note it isn't committed.
              dangling_refs: instruction files that are dangling symlinks (committed
                  `.cursorrules -> missing-target`) - an advertised-but-broken
                  instruction surface.
              sensitive: per-path list of REDACTED sensitive-content findings (IPs,
                  SSH/host details, credentials, home-dir/PII paths) for any candidate
                  on disk - tracked or untracked. Surfaced so the remediation warns
                  before recommending a file be committed, especially to a public repo
                  (issue #56).
          """
          repo_root = repo_root.resolve()
          tracked = tracked_files(repo_root)
          # Detect skills directories once, before grading any file. A repo that
          # factors guidance into on-demand skills uses progressive disclosure, so a
          # large instruction file is not penalized as bloat (see compute_bloat_penalty).
          skills_info = detect_skills_dir(repo_root)
          skills_present = skills_info["skills_dirs_present"]
          clock = content_clock(repo_root)  # one build for every candidate
          found: dict[str, dict] = {}
          untracked: list[str] = []
          dangling_refs: list[dict] = []
          sensitive: dict[str, list] = {}
          for rel_path in INSTRUCTION_FILE_PATHS:
              candidate = repo_root / rel_path
              # A dangling symlink (an instruction file pointing at a missing target)
              # exists() == False but is_symlink() == True - an advertised, broken
              # instruction reference, not "no file".
              if candidate.is_symlink() and not candidate.exists():
                  dangling_refs.append({"path": rel_path, "reason": "symlink target missing"})
                  continue
              if not candidate.exists():
                  continue
              # Scan every candidate on disk for content unsafe to publish - tracked
              # OR untracked. An untracked file is exactly the one the remediation
              # might tell the user to commit, so it must be scanned before that.
              try:
                  disk_text = candidate.read_text(encoding="utf-8")
              except (OSError, UnicodeDecodeError):
                  disk_text = ""
              flags = scan_sensitive_content(disk_text) if disk_text else []
              if flags:
                  sensitive[rel_path] = flags
              # Only grade genuine repo files. An on-disk-but-untracked instruction
              # file (a contributor's personal CLAUDE.md, or one symlinked in from
              # outside) is recorded as a finding rather than credited to the score.
              if not is_repo_file(candidate, repo_root, tracked):
                  untracked.append(rel_path)
                  continue
              text = disk_text
              freshness = _file_freshness_days(candidate, clock)
              grade = grade_instructions(
                  text, freshness_days=freshness, skills_present=skills_present
              )
              entry = {
                  "grade": grade.grade,
                  "score": grade.score,
                  "subscores": grade.subscores,
                  "freshness_days": freshness,
                  "line_count": len(text.splitlines()),
                  "present": True,
              }
              # Alias detection (issue #57): a committed AGENTS.md/GEMINI.md that is a
              # symlink to - or a thin stub pointing at - a canonical instruction file
              # is the desired single-source-of-truth shape, not a low-scoring bespoke
              # doc. Record the target so the second pass can inherit its grade.
              alias_target = _alias_target(candidate, text, repo_root)
              if alias_target:
                  entry["alias_target_basename"] = alias_target
              found[rel_path] = entry
      
          _resolve_alias_grades(found)
      
          best = (max(found.values(), key=lambda v: GRADE_RANK.get(v["grade"], 0))["grade"]
                  if found else None)
          return found, best, untracked, dangling_refs, skills_info, sensitive
      
      
      # Basenames (lowercased) of the known instruction files, for cross-referencing
      # broken doc links against the instruction surface.
      _INSTRUCTION_BASENAMES = {Path(p).name.lower() for p in INSTRUCTION_FILE_PATHS}
      
      # Canonical files an alias would point at (a single source of truth).
      _CANONICAL_ALIAS_TARGETS = {"claude.md", "agents.md", "gemini.md"}
      
      
      def _alias_target(candidate: Path, text: str, repo_root: Path) -> str | None:
          """Return the canonical basename this file aliases, or None.
      
          Two shapes count as an alias (issue #57):
            * a symlink whose target is a canonical instruction file, or
            * a thin stub whose only real content references a canonical file.
          The alias's own basename is excluded, so CLAUDE.md never aliases itself.
          """
          self_name = candidate.name.lower()
          # Symlink alias - the target is whatever the link resolves to.
          if candidate.is_symlink():
              try:
                  target_name = candidate.resolve().name.lower()
              except OSError:
                  target_name = ""
              if target_name in _CANONICAL_ALIAS_TARGETS and target_name != self_name:
                  return candidate.resolve().name
          # Thin-stub alias - short file that just points at a canonical doc.
          alias = detect_alias(text)
          if alias["is_alias"] and alias["alias_target"]:
              if alias["alias_target"].lower() != self_name:
                  return alias["alias_target"]
          return None
      
      
      def _resolve_alias_grades(found: dict[str, dict]) -> None:
          """Let an alias inherit the grade of the canonical file it points at.
      
          A thin alias/symlink should grade as the single-source-of-truth it routes
          to, not as a low-scoring standalone doc that the remediation would tell the
          user to rewrite. Mutates ``found`` in place: marks ``is_alias`` and copies
          the target's grade/score when the target is itself graded.
          """
          by_basename = {Path(rel).name.lower(): meta for rel, meta in found.items()}
          for meta in found.values():
              target = meta.pop("alias_target_basename", None)
              if not target:
                  continue
              target_meta = by_basename.get(target.lower())
              meta["is_alias"] = True
              meta["alias_target"] = target
              if target_meta is not None and target_meta is not meta:
                  # Inherit the canonical grade - the alias is as good as what it
                  # points at, and carries no maintenance burden of its own.
                  meta["grade"] = target_meta["grade"]
                  meta["score"] = target_meta["score"]
      
      
      def detect_ancestor_instructions(repo_root: Path) -> list[str]:
          """Detect committed-elsewhere instruction files that cascade into this repo.
      
          Claude Code composes ``CLAUDE.md`` from every ancestor directory plus the
          global ``~/.claude/CLAUDE.md``. So "no instruction file at the repo root" is
          not the same as "no instructions anywhere" - a clone gets none of the
          ancestor cascade, but the maintainer working in-tree does (issue #57).
      
          Returns REDACTED, repo-relative / ``~``-relative descriptors (never absolute
          paths - those would leak a home directory into the committed wiki). Best
          effort: any filesystem error yields an empty list.
          """
          found: list[str] = []
          try:
              repo_root = repo_root.resolve()
              # Walk parent directories above the repo root (bounded depth).
              parent = repo_root.parent
              depth = 1
              while parent != parent.parent and depth <= 6:
                  for name in ("CLAUDE.md", "AGENTS.md", "GEMINI.md"):
                      if (parent / name).is_file():
                          found.append(f"{name} ({depth} level(s) above repo root)")
                  parent = parent.parent
                  depth += 1
              # The global user instructions, if present.
              for rel in (".claude/CLAUDE.md", ".codex/AGENTS.md", ".gemini/GEMINI.md"):
                  if (Path.home() / rel).is_file():
                      found.append(f"~/{rel} (global user instructions)")
          except OSError:
              return []
          return found
      
      
      def _broken_instruction_refs(doc_graph: dict, dangling_refs: list[dict]) -> list[dict]:
          """Combine dangling-symlink instruction files with broken doc links whose
          target is an instruction file (an entry doc linking a missing CLAUDE.md).
          These are advertised-but-broken instruction references."""
          refs = list(dangling_refs)
          if doc_graph.get("available"):
              for bl in doc_graph.get("broken_links", []):
                  target = bl.get("target", "")
                  if Path(target).name.lower() in _INSTRUCTION_BASENAMES:
                      refs.append({
                          "from": bl.get("from"), "target": target,
                          "reason": "link to missing instruction file",
                      })
          return refs
      
      
      def _read_plugin_version() -> str:
          """Read the plugin version from .claude-plugin/plugin.json.
      
          The plugin.json lives three directories up from this script:
              scripts/assess_core.py -> scripts/ -> skills/assess/ -> skills/ -> repo root
          """
          plugin_json = Path(__file__).resolve().parents[3] / ".claude-plugin" / "plugin.json"
          try:
              data = json.loads(plugin_json.read_text(encoding="utf-8"))
              return str(data.get("version", "unknown"))
          except (FileNotFoundError, json.JSONDecodeError):
              return "unknown"
      
      
      def _parse_semver(value: str | None) -> tuple[int, int, int] | None:
          """Parse ``MAJOR.MINOR.PATCH`` (leading ``v`` and a pre-release/build
          suffix tolerated) into an int triple, or ``None`` when it isn't a semver.
      
          A small local parse rather than a ``packaging`` dependency: the deterministic
          core is stdlib-only by convention (its only deps are the graph libs), and the
          only comparison the diff needs is the major component and equality.
          """
          if not isinstance(value, str):
              return None
          m = re.match(r"^\s*v?(\d+)\.(\d+)\.(\d+)", value)
          if not m:
              return None
          return int(m.group(1)), int(m.group(2)), int(m.group(3))
      
      
      def _diff_is_reliable(
          prior_version: str | None,
          current_version: str | None,
          prior_schema: object | None,
          current_schema: object | None,
      ) -> tuple[bool, str | None]:
          """Decide whether a cross-run diff can be trusted, and why not if not.
      
          A diff is only trustworthy when both snapshots came from a comparable
          toolchain. This gates the plugin-version and stats-schema halves of that
          (tool-backend version changes are handled by the caller, which owns the
          per-tool notes). Ordering matches the failure severity:
      
          - prior snapshot never stamped a version -> unreliable (can't establish
            comparability at all);
          - either version unparseable -> unreliable (can't reason about the delta);
          - stats schema changed -> unreliable (the sidecar shape the diff reads moved);
          - MAJOR version changed -> unreliable AND a trend reset (breaking change to
            the deterministic core; prior history is not comparable);
          - only MINOR/PATCH moved -> reliable, trend and gate stay armed.
      
          Returns ``(reliable, note)``; ``note`` is ``None`` exactly when reliable.
          """
          if not prior_version:
              return False, "version not stamped in prior snapshot"
          pv = _parse_semver(prior_version)
          cv = _parse_semver(current_version)
          if pv is None or cv is None:
              return False, (
                  f"unparseable plugin version (prior {prior_version!r}, "
                  f"current {current_version!r})"
              )
          if prior_schema != current_schema:
              return False, f"schema version changed {prior_schema}->{current_schema}"
          if pv[0] != cv[0]:
              return False, f"major version changed {prior_version}->{current_version}"
          return True, None
      
      
      _NON_TOOL_VERSION_KEYS = frozenset(
          {"schema_version", "artifact_schema_version", "plugin_version"})
      
      
      def _stats_tool_versions(stats: dict | None) -> dict[str, str]:
          """Extract the ``{tool: version}`` map a stats sidecar stamped.
      
          Reads every flat ``<tool>_version`` key the treemap writes (``lizard_version``,
          ``scc_version``, and any per-function backend added later), skipping the
          layout and plugin stamps. A tool absent from a pre-stamping snapshot is
          simply omitted - it can't be compared, so it never forces a false reset."""
          if not isinstance(stats, dict):
              return {}
          out: dict[str, str] = {}
          for key, v in stats.items():
              if (key.endswith("_version") and key not in _NON_TOOL_VERSION_KEYS
                      and isinstance(v, str) and v):
                  out[key[: -len("_version")]] = v
          return out
      
      
      def _compute_diff_reliability(
          prior_exists: bool, prior: dict | None, current: dict,
      ) -> tuple[bool, str | None, bool]:
          """Decide whether the cross-run diff is trustworthy, returning
          ``(diff_reliable, diff_version_note, diff_trend_reset)``.
      
          Layers the plugin/schema check (``_diff_is_reliable``) over the tool-backend
          check (``_tool_version_change_note``): a MINOR/PATCH plugin bump keeps the
          diff armed unless a complexity backend also moved. A first run (no prior) is
          trivially reliable - there is nothing to compare, so nothing to distrust.
          """
          if not prior_exists:
              return True, None, False
          reliable, note = _diff_is_reliable(
              (prior or {}).get("plugin_version"),
              current.get("plugin_version"),
              (prior or {}).get("schema_version"),
              current.get("schema_version"),
          )
          if not reliable:
              # A MAJOR plugin bump is a breaking change to the core: the prior trend
              # history is not comparable, so the report discloses a reset.
              trend_reset = bool(note and note.startswith("major version changed"))
              return False, note, trend_reset
          # Plugin/schema are comparable; a backend version change still voids the diff
          # (and names which tool moved) so the numbers aren't read as a regression.
          tool_note = _tool_version_change_note(
              _stats_tool_versions(prior), _stats_tool_versions(current),
          )
          if tool_note is not None:
              return False, tool_note, False
          return True, None, False
      
      
      def _tool_version_change_note(
          prior_tools: dict[str, str], current_tools: dict[str, str]
      ) -> str | None:
          """Return a note naming the first complexity backend whose version changed
          between the two snapshots, or ``None`` when every shared tool matches.
      
          Only a tool recorded in BOTH snapshots can be compared; a tool missing from
          the prior snapshot (older, pre-stamping) is skipped rather than treated as a
          change - that case is already caught by the plugin/schema reliability check.
          A backend version change (e.g. a lizard release) can shift cyclomatic scores
          with no change in the tree, so the diff against the prior snapshot is voided.
          """
          for tool in sorted(current_tools):
              pv = prior_tools.get(tool)
              cv = current_tools.get(tool)
              if pv is not None and cv is not None and pv != cv:
                  return (
                      f"{tool} version changed {pv}->{cv}; complexity scores may shift, "
                      "so the diff against the prior snapshot is not comparable"
                  )
          return None
      
      
      def _has_sibling_test(
          repo_root: Path, rel_path: str, shared_names: frozenset[str] = frozenset(),
          index: TestIndex | None = None,
      ) -> bool | None:
          """Best-effort: does this file have a test file?
      
          Delegates to ``lib.sibling_tests.has_sibling_test``, the one resolver the
          ``test_focus`` signal also reads, so the hotspot page's ``Has test file`` row
          and the focus table agree. ``True``/``False`` from a filesystem check of the
          naming idioms (``foo.ts`` next to ``foo.test.ts``, ``FooTest.java``, an
          adjacent ``__tests__/``, a mirrored ``tests/`` tree, ...); ``None`` only when
          the file isn't on disk (a since-deleted path in a stats snapshot).
          """
          return has_sibling_test(repo_root, rel_path, shared_names, index)
      
      
      def _load_first_flagged(assess_dir: Path) -> dict[str, str]:
          """Load the first-flagged date map from .assess/first-flagged.json.
      
          Returns an empty dict if the file does not exist yet (first run).
          """
          state_file = assess_dir / "first-flagged.json"
          if not state_file.exists():
              return {}
          return json.loads(state_file.read_text(encoding="utf-8"))
      
      
      def _rekey_first_flagged(
          first_flagged: dict[str, str], rename_map: dict[str, str],
      ) -> dict[str, str]:
          """Move each first-flagged entry for a renamed path onto its current path.
      
          The date travels with the file. When the current path already has an entry,
          the earlier known date wins, so a rename never makes a file look newer.
          """
          rekeyed = {k: v for k, v in first_flagged.items() if k not in rename_map}
          for old, date in first_flagged.items():
              new = rename_map.get(old)
              if new is None:
                  continue
              known = sorted(d for d in (date, rekeyed.get(new)) if d and d != "unknown")
              rekeyed[new] = known[0] if known else "unknown"
          return rekeyed
      
      
      def _same_measurement_prior_run(
          assess_dir: Path, *, run_date: str, measured_commit: dict
      ) -> dict | None:
          """The previous run's context when this run supersedes it, else None.
      
          A run supersedes the previous one when both share ``run_date`` and the
          measured commit. The previous run-context.json is still on disk here (this
          run writes its own later), so it names the entry's run id and commit. The
          wiki writer only removes that run's log entry if it is the log's last and
          still carries placeholders; a finalized entry is history and stays (#355).
      
          A target with no git (``available`` false on both runs) has no commit to
          key on, so the date and the prior run id are the whole identity: two such
          runs on one day count as the same measurement.
          """
          try:
              prior = json.loads((assess_dir / "run-context.json").read_text(encoding="utf-8"))
          except (OSError, ValueError):
              return None
          if not isinstance(prior, dict) or not prior.get("run_id"):
              return None
          if prior.get("run_date") != run_date:
              return None
          prior_commit = prior.get("measured_commit")
          if not isinstance(prior_commit, dict):
              return None
          head_sha = measured_commit.get("head_sha")
          if head_sha:
              same = prior_commit.get("head_sha") == head_sha
          else:
              same = (
                  measured_commit.get("available") is False
                  and prior_commit.get("available") is False
              )
          return prior if same else None
      
      
      def _inherited_provisional_paths(
          assess_dir: Path, superseded: dict | None, first_flagged_map: dict[str, str],
      ) -> set[str]:
          """Paths first flagged only by the superseded, never-finalized run (#356).
      
          Empty unless the superseded run's log entry is still unfinalized. Each run
          records ``provisional_first_flagged``: the paths it first flagged plus those
          it inherited this way, so a chain of unfinalized same-day runs carries a
          file forward after it stops being "new". A run-context written before the
          key existed yields nothing: its ``diff_detail.new`` cannot tell a file first
          flagged there from one first flagged by a finalized run earlier that day
          that graduated and returned, and retiring the latter would delete a
          finalized date. Only paths whose first-flagged date is still that run's
          date qualify.
          """
          if superseded is None or not last_log_entry_is_unfinalized_run(
              assess_dir, superseded["run_id"],
          ):
              return set()
          paths = superseded.get("provisional_first_flagged")
          if not isinstance(paths, list):
              return set()
          return {
              p for p in paths
              if isinstance(p, str) and first_flagged_map.get(p) == superseded.get("run_date")
          }
      
      
      def _excluded_after_unfinalized_run(
          assess_dir: Path, *, superseded: dict | None, first_flagged_map: dict[str, str],
          current: dict, diff: StatsDiff, excludes: tuple[set[str], list[str]],
      ) -> tuple[set[str], list[str]]:
          """Split out the files excluded after a never-finalized run (#356).
      
          Returns ``(provisional, excluded)``. ``provisional`` is what this run records
          as ``provisional_first_flagged``: the inherited paths plus the ones this run
          flags for the first time, minus ``excluded``. ``excluded`` is the sorted
          inherited paths now matched by a config exclude and no longer a top hotspot;
          they are removed from ``diff.graduated`` here so the rotated prior stats do
          not carry them into index.md. Must run before the hotspot loop stamps new
          first-flagged dates.
          """
          inherited = _inherited_provisional_paths(assess_dir, superseded, first_flagged_map)
          current_hot = {h["path"] for h in current.get("top_hotspots", [])}
          excluded = sorted(
              p for p in inherited - current_hot if is_user_excluded(Path(p), *excludes)
          )
          diff.graduated = [h for h in diff.graduated if h.path not in excluded]
          fresh = {h.path for h in diff.new if h.path not in first_flagged_map}
          return (inherited | fresh) - set(excluded), excluded
      
      
      def _retire_excluded_unfinalized(
          assess_dir: Path, excluded: list[str], first_flagged_map: dict[str, str],
      ) -> tuple[list[str], list[str]]:
          """Retire the pages of ``excluded`` and drop their first-flagged entries.
      
          Returns ``(retired, dropped)``. A path whose page exists but has no status
          token to stamp keeps its entry, so a page that still reads live never loses
          its first-flagged date.
          """
          retired, unstamped = retire_excluded_hotspots(assess_dir, excluded)
          dropped = [p for p in excluded if p not in unstamped and p in first_flagged_map]
          for path in dropped:
              del first_flagged_map[path]
          return retired, dropped
      
      
      def _drop_superseded_log_entry(assess_dir: Path, superseded: dict | None) -> None:
          """Remove the superseded run's log entry when it is still unfinalized."""
          if superseded is not None:
              supersede_unfinalized_log_entry(assess_dir, superseded["run_id"])
      
      
      def _save_first_flagged(assess_dir: Path, first_flagged: dict[str, str]) -> None:
          """Persist the first-flagged date map to .assess/first-flagged.json."""
          (assess_dir / "first-flagged.json").write_text(
              json.dumps(first_flagged, indent=2), encoding="utf-8"
          )
      
      
      def _write_badge(
          assess_dir: Path, promissory: Any, derived_findings: list[dict],
          run_id: str | None = None, scope: str | None = None,
      ) -> None:
          """Write the deterministic default badge, always.
      
          The shipped ``badge.json`` is the deterministic findings-count form: a pure
          function of measured run data, never an LLM-authored score. It is written on
          every run and is no longer overwritten by ``assess_finalize`` - the
          LLM-derived grade lives in ``assess-report.md``, and the badge's ``link``
          funnels a badge-clicker there. ``run_id`` stamps the badge with the run that
          produced it. ``scope`` (the repo-relative subtree) labels the badge for a
          ``/assess <path>`` monorepo run.
          """
          stale = (
              promissory.get("total_stale", 0)
              if isinstance(promissory, dict) and promissory.get("available")
              else 0
          )
          badge = fallback_badge(
              concern_count_from_findings(derived_findings), stale, run_id=run_id,
              scope=scope,
          )
          badge["link"] = "./assess-report.md"
          write_badge(assess_dir, badge)
      
      
      # Cap on accretion files carried into run-context.json. The scanner measures
      # every file; the run-context list keeps only the worst few that also score in
      # the top complexity/size band, so a growing-but-simple file never earns a line.
      MAX_ACCRETION_FILES = 12
      
      
      def _excluded_generated(complexity_stats: dict) -> list[dict[str, str]]:
          """The stats file's ``excluded_generated`` list, keeping well-formed rows.
      
          Each row is ``{"path", "reason"}`` with non-empty strings; anything else
          (an older stats file without the key, a malformed row) is dropped, so the
          run-context key is always a list.
          """
          rows = complexity_stats.get("excluded_generated")
          if not isinstance(rows, list):
              return []
          return [
              {"path": r["path"], "reason": r["reason"]}
              for r in rows
              if isinstance(r, dict)
              and isinstance(r.get("path"), str) and r["path"]
              and isinstance(r.get("reason"), str) and r["reason"]
          ]
      
      
      def _top_band_paths(complexity_stats: dict) -> set[str]:
          """Paths already in the top complexity/size band of this run's stats.
      
          Union of the three ranked top-N lists (``top_hotspots``/``top_complex``/
          ``top_large``). Accretion is only surfaced for a file already in this set:
          a growing file that scores low on complexity *and* size isn't a hotspot, so
          flagging its growth would be noise. Mirrors lib.keyhole_signals._paths_from_stats.
          """
          paths: set[str] = set()
          for key in ("top_hotspots", "top_complex", "top_large"):
              for entry in complexity_stats.get(key) or []:
                  path = entry.get("path")
                  if path:
                      paths.add(path)
          return paths
      
      
      def _accretion_block(scan: Any, complexity_stats: dict) -> dict[str, Any]:
          """Serialize an AccretionScan into the run-context ``accretion_ratchet`` block.
      
          On an unavailable scan, emits the same shape with an empty file list and the
          scan's reason (graceful degradation - a failed scan is never read as "no
          accretion"). On success, the flagged files are filtered to those already in
          the top complexity/size band (the noise budget), sorted by net additions
          descending with a path tie-break for a total, deterministic order, and capped
          at MAX_ACCRETION_FILES.
          """
          block: dict[str, Any] = {
              "available": scan.available,
              "reason": scan.reason,
              "reliable": scan.reliable,
              "deletion_fraction_threshold": scan.deletion_fraction_threshold,
              "files": [],
          }
          if not scan.available:
              return block
      
          band = _top_band_paths(complexity_stats)
          in_band = [f for f in scan.files if f.path in band]
          # The scan already sorts by (-net_additions, path); re-sort defensively so
          # the serialized order is a total, clone-independent order regardless of the
          # filtered subset's incoming order.
          in_band.sort(key=lambda f: (-f.net_additions, f.path))
          block["total_in_band"] = len(in_band)
          block["files"] = [f.to_dict() for f in in_band[:MAX_ACCRETION_FILES]]
          return block
      
      
      def _accretion_lookup(scan: Any) -> dict[str, dict]:
          """O(1) ``{path: accretion_info}`` for the hotspot pages, from one scan.
      
          Each entry is the serialized AccretionFile plus the scan-wide ``reliable``
          flag, so a hotspot page can name a file's growth profile and disclaim it on
          degenerate history without re-deriving anything. Returns ``{}`` when the
          scan was unavailable - the hotspot loop then writes pages with no growth
          line (graceful degradation; the scan never gates page generation).
          """
          if not scan.available:
              return {}
          lookup: dict[str, dict] = {}
          for af in scan.files:
              entry = af.to_dict()
              entry["reliable"] = scan.reliable
              lookup[af.path] = entry
          return lookup
      
      
      # The six Tier 1 grouping-disagreement counts, in a fixed order so the
      # run-context tier_1 sub-block is deterministic regardless of dict iteration.
      _TIER1_DISAGREEMENT_KEYS = (
          "human_grouped_static_splits",
          "human_split_static_fuses",
          "human_grouped_never_cochange",
          "human_split_but_cochange",
          "human_static_agree",
          "human_cochange_agree",
      )
      
      
      def _structure_drift_block(
          repo_root: Path, tier_1: dict,
      ) -> dict | None:
          """Build the run-context ``structure_drift`` block (Tier 0 + Tier 1).
      
          Tier 0 (``detect_path_existence_drift``) is the zero-threshold cut: declared
          ownership patterns matching no tracked file. It runs whenever an ownership
          map exists; when none does it degrades to ``available: False`` and the whole
          block is omitted (the caller drops a ``None``), keeping non-owned repos'
          run-context byte-stable.
      
          ``tier_1`` is the grouping-disagreement result ``keyhole_signals.integrate``
          already computed from the behaviour block's co-change pairs (no second
          ``git log`` parse, no double computation) - either the six disagreement
          counts or an ``available: False`` marker when the static import graph was
          unavailable or no ownership map existed. The seam allowlist was applied
          inside the detector, so the counts here are already post-allowlist.
      
          Returns ``None`` when Tier 0 is unavailable (no ownership map) - a graceful,
          half-block-free omission. Otherwise returns a JSON-serialisable block whose
          ``tier_0`` always carries data and whose ``tier_1`` is either the six
          disagreement counts or ``{"available": False}``.
          """
          tier_0 = detect_path_existence_drift(repo_root)
          if not tier_0.get("available"):
              return None  # no ownership map - omit the block entirely
      
          block: dict[str, Any] = {
              "tier_0": {
                  "available": True,
                  "empty_ownership_patterns": tier_0["empty_ownership_patterns"],
                  "total_patterns": tier_0["total_patterns"],
                  "matched_patterns": tier_0["matched_patterns"],
              },
          }
      
          if not tier_1.get("available"):
              block["tier_1"] = {"available": False}
              return block
      
          tier_1_block: dict[str, Any] = {"available": True}
          for key in _TIER1_DISAGREEMENT_KEYS:
              tier_1_block[f"{key}_count"] = tier_1.get(f"{key}_count", 0)
          tier_1_block["seam_allowlist_applied"] = True
          tier_1_block["allowlist_pairs_count"] = len(SEAM_ALLOWLIST)
          block["tier_1"] = tier_1_block
          return block
      
      
      def _attach_structure_drift(
          ctx: dict[str, Any], repo_root: Path, tier_1: dict,
      ) -> None:
          """Attach the structure_drift block to ctx, or omit it on a graceful degrade.
      
          Builds the block via :func:`_structure_drift_block` (Tier 0 + the supplied
          Tier 1 result) under :func:`_safe`. Attaches only a real block (one carrying
          a ``tier_0``): the no-ownership-map path returns ``None`` and a scan failure
          returns ``_safe``'s degrade dict (no ``tier_0``); both omit the block rather
          than emit a half-block, keeping the contract that absence means "nothing to
          drift against / not assessed", never "no drift".
          """
          block = _safe(
              "structure_drift",
              lambda: _structure_drift_block(repo_root, tier_1),
          )
          if isinstance(block, dict) and "tier_0" in block:
              ctx["structure_drift"] = block
      
      
      def _marker_debt_sentence(debt: dict | None) -> str:
          """One briefing sentence accusing a hotspot of its own stale promises."""
          if not debt:
              return ""
          families = ", ".join(debt["families"])
          return (
              f"Carries {debt['count']} stale promissory marker(s) "
              f"({families}; oldest survived {debt['max_survived']} edits to this file). "
          )
      
      
      def _safe(label: str, fn):
          """Run a read-side scan, degrading to an unavailable marker on any failure.
      
          Read-side signals are additive context for the LLM, never gates - a broken
          scan must never block the assessment (PRD: "never block").
          """
          try:
              return fn()
          except Exception as e:  # noqa: BLE001 - intentional catch-all; degrade, don't crash
              return {"available": False, "reason": f"{label} scan failed: {e}"}
      
      
      def _normalize_test_pressure(test_pressure: Any) -> dict:
          """Normalize a ``scan_test_pressure`` result into the run-context block shape.
      
          A failed (or malformed) scan must not read as "no mutation setup": that
          would mis-score Layer 1 just as a failed liveness scan would mis-score
          observability. On a bad result, carry an explicit unavailable marker with a
          null ``mutation_config_present`` and empty heuristic buckets so the LLM sees
          "not assessed", never a false negative. Keys mirror the real block's
          ``cheap_heuristics`` schema (assertion_on_internal / untested_boundaries /
          duplicate_truth) so the consumer's shape doesn't change on the failure path.
      
          Shared by the default read-only scan (``build_run_context``) and the opt-in
          mutation re-run (``run_opt_in_mutation``) so both write an identical shape.
          """
          tp_ok = (isinstance(test_pressure, dict)
                   and "mutation_config_present" in test_pressure
                   and "cheap_heuristics" in test_pressure)
          if tp_ok:
              return test_pressure
          return {
              "available": False,
              "reason": (test_pressure.get("reason")
                         if isinstance(test_pressure, dict)
                         else "test_pressure scan unavailable"),
              "mutation_config_present": None,
              "cheap_heuristics": {
                  "assertion_on_internal": [],
                  "untested_boundaries": [],
                  "duplicate_truth": [],
              },
          }
      
      
      # The annotation the LLM must attach to Layer 6 when mutation testing never
      # ran. Layer 6 (truth pressure) asks whether the suite *proves* behaviour, not
      # merely visits it - a claim only a mutation run can substantiate. Absent that
      # run, the strongest honest verdict is Partial; a Present claim would be an
      # unproven self-description, exactly the guardrail-erosion failure /assess
      # exists to catch. assess_finalize enforces the cap deterministically.
      MUTATION_NOT_RUN_ANNOTATION = "truth-pressure unproven (mutation not run)"
      
      
      def _mutation_not_run_cap(test_pressure_block: dict) -> dict:
          """The Layer 6 cap the LLM reads: does mutation evidence exist this run?
      
          ``mutation_run`` is True only when the (opt-in, code-executing) bounded
          mutation pass actually ran - coverage-config detection alone leaves it
          False. When it is False, Layer 6 cannot be scored above Partial and the
          ``annotation`` must be attached; assess_finalize rejects a finalize-input
          that violates this.
      
          The flag alone is not trusted: a block that claims ``mutation_run`` but
          carries no parsed mutant record in ``per_file`` is evidence-free, so the cap
          stays applied (#317).
          """
          per_file = (
              test_pressure_block.get("per_file")
              if isinstance(test_pressure_block, dict) else None
          )
          mutation_run = bool(
              isinstance(test_pressure_block, dict)
              and test_pressure_block.get("mutation_run", False)
              and isinstance(per_file, list)
              and any(isinstance(rec, dict) for rec in per_file)
          )
          return {
              "applies": not mutation_run,
              "mutation_run": mutation_run,
              "max_layer6_band": "Present" if mutation_run else "Partial",
              "annotation": None if mutation_run else MUTATION_NOT_RUN_ANNOTATION,
          }
      
      
      def _build_stale_hubs(doc_graph: dict, doc_staleness: dict) -> list[dict]:
          """Centrality x staleness - the priority Layer 0 signal.
      
          A stale *hub* (high PageRank) is the most dangerous lying map: everything
          routes through it. We join the doc graph's central docs with their
          staleness ratio so a stale hub surfaces as a top finding.
      
          Each hub carries the underlying `subject_method` + a `confidence` flag.
          `subject_method == "repo-baseline"` means the ratio is computed against
          repo-wide churn (no derivable subject), so the priority composite shares
          a denominator across every baseline entry - confidence on those is "low".
          The sort halves the priority of low-confidence entries so a precise-subject
          hub at half the raw priority of a baseline one still outranks it.
          """
          if not doc_graph.get("available") or not doc_staleness.get("available"):
              return []
          staleness_by_path = {d["path"]: d for d in doc_staleness.get("docs", [])}
          hubs: list[dict] = []
          for hub in doc_graph.get("hubs", []):
              s = staleness_by_path.get(hub["path"])
              if s is None:
                  continue
              priority = round(hub["pagerank"] * s["ratio"], 3)
              confidence = s.get("confidence", "high")
              hubs.append({
                  "path": hub["path"],
                  "pagerank": hub["pagerank"],
                  "last_commit_days": s["last_commit_days"],
                  "code_churn_in_window": s["code_churn_in_window"],
                  "ratio": s["ratio"],
                  "subject_method": s.get("subject_method"),
                  "confidence": confidence,
                  "priority": priority,
              })
          return sorted(
              hubs,
              key=lambda h: -(h["priority"] * (0.5 if h["confidence"] == "low" else 1.0)),
          )
      
      
      def resolve_scope(
          repo_root: Path, scope: Path | None
      ) -> tuple[Path | None, str | None, str]:
          """Resolve a ``--scope`` argument into (absolute path, repo-relative, slug).
      
          A whole-repo run (``scope`` is None) returns ``(None, None, "")`` so the
          caller keeps ``repo_root/.assess`` and every output is byte-identical to a
          pre-scope run. A scoped run validates the path exists and is under
          ``repo_root``; the slug replaces path separators with hyphens so artifacts
          land under ``.assess/<slug>/``. Raises ``ValueError`` on a missing or
          outside-repo path so the CLI can report it cleanly (a non-zero exit).
          """
          if scope is None:
              return None, None, ""
          scope_abs = (scope if scope.is_absolute() else repo_root / scope).resolve()
          if not scope_abs.exists():
              raise ValueError(f"scope path does not exist: {scope_abs}")
          if not scope_abs.is_relative_to(repo_root):
              raise ValueError(f"scope path is not under repo root {repo_root}: {scope_abs}")
          rel = scope_abs.relative_to(repo_root)
          slug = str(rel).replace("/", "-").replace("\\", "-")
          return scope_abs, str(rel), slug
      
      
      def build_run_context(
          *, repo_root: Path, run_date: str, non_interactive: bool = False,
          scope: Path | None = None,
      ) -> dict:
          """Run the deterministic pipeline and return the structured context dict.
      
          ``non_interactive`` is the orchestrator's explicit headless/CI signal; it
          (together with the ``CI`` / ``ASSESS_NON_INTERACTIVE`` env vars) decides the
          ``interactive`` flag and the pre-recorded ``offers``. It is never inferred
          from ``sys.stdin.isatty()`` - the core always runs as a subprocess with no
          controlling terminal, so an interactive /assess would misread as headless.
      
          ``scope`` restricts the assessment to a subtree (``/assess <path>`` monorepo
          scoping): artifacts land under ``.assess/<slug>/`` and the file-enumerating
          scans (complexity stats read from the scoped sidecar, doc graph, doc
          staleness) see only the subtree, so the score/badge/wiki carry no signal
          from a sibling directory. None (the default) is a whole-repo run, unchanged.
      
          Side effects: writes index.md, log.md, hotspots/*.md, run-context.json.
          """
          scope_abs, scope_rel, scope_slug = resolve_scope(repo_root, scope)
          assess_dir = repo_root / ".assess" / scope_slug if scope_slug else repo_root / ".assess"
          assess_dir.mkdir(parents=True, exist_ok=True)
          # Unique id minted once at the top of the run and stamped on every artifact
          # this build produces, so finalize can prove the finalize-input it later
          # consumes was authored against *this* run-context and not a stale one.
          run_id = _new_run_id()
          current = load_stats(assess_dir / "complexity-stats.json") or {
              "files_scored": 0, "top_hotspots": [], "top_complex": [], "top_large": [],
              "loc": {}, "ccn": {},
          }
          prior = load_stats(assess_dir / "complexity-stats.prior.json")
          prior_exists = prior is not None
      
          # A diff is only trustworthy when both snapshots came from a comparable
          # toolchain. Three things can void it, in descending severity, all schema-
          # and version-aware (not a blunt exact-string equality):
          #   1. the plugin's stats schema or MAJOR version changed - the sidecar shape
          #      or the deterministic core moved (major also resets the trend);
          #   2. a complexity backend (lizard/scc) version changed - scores can shift
          #      with no change in the tree, so the note names the tool;
          #   3. the prior snapshot never stamped a version - comparability can't be
          #      established, so "graduated" entries may be phantom filter transitions.
          # A mere MINOR/PATCH plugin bump keeps the diff reliable and the gate armed.
          prior_version = prior.get("plugin_version") if prior else None
          current_schema = current.get("schema_version")
          prior_schema = prior.get("schema_version") if prior else None
          current_tools = _stats_tool_versions(current)
          prior_tools = _stats_tool_versions(prior)
          diff_reliable, diff_version_note, diff_trend_reset = _compute_diff_reliability(
              prior_exists, prior, current,
          )
      
          diff = diff_stats(prior=prior, current=current)
          # A hotspot that left the ranking because this run excluded it as generated
          # did not graduate: the filter changed, not the file. Drop it from the
          # graduated list so the append-only log and index never record it as one.
          # Content excludes are named in excluded_generated; the generated-name
          # globs are silent, so they are matched here directly.
          generated_paths = {r["path"] for r in _excluded_generated(current)}
          diff.graduated = [
              h for h in diff.graduated
              if h.path not in generated_paths and not matches_generated_name(h.path)
          ]
          instruction_files, instructions_grade, untracked_instr, dangling_instr, skills_info, \
              sensitive_instr = _grade_instruction_files(repo_root)
      
          # Historical path -> current path, from git's rename detection. Built once:
          # it re-keys the first-flagged map here and folds co-change history onto
          # current paths in the keyhole integrate below.
          rename_map = build_rename_map(repo_root)
      
          # Load (and later update) the persistent first-flagged date map, with any
          # entry for a renamed file moved to its current path.
          first_flagged_map = _rekey_first_flagged(
              _load_first_flagged(assess_dir), rename_map.paths)
      
          # User-supplied excludes (`.assess/config.toml`), loaded once and threaded
          # into every read-side scan (heatmap parity, doc graph, staleness, liveness,
          # markers) so "this is reference data, not source" is a single statement.
          extra_exclude_dirs, extra_exclude_patterns = load_excludes(repo_root)
      
          # Excluded after an unfinalized run (#356): a file first flagged only by the
          # never-finalized run this one supersedes, and now excluded by config, was
          # never part of a finished assessment. Its page is retired, its first-flagged
          # entry dropped, and it is kept out of "graduated" so the rotated prior stats
          # do not carry it into index.md. Files first flagged by a finalized run are
          # outside the rule, as are files still in the current top hotspots.
          measured_commit = git_commit_info(repo_root)
          superseded = _same_measurement_prior_run(
              assess_dir, run_date=run_date, measured_commit=measured_commit,
          )
          provisional, excluded_unfinalized = _excluded_after_unfinalized_run(
              assess_dir, superseded=superseded, first_flagged_map=first_flagged_map,
              current=current, diff=diff,
              excludes=(extra_exclude_dirs, extra_exclude_patterns),
          )
      
          # Promissory markers (stale TODO/FIXME, suppressions, disabled tests),
          # scanned before the wiki pages so each hotspot page can carry its own
          # marker debt. summary() shape on success; _safe's degrade dict on failure
          # (both carry `available`).
          promissory = _safe(
              "promissory_markers",
              lambda: scan_promissory_markers(
                  repo_root,
                  extra_exclude_dirs=extra_exclude_dirs,
                  extra_exclude_patterns=extra_exclude_patterns,
                  scope=scope_abs,
              ).summary(),
          )
          marker_debt_by_file = (
              promissory.get("stale_by_file", {}) if promissory.get("available") else {}
          )
      
          # Accretion ratchet (files whose line count only ever grows): the first of
          # the three write-side tendencies. scan_accretion_ratchet never raises - it
          # degrades to available=False internally - and returns an AccretionScan
          # dataclass (not a dict), so it is called directly rather than through _safe
          # (whose degrade path yields a dict). The block is serialized below, after
          # the complexity band is known, so growth is reported only for files already
          # in the top complexity/size band.
          accretion_scan = scan_accretion_ratchet(repo_root)
          # O(1) per-file lookup for the hotspot pages, built from the same scan the
          # run-context block serializes (no re-scan). Empty when the scan was
          # unavailable - graceful degradation: those files just get no growth line.
          accretion_by_file = _accretion_lookup(accretion_scan)
      
          # Build status map: which paths are graduated, new, regressed, persistent
          status_map: dict[str, str] = {}
          for h in diff.graduated:
              status_map[h.path] = "graduated"
          for h in diff.new:
              status_map[h.path] = "new"
          for h in diff.regressed:
              status_map[h.path] = "regressed"
          for h in diff.persistent:
              status_map[h.path] = "persistent"
      
          # Wiki: hotspot pages for current top hotspots
          hotspot_entries: list[HotspotEntry] = []
          # Same flat-tree disambiguation the test_focus block applies to these files.
          hot_shared_names = shared_name_keys(
              h["path"] for h in current.get("top_hotspots", []))
          # One repository index for every hot file's parallel-tree (basename) probe.
          hot_test_index = build_test_index(repo_root) if current.get("top_hotspots") else None
          for h in current.get("top_hotspots", []):
              path = h["path"]
              # Preserve the original first_flagged date across runs. A path missing
              # from the map is either genuinely new this run (stamp today) or it was
              # present in the prior snapshot but we have no recorded date - e.g. the
              # prior stats were seeded without first-flagged.json. In the latter case
              # it predates this run, so an honest "unknown" beats a wrong today.
              if path not in first_flagged_map:
                  first_flagged_map[path] = (
                      run_date if status_map.get(path) == "new" else "unknown"
                  )
              first_flagged = first_flagged_map[path]
              status = status_map.get(path, "active")
              commits = hotspot_commits(h)
              loc = h.get("loc", 0)
              ccn = h.get("ccn", 0)
              hotspot_entries.append(HotspotEntry(
                  path=path,
                  first_flagged=first_flagged,
                  last_seen=run_date,
                  status=status,
                  ccn=ccn,
                  loc=loc,
              ))
              write_hotspot_page(
                  assess_dir,
                  path=path,
                  first_flagged=first_flagged,
                  last_seen=run_date,
                  status=status,
                  loc=loc,
                  ccn=ccn,
                  commits=commits,
                  has_tests=_has_sibling_test(repo_root, path, hot_shared_names,
                                              hot_test_index),
                  history_rows=f"| {run_date} | {loc} | {ccn} | {commits} | {status} |",
                  briefing=(
                      f"Hotspot ({status}). "
                      f"{loc} LOC, "
                      f"max cyclomatic complexity {ccn}, "
                      f"{commits} commits in churn window. "
                      + _marker_debt_sentence(marker_debt_by_file.get(path))
                      + "(Briefing refined by LLM via assess_finalize - see Suggested actions below.)"
                  ),
                  actions=UNFINALIZED_ACTIONS_POINTER,
                  accretion_data=accretion_by_file.get(path),
                  run_id=run_id,
                  schema_version=ARTIFACT_SCHEMA_VERSION,
              )
      
          # Prune orphan hotspot pages: any page from a prior run whose source file no
          # longer exists on disk is stamped retired (history preserved) so no active
          # page keeps describing a deleted file. Runs after the current top hotspots
          # are (re)written, so a file that is still a live hotspot has just had its
          # page refreshed and won't be touched.
          pruned_hotspots = prune_orphan_hotspots(assess_dir, repo_root)
          retired_excluded, dropped_first_flagged = _retire_excluded_unfinalized(
              assess_dir, excluded_unfinalized, first_flagged_map,
          )
      
          # Also surface graduated hotspots in the index. Carry the file's actual
          # current metrics across the three top-N lists in `current` - graduating
          # off `top_hotspots[:10]` means the file fell out of the composite
          # ranking, NOT that its LOC or CCN dropped to zero. A graduated file
          # almost always still appears in `top_complex` (top 10 by raw CCN) or
          # `top_large` (top 10 by raw LOC) since those are wider views, so we
          # can recover real numbers in the common case. Merge metrics per-path
          # because top_complex entries carry only `ccn` and top_large entries
          # carry only `loc` - only top_hotspots carries both. When a metric
          # genuinely isn't present in any list, leave it as None - the wiki
          # renders None as "-" rather than misleading zeros (issue #52 Bug 1).
          current_locs: dict[str, int] = {}
          current_ccns: dict[str, int] = {}
          for src_key in ("top_hotspots", "top_complex", "top_large"):
              for entry in current.get(src_key, []):
                  path = entry.get("path")
                  if not path:
                      continue
                  loc = entry.get("loc")
                  ccn = entry.get("ccn")
                  if loc is not None and path not in current_locs:
                      current_locs[path] = int(loc)
                  if ccn is not None and path not in current_ccns:
                      current_ccns[path] = int(ccn)
      
          for h in diff.graduated:
              hotspot_entries.append(HotspotEntry(
                  path=h.path,
                  # Graduated means it was a prior hotspot; if we have no recorded
                  # first-flagged date, it predates this run - "unknown", not today.
                  first_flagged=first_flagged_map.get(h.path, "unknown"),
                  last_seen=run_date,
                  status="graduated",
                  ccn=current_ccns.get(h.path),
                  loc=current_locs.get(h.path),
              ))
      
          # Persist the updated first-flagged map for future runs
          _save_first_flagged(assess_dir, first_flagged_map)
      
          write_index(
              assess_dir, hotspot_entries, last_updated=run_date,
              run_id=run_id, schema_version=ARTIFACT_SCHEMA_VERSION,
              scope=scope_rel,
          )
      
          top_action = "Deterministic ranker not yet wired (LLM picks Top 3)"
          log_entry = LogEntry(
              run_date=run_date,
              files_scored=current.get("files_scored", 0),
              readiness_score=0.0,  # LLM produces the layered score
              maturity_label="(LLM fills in)",
              instructions_grade=instructions_grade,
              graduated_count=len(diff.graduated),
              regressed_count=len(diff.regressed),
              new_count=len(diff.new),
              persistent_count=len(diff.persistent),
              top_action=top_action,
              plugin_version=_read_plugin_version(),
              run_id=run_id,
              schema_version=ARTIFACT_SCHEMA_VERSION,
          )
          _drop_superseded_log_entry(assess_dir, superseded)
          append_log_entry(assess_dir, log_entry)
      
          # log.md integrity: verify the chained checksums after the append. A break
          # (an earlier entry edited after the fact) is disclosed in log.md itself and
          # surfaced here so the report/gate can render it - a lying history is exactly
          # the self-description-under-no-pressure failure the toolkit guards against.
          log_valid, log_broken_at = verify_log_chain(assess_dir)
      
          # Heterogeneous run-context bus: values are dicts, lists, scalars, or the
          # degrade-gracefully bool/str/None fallbacks. Typed as dict[str, Any] so the
          # block accessors below (ctx["dead_code"] etc.) stay assignable to the
          # signal functions that consume them.
          ctx: dict[str, Any] = {
              # Run provenance: the unique id every artifact of this run carries, and
              # the artifact schema version a consumer checks before reading. finalize
              # refuses to reconcile a finalize-input whose run_id disagrees (a torn
              # write). `artifact_schema_version` is distinct from the stats-layout
              # `schema_version` set further below (from #244).
              "run_id": run_id,
              "artifact_schema_version": ARTIFACT_SCHEMA_VERSION,
              "run_date": run_date,
              # Monorepo scope (`/assess <path>`): the repo-relative subtree this run
              # covers, and its artifact-directory slug (.assess/<slug>/). Both null /
              # "" for a whole-repo run, so a consumer that ignores them sees the
              # pre-scope shape. The report, badge, and wiki label the run with these.
              "scope": scope_rel,
              "scope_slug": scope_slug or None,
              # The commit the scan measured. Absolute LOC/CCN figures are a snapshot
              # of this commit; the report pins the SHA and warns when HEAD is dirty
              # or behind its upstream so the numbers aren't read as current (#59).
              "measured_commit": measured_commit,
              "prior_stats_exists": prior_exists,
              "stats_summary": {
                  "files_scored": current.get("files_scored", 0),
                  "loc": current.get("loc", {}),
                  # Estimated tokens (the keyhole size unit) + the budget rollup: repo
                  # total and how many files / top-level subtrees exceed one
                  # context-window keyhole. The most on-thesis snapshot signal - the
                  # literal "does the relevant slice fit?" measure. Empty {} on a
                  # pre-token stats snapshot (back-compat).
                  "est_tokens": current.get("est_tokens", {}),
                  "ccn": current.get("ccn", {}),
                  "top_hotspots": current.get("top_hotspots", []),
              },
              "instruction_files": instruction_files,
              "instructions_grade": instructions_grade,
              "diff": diff.summary(),
              "diff_reliable": diff_reliable,
              "diff_version_note": diff_version_note,
              # True only when a 
    • assess_emit_workflow.py 11.4 KB
      """Emit the frozen-harness CI workflow for /assess (the third end-of-run offer).
      
      Thin CLI over ``lib.ci_workflow.emit_ci_workflow``. The orchestrator (SKILL.md
      Step 6.5) runs this after the user accepts the "freeze this into a repeatable
      check?" offer. It writes ``.github/workflows/assess-gate.yml`` into the target
      repo, pinning a published toolkit release and baking in the toolchain it found.
      
      Defaults are derived so the common case is a single argument:
      - ``--version`` defaults to the running plugin's version (``plugin.json`` beside
        this script), never the target's ``.assess/run-context.json``, which can be
        months old. The pin is checked upstream: ``git ls-remote --tags`` lists the
        published tags and ``gh api .../contents/action.yml?ref=<tag>`` confirms the
        tag ships the action. ``gh`` checks the running tag first; only when it is
        absent or ships no ``action.yml`` are the tags listed and the newest published
        tag that does is pinned instead, with the choice printed. With neither ``gh`` nor ``git`` reaching GitHub, the running
        version is emitted with an "unverified" warning. An explicit ``--version`` is
        emitted as given, unchecked. When the running version is unknown or known
        unpublished and no published tag qualifies, nothing is written and the exit
        code is 1.
      - ``--branch`` defaults to the repo's detected default branch (``main`` if it
        can't be detected).
      - ``--tools`` defaults to auto-detecting ``scc`` on PATH plus ``lizard`` (the
        always-present complexity backend); pass a comma list to override.
      - ``--paths <glob>`` / ``--paths-ignore <glob>`` (each repeatable, order kept)
        become ``paths:`` / ``paths-ignore:`` under ``on.pull_request``. GitHub rejects
        both on one event, so passing both is a usage error. With neither, when an
        existing workflow under ``.github/workflows/`` filters pull requests by path (a
        ``paths:`` or ``paths-ignore:`` key on a ``pull_request`` trigger, or
        ``dorny/paths-filter``), the gate gets
        ``paths-ignore: ['**/*.md', '.assess/**']`` and a line saying so is printed.
      
      Run:
          uv run assess_emit_workflow.py <repo_root> [--version V] [--branch B] [--tools a,b]
              [--paths GLOB ... | --paths-ignore GLOB ...]
      """
      # /// script
      # requires-python = ">=3.11"
      # ///
      from __future__ import annotations
      
      import json
      import re
      import shutil
      import subprocess
      import sys
      from pathlib import Path
      
      from lib.ci_workflow import DEFAULT_PATHS_IGNORE, emit_ci_workflow, find_path_filtered_workflow
      
      
      _REPO = "bjcoombs/ai-native-toolkit"
      _REMOTE = f"https://github.com/{_REPO}.git"
      # action.yml first shipped in v1.42.0; no earlier tag can back a ``uses:`` line.
      _FIRST_ACTION_RELEASE = (1, 42, 0)
      # Upper bound on per-tag action.yml lookups while walking down the tag list.
      _MAX_TAG_PROBES = 10
      _SEMVER_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
      
      
      def _running_version() -> str | None:
          """The running plugin's version from ``.claude-plugin/plugin.json``.
      
          scripts/assess_emit_workflow.py -> scripts/ -> skills/assess/ -> skills/ -> repo root.
          Same lookup as ``assess_core._read_plugin_version``; not imported from there
          because that module pulls the whole analysis stack and its dependencies.
          """
          plugin_json = Path(__file__).resolve().parents[3] / ".claude-plugin" / "plugin.json"
          try:
              version = json.loads(plugin_json.read_text(encoding="utf-8")).get("version")
          except (OSError, json.JSONDecodeError):
              return None
          return str(version) if version else None
      
      
      def _run(cmd: list[str]) -> tuple[int, str, str]:
          """Run ``cmd``; ``(returncode, stdout, stderr)``, with 127 when it can't start.
      
          The only door to the network (tests stub it), via ``gh`` / ``git`` on PATH.
          """
          try:
              out = subprocess.run(cmd, capture_output=True, text=True, timeout=20, check=False)
          except (OSError, subprocess.SubprocessError) as exc:
              return 127, "", str(exc)
          return out.returncode, out.stdout, out.stderr
      
      
      def _semver(tag: str) -> tuple[int, int, int] | None:
          m = _SEMVER_TAG.match(tag)
          return (int(m[1]), int(m[2]), int(m[3])) if m else None
      
      
      def _published_tags() -> list[str] | None:
          """Release tags (``vX.Y.Z``) on the upstream, newest first; None if unreachable."""
          rc, out, _ = _run(["git", "ls-remote", "--tags", "--refs", _REMOTE])
          if rc != 0:
              return None
          tags = [line.rsplit("refs/tags/", 1)[-1] for line in out.splitlines() if "refs/tags/" in line]
          return sorted((t for t in tags if _semver(t)), key=lambda t: _semver(t) or (0, 0, 0), reverse=True)
      
      
      def _action_status(tag: str) -> str:
          """``ok`` if ``tag`` ships action.yml, ``absent`` on a definite HTTP 404,
          ``unknown`` when gh can't answer (missing, unauthenticated, offline)."""
          rc, out, err = _run(["gh", "api", f"repos/{_REPO}/contents/action.yml?ref={tag}"])
          if rc == 0:
              return "ok"
          return "absent" if "HTTP 404" in f"{out}\n{err}" else "unknown"
      
      
      def _released_with_action(tag: str) -> bool:
          return (_semver(tag) or (0, 0, 0)) >= _FIRST_ACTION_RELEASE
      
      
      def _unverified(version: str, why: str) -> tuple[str, str]:
          return version, (
              f"WARNING: pin v{version} is unverified - {why}. "
              "Check the tag exists and ships action.yml before committing the workflow."
          )
      
      
      def _resolve_version(running: str | None) -> tuple[str | None, str]:
          """Pick the version to pin and a one-line note saying which and why.
      
          ``None`` means nothing safe can be pinned; the note says why."""
          status = _action_status(f"v{running}") if running else "absent"
          if running and status == "ok":
              return running, f"Pinned v{running}: the running version's tag ships action.yml."
          tags = _published_tags()
          if running and status == "unknown":
              if tags is None:
                  return _unverified(running, "neither gh nor git ls-remote reached GitHub")
              if f"v{running}" in tags and _released_with_action(f"v{running}"):
                  return running, f"Pinned v{running}: the tag is published (gh could not confirm action.yml)."
          why = f"v{running} is not published or ships no action.yml" if running else "the running version is unknown"
          for tag in [t for t in tags or [] if _released_with_action(t)][:_MAX_TAG_PROBES]:
              # gh unavailable before or during the walk: a release at or after the
              # first action.yml release stands in. Only a definite 404 rules a tag out.
              probe = status if status == "unknown" else _action_status(tag)
              if probe == "ok":
                  return tag[1:], f"Pinned {tag}, the newest published tag that ships action.yml: {why}."
              if probe == "unknown":
                  return tag[1:], f"Pinned {tag}, the newest published release after action.yml shipped (gh could not confirm it): {why}."
          # Every path here has a definite negative (gh's 404, or a tag list without
          # the running tag), so pinning the running version would write a dead ref.
          return None, (
              f"ERROR: no workflow written - {why}, and no published tag shipping action.yml was "
              "found. Pass --version <X.Y.Z> naming a published release."
          )
      
      
      def _default_branch(repo_root: Path) -> str:
          """Best-effort detect the repo's default branch; fall back to 'main'."""
          try:
              out = subprocess.run(
                  ["git", "-C", str(repo_root), "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
                  capture_output=True, text=True, timeout=5, check=False,
              )
              ref = out.stdout.strip()
              if ref:
                  return ref.rsplit("/", 1)[-1]
          except (OSError, subprocess.SubprocessError):
              pass
          return "main"
      
      
      def _detect_tools() -> list[str]:
          """Discovered external tools we can install in CI; lizard is always present."""
          tools = ["lizard"]
          if shutil.which("scc"):
              tools.append("scc")
          return tools
      
      
      def _opt(args: list[str], name: str) -> str | None:
          if name in args:
              idx = args.index(name)
              if idx + 1 < len(args):
                  return args[idx + 1]
          return None
      
      
      def _opt_all(args: list[str], name: str) -> list[str]:
          """Every value of a repeatable flag, in the order given."""
          return [args[i + 1] for i, arg in enumerate(args[:-1]) if arg == name]
      
      
      def _path_filters(repo_root: Path, paths: list[str], paths_ignore: list[str]) -> tuple[list[str], list[str]]:
          """The explicit filters, or the docs-only default when the repo already filters by path."""
          if paths or paths_ignore:
              return paths, paths_ignore
          source = find_path_filtered_workflow(repo_root)
          if source is None:
              return [], []
          globs = ", ".join(DEFAULT_PATHS_IGNORE)
          print(
              f"Applied the default paths-ignore ({globs}): {source.relative_to(repo_root)} "
              "already filters pull requests by path, so docs-only and .assess/-only PRs skip "
              "the gate, and doc-truth findings (lying_map, orphaned_understanding) no longer "
              "gate them. A skipped PR reports no gate check at all, so a required status check "
              "on the gate would stay pending on it. Pass --paths or --paths-ignore to override.",
              file=sys.stderr,
          )
          return [], list(DEFAULT_PATHS_IGNORE)
      
      
      _USAGE = (
          "Usage: assess_emit_workflow.py <repo_root> [--version V] [--branch B] [--tools a,b] "
          "[--paths GLOB ... | --paths-ignore GLOB ...]"
      )
      
      
      def main(argv: list[str] | None = None) -> int:
          args = list(sys.argv[1:] if argv is None else argv)
          flags = {"--version", "--branch", "--tools", "--paths", "--paths-ignore"}
          positional: list[str] = []
          i = 0
          while i < len(args):
              if args[i] in flags:
                  if i + 1 >= len(args) or args[i + 1] in flags:
                      print(f"{args[i]} needs a value.", file=sys.stderr)
                      print(_USAGE, file=sys.stderr)
                      return 2
                  if args[i] in {"--paths", "--paths-ignore"} and not args[i + 1].strip():
                      print(f"{args[i]} needs a non-empty value.", file=sys.stderr)
                      print(_USAGE, file=sys.stderr)
                      return 2
                  i += 2
                  continue
              if args[i].startswith("-"):
                  print(f"Unknown option {args[i]}: pass a flag and its value as two arguments.", file=sys.stderr)
                  print(_USAGE, file=sys.stderr)
                  return 2
              positional.append(args[i])
              i += 1
          if len(positional) != 1:
              if len(positional) > 1:
                  print(
                      f"Unexpected arguments {positional[1:]}: quote globs so the shell does not expand them.",
                      file=sys.stderr,
                  )
              print(_USAGE, file=sys.stderr)
              return 2
          paths, paths_ignore = _opt_all(args, "--paths"), _opt_all(args, "--paths-ignore")
          if paths and paths_ignore:
              print("GitHub rejects --paths and --paths-ignore on the same event; pass one.", file=sys.stderr)
              print(_USAGE, file=sys.stderr)
              return 2
          repo_root = Path(positional[0]).resolve()
          version = _opt(args, "--version")
          if version is None:
              version, note = _resolve_version(_running_version())
              print(note, file=sys.stderr)
              if version is None:
                  return 1
          branch = _opt(args, "--branch") or _default_branch(repo_root)
          tools_arg = _opt(args, "--tools")
          tools = (
              [t.strip() for t in tools_arg.split(",") if t.strip()]
              if tools_arg is not None
              else _detect_tools()
          )
          paths, paths_ignore = _path_filters(repo_root, paths, paths_ignore)
          path = emit_ci_workflow(
              repo_root, tools, version, default_branch=branch, paths=paths, paths_ignore=paths_ignore,
          )
          print(str(path))
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • assess_finalize.py 30.3 KB
      """LLM write-back for /assess: fill placeholders left by the deterministic core.
      
      The deterministic core writes log.md and hotspots/*.md with placeholders for
      LLM-derived content (score, maturity label, top action, per-hotspot actions).
      After the LLM writes assess-report.md, it also writes finalize-input.json with
      its derived values and invokes this script to update the wiki files in place.
      
      Reads (in order; first hit wins):
          {repo_root}/.assess/.cache/finalize-input.json  (preferred - transient cache)
          {repo_root}/.assess/finalize-input.json         (legacy - written to working tree)
      
      The input file is **consumed and deleted** on success. It carries no future
      utility past the run that produced it, and leaving it in the working tree
      caused noisy diffs when users committed `.assess/` (issue #39).
      
      Updates:
          {repo_root}/.assess/log.md           (this run's entry, by assess:run_id,
                                                then the chain re-computed)
          {repo_root}/.assess/hotspots/*.md    (Suggested actions sections)
      
      Writes (when the input carries an ``actions`` array):
          {repo_root}/.assess/actions.json     (durable machine-readable Top 3
                                                action contract for executor agents;
                                                v2 - carries per-action status/mode and
                                                preserves done state across re-runs)
      
      Run:
          uv run assess_finalize.py <repo_root>
          uv run assess_finalize.py <repo_root> --drop-entry <run_id>
              (replaces a never-finalized log entry that blocks finalize with a
              one-line tombstone and re-chains the log; finalized entries are refused)
      """
      # /// script
      # requires-python = ">=3.11"
      # ///
      from __future__ import annotations
      
      import json
      import re
      import sys
      from pathlib import Path
      
      
      # Make sibling lib package importable when run as a script
      sys.path.insert(0, str(Path(__file__).resolve().parent))
      
      from lib.badge import maturity_band
      from lib.evidence_check import check_evidence, describe
      from lib.keyhole_signals import mode_for_finding
      from lib.wiki_writer import (
          find_log_entry,
          log_entry_date,
          log_entry_is_unfinalized,
          log_entry_owns_span,
          log_entry_run_id,
          read_log_entries,
          rewrite_log_entry,
          slug_for_path,
      )
      
      
      class FinalizeValidationError(Exception):
          """Raised when finalize-input.json violates a run-context invariant.
      
          finalize is fail-closed: on any violation it raises *before writing
          anything*, so a torn, mismatched, or fabricated input can never reach the
          wiki, the badge, or the actions contract. ``main()`` catches it and exits
          non-zero with the specific violation named.
      
          The invariants this guards (see ``_validate_finalize_input``) are the
          contract that lets a downstream reader trust the finalised wiki: the score
          fits its denominator, the maturity label matches the score band, every
          hotspot action names a real hotspot, the input came from *this* run (run_id),
          Layer 6 never claims proof (Present) the run never gathered (no mutation),
          and no layer verdict rests only on evidence that fails its re-check.
          """
      
      
      # Canonical maturity tier keywords, used to pull the tier the LLM claimed out of
      # a free-text ``maturity_label`` (which may be decorated, e.g.
      # "Knowledge Base · Solid (3 applicable layers)"). No pair is a substring of
      # another, so a single containment test per keyword is unambiguous.
      _MATURITY_KEYWORDS = ("AI-Native", "Not Ready", "Solid", "Basic")
      
      # The annotation the LLM must attach to Layer 6 when mutation testing never ran.
      # Mirrors ``assess_core.MUTATION_NOT_RUN_ANNOTATION`` (the two scripts share no
      # import, so the literal is duplicated); the finalize error names it so a caller
      # knows the required remediation.
      MUTATION_NOT_RUN_ANNOTATION = "truth-pressure unproven (mutation not run)"
      
      
      def _log_target(assess_dir: Path, run_id: str | None) -> int | None:
          """Index of the log entry finalize fills, or None when there is none.
      
          The entry stamped with this run's ``assess:run_id`` wins. A legacy log
          (no entry carries a run id stamp) or a context with no run id falls back to
          the last entry still carrying placeholders, the pre-#355 behaviour. In a
          stamped log a missing entry refuses: falling back there would write this
          run's score into another run's entry and re-sign it.
          """
          entries = read_log_entries(assess_dir)
          if run_id:
              idx = find_log_entry(assess_dir, run_id)
              if idx is not None:
                  return idx
              if any(log_entry_run_id(e) for e in entries):
                  raise FinalizeValidationError(
                      f"log.md has no entry stamped run_id={run_id}; refusing to fill "
                      "another run's entry. Re-run the core to write this run's entry."
                  )
          for i in range(len(entries) - 1, -1, -1):
              if log_entry_is_unfinalized(entries[i]):
                  return i
          return None
      
      
      def _validate_no_earlier_same_date_placeholders(assess_dir: Path, target: int | None) -> None:
          """Refuse when an earlier entry for the target's date is still unfinalized.
      
          Two unfinalized entries on one date mean a run was superseded without its
          entry being replaced (a different commit, or a stale run-context): filling
          only the later one would leave a same-day placeholder entry that reads as a
          finished run. The error names the earlier entry so the operator can find it.
          """
          if target is None:
              return
          entries = read_log_entries(assess_dir)
          day = log_entry_date(entries[target])
          if day is None:
              return
          for content in entries[:target]:
              stale_id = log_entry_run_id(content)
              # An entry written before run-id stamps existed cannot be addressed by
              # --drop-entry, so refusing on it would block finalize for good; such an
              # entry keeps the pre-#355 treatment (left in place as history).
              if stale_id is None:
                  continue
              if log_entry_date(content) == day and log_entry_is_unfinalized(content):
                  heading = next(
                      (ln for ln in content.splitlines() if ln.startswith("## ")), "?"
                  )
                  raise FinalizeValidationError(
                      f"log.md entry run_id={stale_id} ({heading}) for {day} still "
                      "carries unfilled placeholders; an earlier same-date run was "
                      "never finalized. Its run-context is gone, so drop it with: "
                      f"assess_finalize.py <repo_root> --drop-entry {stale_id}, then "
                      "re-run finalize. Deleting it by hand breaks the log chain."
                  )
      
      
      def _finalize_log(
          assess_dir: Path,
          *,
          target: int | None,
          score: float,
          maturity_label: str,
          top_action: str,
          denominator: int = 8,
      ) -> None:
          """Fill the placeholders of log entry ``target`` and re-chain the log.
      
          Only that entry is updated; other entries are immutable historical records.
          The rewrite recomputes the chain marker of the filled entry and every later
          one, so ``verify_log_chain`` stays valid after finalize (#355).
          ``denominator`` is 8 for a software repo and the applicable-layer count for
          a knowledge base (#224), so the finalised line reads ``2.5 / 3`` rather
          than ``2.5 / 8``.
          """
          if target is None:
              return
          text = read_log_entries(assess_dir)[target]
          # The placeholder the core writes is always "/ 8"; the finalised
          # denominator may differ for a knowledge base, so the replacement carries it.
          text = _replace_last(
              text,
              pattern=r"\*\*AI Readiness:\*\* [\d.]+ / 8 \(\(LLM fills in\)\)",
              replacement=f"**AI Readiness:** {score} / {denominator} ({maturity_label})",
          )
          text = _replace_last(
              text,
              pattern=r"\*\*Top action:\*\* Deterministic ranker not yet wired \(LLM picks Top 3\)",
              replacement=f"**Top action:** {top_action}",
          )
          rewrite_log_entry(assess_dir, target, text)
      
      
      def _replace_last(text: str, *, pattern: str, replacement: str) -> str:
          """Replace the LAST occurrence of pattern in text with replacement (literal).
      
          Uses slicing rather than re.sub so the replacement is a literal string,
          not subject to backreference interpretation.
          """
          matches = list(re.finditer(pattern, text))
          if not matches:
              return text
          last = matches[-1]
          return text[:last.start()] + replacement + text[last.end():]
      
      
      def _finalize_hotspot_actions(assess_dir: Path, *, hotspot_actions: dict[str, list[str]]) -> None:
          """Rewrite the 'Suggested actions' section of each hotspot page.
      
          Pages whose paths are absent from hotspot_actions are left as-is. Paths in
          hotspot_actions whose pages don't exist are silently skipped (lifecycle:
          a hotspot might have graduated between LLM read and finalize).
          """
          hotspots_dir = assess_dir / "hotspots"
          if not hotspots_dir.exists():
              return
      
          for path, actions in hotspot_actions.items():
              slug = slug_for_path(path)
              page_path = hotspots_dir / f"{slug}.md"
              if not page_path.exists():
                  continue
      
              page_text = page_path.read_text(encoding="utf-8")
              actions_block = "\n".join(f"- {a}" for a in actions) if actions else "- (no actions)"
              # Replace the section content between "## Suggested actions" and end-of-file (or next ## heading)
              new_text = re.sub(
                  r"(## Suggested actions\s*\n\s*\n).*?(\n##\s|$)",
                  lambda m: m.group(1) + actions_block + "\n" + m.group(2),
                  page_text,
                  count=1,
                  flags=re.DOTALL,
              )
              page_path.write_text(new_text, encoding="utf-8")
      
      
      # Keys every action contract entry must carry. The executor-critical pair is
      # done_when (the exit criterion - without it a weak model doesn't know when to
      # stop) and scope_fence (what NOT to touch - without it a weak model
      # over-extends). Entries missing required keys are dropped with a warning
      # rather than failing the run: a partial contract beats none, but a malformed
      # entry must not reach an executor as if it were complete.
      ACTION_REQUIRED_KEYS = {"rank", "action", "done_when", "scope_fence"}
      
      # actions.json schema version. v2 adds executor-lifecycle fields (status /
      # claimed_by / completed_sha) plus a deterministic execution ``mode`` per action
      # and a top-level ``run_id`` stamp. v1 (schema:1, no lifecycle fields) is still
      # read for status carry-forward - see _read_prior_action_status.
      ACTIONS_SCHEMA_VERSION = 2
      
      # The executor lifecycle. A fresh action is ``pending``; an executor ``claimed``
      # it; ``done`` records the ``completed_sha`` that satisfied ``done_when``;
      # ``reopened`` means a later run re-flagged work a prior run marked done.
      ACTION_STATUS_VALUES = frozenset({"pending", "claimed", "done", "reopened"})
      
      # Lifecycle fields carried forward from a prior actions.json so a done action
      # stays done (with its completed_sha and claimant) across re-runs. Everything
      # else - rank, mode, done_when, scope_fence - is recomputed each run from the
      # freshest findings.
      _ACTION_CARRY_FIELDS = ("status", "claimed_by", "completed_sha")
      
      
      def _read_prior_action_status(assess_dir: Path) -> dict[str, dict]:
          """Prior actions keyed by their ``action`` text, for status carry-forward.
      
          The action directive is the stable identity across runs: rank reshuffles as
          findings re-prioritise, but "investigate the src/foo.go seam" is the same
          piece of work whether it ranks 1 or 3 this time. Reads v1 and v2 contracts
          alike - a v1 entry simply carries no lifecycle fields, so a re-run over a v1
          actions.json initialises every action to pending (backward compatible). A
          missing or unreadable prior contract yields no carry-forward, not an error.
          """
          path = assess_dir / "actions.json"
          if not path.exists():
              return {}
          try:
              prior = json.loads(path.read_text(encoding="utf-8"))
          except (json.JSONDecodeError, OSError):
              return {}
          out: dict[str, dict] = {}
          for a in prior.get("actions", []) if isinstance(prior, dict) else []:
              if isinstance(a, dict) and isinstance(a.get("action"), str):
                  out[a["action"]] = a
          return out
      
      
      def _carry_status_fields(prior_entry: dict) -> dict:
          """The lifecycle fields to inherit from a matching prior action.
      
          An unrecognised prior status resets to ``pending`` (a corrupted or
          hand-edited contract must not smuggle an out-of-vocabulary status forward).
          A v1 prior entry has none of these fields, so the executor sees a fresh
          pending action with a null claimant and no completed_sha.
          """
          status = prior_entry.get("status")
          return {
              "status": status if status in ACTION_STATUS_VALUES else "pending",
              "claimed_by": prior_entry.get("claimed_by"),
              "completed_sha": prior_entry.get("completed_sha"),
          }
      
      
      def _write_actions_contract(
          assess_dir: Path, actions: list[dict], *, run_id: str | None = None
      ) -> None:
          """Write the durable machine-readable Top 3 contract to actions.json (v2).
      
          Unlike finalize-input.json (consumed and deleted - transient by design),
          actions.json persists: it is the artifact an executing agent reads to know
          what to do, how to verify it, where to stop, and - v2 - whether the work is
          still open. Status/claimed_by/completed_sha are carried forward from any
          existing contract so a done action stays done across re-runs; ``mode`` is
          derived deterministically from each action's ``finding`` type.
          """
          prior = _read_prior_action_status(assess_dir)
          valid = []
          for a in actions:
              if not isinstance(a, dict) or not ACTION_REQUIRED_KEYS <= set(a):
                  missing = ACTION_REQUIRED_KEYS - set(a) if isinstance(a, dict) else ACTION_REQUIRED_KEYS
                  print(
                      f"actions.json: dropping malformed entry (missing {sorted(missing)})",
                      file=sys.stderr,
                  )
                  continue
              valid.append(a)
          if not valid:
              return
          entries = []
          for a in sorted(valid, key=lambda a: a["rank"]):
              # Keep whatever the LLM supplied (rank/action/done_when/scope_fence plus
              # any recommended files/first_step/layer/effort/finding), then stamp the
              # v2 lifecycle + derived mode over it so those fields are authoritative.
              entry = {
                  **a,
                  **_carry_status_fields(prior.get(a["action"], {})),
                  "mode": mode_for_finding(a.get("finding")),
              }
              entries.append(entry)
          payload = {
              "schema": ACTIONS_SCHEMA_VERSION,
              "run_id": run_id,
              "actions": entries,
          }
          (assess_dir / "actions.json").write_text(
              json.dumps(payload, indent=2) + "\n", encoding="utf-8"
          )
      
      
      def _locate_input(assess_dir: Path) -> Path:
          """Find finalize-input.json. Prefer the transient cache location; fall back
          to the legacy in-tree path for backwards compatibility.
          """
          cache_path = assess_dir / ".cache" / "finalize-input.json"
          if cache_path.exists():
              return cache_path
          legacy = assess_dir / "finalize-input.json"
          if legacy.exists():
              return legacy
          raise FileNotFoundError(
              f"finalize-input.json not found at {cache_path} or {legacy}"
          )
      
      
      def _load_run_context(assess_dir: Path) -> dict:
          """Load run-context.json, the ground truth finalize reconciles against.
      
          Its absence is a hard failure, not a skip: finalize *reconciles* the
          LLM-authored input against the deterministic core's output, so with no
          run-context there is nothing to reconcile against and the invariants can't be
          enforced - the fail-closed choice is to refuse.
          """
          path = assess_dir / "run-context.json"
          if not path.exists():
              raise FinalizeValidationError(
                  f"run-context.json missing at {path} - finalize cannot reconcile the "
                  "LLM input against the deterministic core's output; refusing to write"
              )
          try:
              return json.loads(path.read_text(encoding="utf-8"))
          except json.JSONDecodeError as e:
              raise FinalizeValidationError(
                  f"run-context.json at {path} is not valid JSON: {e}"
              ) from e
      
      
      def _claimed_maturity_tier(label: str) -> str | None:
          """The canonical maturity tier named inside a (possibly decorated) label.
      
          Returns None when no single recognised tier is present - a custom label with
          zero or ambiguously many keywords carries too little structure to reconcile,
          so that specific check is skipped rather than false-rejected.
          """
          low = label.lower()
          hits = [k for k in _MATURITY_KEYWORDS if k.lower() in low]
          return hits[0] if len(hits) == 1 else None
      
      
      def _layer_score(data: dict, layer: int) -> float | None:
          """The LLM-supplied numeric score for one layer, or None if not carried.
      
          ``layer_scores`` maps a layer id to its 0.0/0.5/1.0 band (Missing/Partial/
          Present). JSON object keys are strings, but an int key is tolerated too. A
          legacy input with no ``layer_scores`` returns None so the layer-cap check is
          skipped rather than firing on absent data.
          """
          scores = data.get("layer_scores")
          if not isinstance(scores, dict):
              return None
          for key in (str(layer), layer):
              if key in scores:
                  try:
                      return float(scores[key])
                  except (TypeError, ValueError):
                      return None
          return None
      
      
      def _validate_run_id_match(data: dict, ctx: dict) -> None:
          """Both artifacts must be from the same run (torn-write detection).
      
          Only enforced when *both* carry a run_id: a legacy artifact missing the
          stamp still finalises (backward compat). A disagreement means the
          finalize-input was authored against a different run-context than the one on
          disk - a torn write finalize must refuse.
          """
          in_id = data.get("run_id")
          ctx_id = ctx.get("run_id")
          if in_id and ctx_id and in_id != ctx_id:
              raise FinalizeValidationError(
                  f"run_id mismatch (torn write): finalize-input run_id {in_id!r} != "
                  f"run-context run_id {ctx_id!r}; refusing to reconcile artifacts from "
                  "different runs"
              )
      
      
      def _validate_denominator(denominator: int, ctx: dict) -> None:
          """The finalize-input denominator must match the archetype's.
      
          Skipped when the archetype scan degraded (no available block / no
          denominator) - honest-degrade beats false-rejecting a run whose archetype
          couldn't be determined.
          """
          archetype = ctx.get("archetype")
          if not isinstance(archetype, dict) or not archetype.get("available"):
              return
          ctx_denominator = archetype.get("denominator")
          if ctx_denominator is None:
              return
          if int(ctx_denominator) != denominator:
              raise FinalizeValidationError(
                  f"denominator mismatch: finalize-input denominator {denominator} != "
                  f"run-context archetype.denominator {ctx_denominator}"
              )
      
      
      def _validate_score(score: float, denominator: int) -> None:
          """A layered score can never exceed its denominator (the display ceiling)."""
          if score > denominator:
              raise FinalizeValidationError(
                  f"score {score} exceeds denominator {denominator}"
              )
      
      
      def _validate_maturity(score: float, denominator: int, label: str) -> None:
          """The maturity label must name the tier the score actually earns.
      
          Bands come from ``lib.badge.maturity_band`` (the single source of truth,
          co-located with the badge colour ratios). A label that claims a different
          tier than the score/denominator fraction earns is rejected - the guardrail
          against a run that quietly overstates (or understates) its own readiness.
          """
          claimed = _claimed_maturity_tier(label)
          if claimed is None:
              return
          expected = maturity_band(score, denominator)
          if claimed != expected:
              ratio = (score / denominator) if denominator else 0.0
              raise FinalizeValidationError(
                  f"maturity_label {label!r} claims tier {claimed!r} but score "
                  f"{score}/{denominator} (ratio {ratio:.3f}) is tier {expected!r}"
              )
      
      
      def _validate_hotspot_actions(data: dict, ctx: dict) -> None:
          """Every hotspot_actions key must be a real top hotspot from run-context.
      
          A path the LLM invented (not in ``stats_summary.top_hotspots``) is a
          fabricated map: the error names the offending path so the cause is obvious.
          """
          hotspot_actions = data.get("hotspot_actions", {})
          if not isinstance(hotspot_actions, dict):
              return
          stats_summary = ctx.get("stats_summary")
          top = stats_summary.get("top_hotspots", []) if isinstance(stats_summary, dict) else []
          known = {h.get("path") for h in top if isinstance(h, dict) and h.get("path")}
          for path in hotspot_actions:
              if path not in known:
                  raise FinalizeValidationError(
                      f"hotspot_actions references {path!r}, which is not a top hotspot "
                      f"in run-context.json (stats_summary.top_hotspots). "
                      f"Known hotspots: {sorted(known)}"
                  )
      
      
      def _validate_layer6_cap(data: dict, ctx: dict) -> None:
          """Layer 6 cannot exceed Partial when mutation testing never ran.
      
          ``mutation_not_run_cap.mutation_run`` (or, for a run-context that predates
          the block, ``test_pressure.mutation_run``) is True only when the bounded
          mutation pass actually executed. Without it, a Present verdict (score > 0.5)
          for Layer 6 is an unproven self-description - the guardrail-erosion failure
          /assess exists to catch - so finalize refuses it. A legacy input carrying no
          ``layer_scores`` is exempt (nothing to check).
          """
          cap = ctx.get("mutation_not_run_cap")
          if isinstance(cap, dict):
              mutation_ran = bool(cap.get("mutation_run", False))
          else:
              tp = ctx.get("test_pressure")
              mutation_ran = bool(isinstance(tp, dict) and tp.get("mutation_run", False))
          if mutation_ran:
              return
          layer6 = _layer_score(data, 6)
          if layer6 is None:
              return
          if layer6 > 0.5:
              raise FinalizeValidationError(
                  "Layer 6 cannot exceed Partial when mutation testing was not run "
                  f"(scored {layer6}). Annotation required: "
                  f"'{MUTATION_NOT_RUN_ANNOTATION}'"
              )
      
      
      def _evidence_layer(entry: object) -> int | None:
          """The layer an evidence entry cites, or None when it names no layer 0-8."""
          if not isinstance(entry, dict):
              return None
          layer = entry.get("layer")
          if isinstance(layer, bool) or not isinstance(layer, int) or not 0 <= layer <= 8:
              return None
          return layer
      
      
      def _validate_evidence(data: dict, repo_root: Path) -> list[dict]:
          """Re-check the input's ``evidence`` list against the repository (#362).
      
          The scorer is a model, and the facts it cites ("docs/guide.md is absent",
          "no workflow calls scripts/check-x.sh") are checked again here with the
          deterministic ``lib.evidence_check``, each ``path`` resolved against
          ``repo_root`` (the parent of ``.assess/``). Grouped by ``layer``:
      
          - every entry of a layer rejected: the verdict rests on nothing true, so
            finalize refuses, naming each rejected entry by kind, path and needle;
          - some entries of a layer rejected (a mixed layer): the verdict still rests
            on a verified fact, so finalize proceeds, and the rejected entries are
            returned for the caller to print as warnings.
      
          An input with no ``evidence`` key is accepted unchecked, as an input with
          no ``layer_scores`` skips the Layer 6 cap. A malformed list (not a list, or
          an entry naming no layer 0-8) cannot be attributed to a verdict, so it
          fails closed. An entry that names its layer but is otherwise malformed
          (unknown kind, missing path or needle) is one the library rejects, so it
          counts as a rejected entry of that layer under the rule above.
          """
          if "evidence" not in data:
              return []
          entries = data["evidence"]
          if not isinstance(entries, list):
              raise FinalizeValidationError(
                  "evidence must be a list of entries "
                  f"({{layer, kind, path[, needle]}}), got {type(entries).__name__}"
              )
          for entry in entries:
              if not isinstance(entry, dict):
                  raise FinalizeValidationError(f"evidence entry {entry!r} is not an object")
              if _evidence_layer(entry) is None:
                  raise FinalizeValidationError(
                      f"evidence entry {describe(entry)} names no layer 0-8 "
                      f"(layer={entry.get('layer')!r})"
                  )
          result = check_evidence(repo_root, entries)
          verified_layers = {_evidence_layer(e) for e in result["evidence"]}
          unsupported = [e for e in result["evidence_rejected"] if e["layer"] not in verified_layers]
          if unsupported:
              named = "; ".join(
                  f"layer {e['layer']}: {describe(e)} ({e['reason']})" for e in unsupported
              )
              raise FinalizeValidationError(
                  f"a layer verdict rests only on rejected evidence: {named}. "
                  "Correct the verdict or its evidence, then re-run finalize."
              )
          return result["evidence_rejected"]
      
      
      def _validate_finalize_input(
          data: dict, ctx: dict, *, denominator: int, repo_root: Path
      ) -> list[dict]:
          """Run every finalize invariant. Raises FinalizeValidationError on the first
          violation, before any write - so a bad input reaches nothing.
      
          Returns the rejected evidence entries of mixed layers, which do not block
          finalize but are reported as warnings.
          """
          _validate_run_id_match(data, ctx)
          _validate_denominator(denominator, ctx)
          _validate_score(float(data["score"]), denominator)
          _validate_maturity(float(data["score"]), denominator, data["maturity_label"])
          _validate_hotspot_actions(data, ctx)
          _validate_layer6_cap(data, ctx)
          return _validate_evidence(data, repo_root)
      
      
      def finalize_run(*, assess_dir: Path) -> None:
          """Read finalize-input.json, validate it against run-context.json, apply it
          to log.md and hotspot pages, then delete the input file.
      
          Fail-closed: run-context.json is read and every invariant checked *before*
          any write, including the re-check of any ``evidence`` list against the
          repository root (the parent of ``assess_dir``). Any violation raises
          ``FinalizeValidationError`` and nothing is written.
          """
          input_path = _locate_input(assess_dir)
          data = json.loads(input_path.read_text(encoding="utf-8"))
          ctx = _load_run_context(assess_dir)
          # Denominator: 8 for a software repo (the display ceiling), or the count of
          # applicable layers for a knowledge base (issue #224). Defaults to 8 so a
          # pre-archetype finalize-input.json finalises exactly as before.
          denominator = int(data.get("denominator", 8))
          tolerated = _validate_finalize_input(
              data, ctx, denominator=denominator, repo_root=assess_dir.parent
          )
          target = _log_target(assess_dir, ctx.get("run_id") or data.get("run_id"))
          _validate_no_earlier_same_date_placeholders(assess_dir, target)
          _finalize_log(
              assess_dir,
              target=target,
              score=data["score"],
              maturity_label=data["maturity_label"],
              top_action=data["top_action"],
              denominator=denominator,
          )
          _finalize_hotspot_actions(assess_dir, hotspot_actions=data.get("hotspot_actions", {}))
          actions = data.get("actions")
          if isinstance(actions, list) and actions:
              _write_actions_contract(assess_dir, actions, run_id=ctx.get("run_id"))
          for e in tolerated:
              print(
                  f"finalize: warning: layer {e['layer']} evidence rejected, verdict kept on "
                  f"its verified entries: {describe(e)} ({e['reason']})",
                  file=sys.stderr,
              )
          # The shipped badge stays deterministic: assess_core wrote the findings-count
          # badge.json (linking to this report). Finalize deliberately does NOT
          # overwrite it with the LLM-derived score - that grade appears inside
          # assess-report.md instead, so the badge never claims a number a
          # deterministic run cannot reproduce.
          # Clean up: the input file is consumed - delete from both the cache and
          # the legacy in-tree location so a stale copy can't leak into a commit.
          try:
              input_path.unlink()
          except OSError:
              pass
          legacy = assess_dir / "finalize-input.json"
          if legacy != input_path and legacy.exists():
              try:
                  legacy.unlink()
              except OSError:
                  pass
      
      
      def drop_unfinalized_entry(*, assess_dir: Path, run_id: str) -> None:
          """Replace the never-finalized log entry stamped ``run_id`` with a tombstone.
      
          The supported way out of the earlier-same-date refusal: the entry's own
          run-context has been overwritten, so it can never be finalized, and deleting
          it by hand breaks the chain for every later entry. The entry becomes a
          one-line chained note (no heading, no stamp, no placeholders) so the log
          still records that the run existed; the chain is recomputed from there. A
          finalized entry is history and is refused.
          """
          idx = find_log_entry(assess_dir, run_id)
          if idx is None:
              raise FinalizeValidationError(f"log.md has no entry stamped run_id={run_id}")
          content = read_log_entries(assess_dir)[idx]
          if not log_entry_owns_span(content, run_id):
              raise FinalizeValidationError(
                  f"log.md entry run_id={run_id} shares its span with log history written "
                  "before the integrity chain existed; dropping it would drop that history "
                  "too. Leave it in place."
              )
          if not log_entry_is_unfinalized(content):
              raise FinalizeValidationError(
                  f"log.md entry run_id={run_id} is finalized; finalized entries are history "
                  "and are not removed"
              )
          day = log_entry_date(content) or "unknown date"
          tombstone = (
              f"> Dropped run {run_id} ({day}): never finalized, its run-context was "
              "superseded; removed with assess_finalize.py --drop-entry.\n\n---\n"
          )
          rewrite_log_entry(assess_dir, idx, tombstone)
      
      
      def main() -> int:
          args = sys.argv[1:]
          usage = "Usage: assess_finalize.py <repo_root> [--drop-entry <run_id>]"
          if len(args) not in (1, 3) or (len(args) == 3 and args[1] != "--drop-entry"):
              print(usage, file=sys.stderr)
              return 2
          repo_root = Path(args[0]).resolve()
          assess_dir = repo_root / ".assess"
          try:
              if len(args) == 3:
                  drop_unfinalized_entry(assess_dir=assess_dir, run_id=args[2])
              else:
                  finalize_run(assess_dir=assess_dir)
          except FinalizeValidationError as e:
              # Fail-closed: a violated invariant means finalize wrote nothing. Name
              # the specific violation and exit non-zero so the run surfaces it.
              print(f"finalize refused: {e}", file=sys.stderr)
              return 1
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • assess_gate.py 12.5 KB
      """CI gate for /assess - the enforcement half of the frozen harness.
      
      Reads ``.assess/run-context.json`` (written by ``assess_core.py``) and the
      ``[gate]`` section of ``.assess/config.toml``, then decides whether the current
      snapshot should fail a pull request. Two kinds of check, both strictly opt-in:
      
      - **Readiness floors** (absolute, evaluated on the current snapshot): a finding
        in ``fail_on`` is present, p95 complexity exceeds ``ccn_p95_max``, or the
        safe-zone containment ratio drops below ``containment_min``.
      - **Regression** (cross-run): with ``fail_on_regression = true``, fail when the
        diff ``assess_core`` computed against the prior committed snapshot reports
        hotspots whose complexity/churn increased. This needs a prior snapshot and a
        reliable diff; on a first run or an unreliable diff it is skipped, never
        fired - a freshly-cloned repo never trips a regression gate on its first PR.
      
      The defaults are warn-only: with no config, every finding is reported but
      nothing fails, so adopting the emitted workflow never blocks a pipeline by
      surprise.
      
      Exit codes:
          0  pass (nothing failing, the gate is disabled / warn-only, OR an
             infrastructure failure - missing/corrupt run-context - was skipped: an
             infra failure is never a red check on an unrelated PR)
          1  fail (a floor was breached, or an opted-in regression fired)
          2  usage error (no repo_root argument)
      
      Run:
          uv run assess_gate.py <repo_root> [--config <path>]
      """
      # /// script
      # requires-python = ">=3.11"
      # ///
      from __future__ import annotations
      
      import json
      import sys
      from pathlib import Path
      from typing import Any
      
      # scripts/ is on sys.path (pyproject pythonpath); lib is a package under it.
      from lib.assess_config import load_gate_config, load_gate_config_file
      
      
      def _findings_by_name(ctx: dict) -> dict[str, dict]:
          """Index ``derived_findings`` by finding name for O(1) lookup."""
          return {
              f["name"]: f
              for f in ctx.get("derived_findings", [])
              if isinstance(f, dict) and "name" in f
          }
      
      
      def check_finding_regressions(
          ctx: dict, gate: dict
      ) -> tuple[list[dict], list[dict]]:
          """Split flagged findings into (failures, warnings) per the gate config.
      
          A finding "fires" when it has a non-empty ``paths`` list - that is the
          deterministic core's own signal that the concern is present in this repo.
          ``fail_on`` takes precedence over ``warn_on`` so a finding named in both is
          only ever counted once, as a failure.
          """
          findings = _findings_by_name(ctx)
          fail_on = gate.get("fail_on", [])
          failures: list[dict] = []
          warnings: list[dict] = []
      
          def _fired(name: str) -> dict | None:
              f = findings.get(name)
              if f and f.get("paths"):
                  return {"finding": name, "count": len(f["paths"]), "paths": f["paths"][:5]}
              return None
      
          for name in fail_on:
              hit = _fired(name)
              if hit is not None:
                  failures.append(hit)
          for name in gate.get("warn_on", []):
              if name in fail_on:
                  continue
              hit = _fired(name)
              if hit is not None:
                  warnings.append(hit)
          return failures, warnings
      
      
      def check_complexity_threshold(ctx: dict, gate: dict) -> list[dict]:
          """Return a one-element list when p95 file CCN exceeds ``ccn_p95_max``."""
          threshold = gate.get("ccn_p95_max")
          if threshold is None:
              return []
          current = ctx.get("stats_summary", {}).get("ccn", {}).get("p95")
          if isinstance(current, (int, float)) and current > threshold:
              return [{"metric": "ccn_p95", "value": current, "threshold": threshold}]
          return []
      
      
      def _containment_ratio(ctx: dict) -> float | None:
          """Safe zones / (safe zones + total concerns) from the keyhole summary.
      
          The deterministic core already rolls the findings into a keyhole summary
          with a safe-zone count and a total-concern count. The ratio is the share of
          examined units that are clean refactor boundaries; ``None`` when there is
          nothing to score (no units flagged either way).
          """
          summary = ctx.get("keyhole_summary") or {}
          safe = summary.get("safe_zones")
          concerns = summary.get("total_concerns")
          if not isinstance(safe, int) or not isinstance(concerns, int):
              return None
          denom = safe + concerns
          if denom <= 0:
              return None
          return safe / denom
      
      
      def check_containment_threshold(ctx: dict, gate: dict) -> list[dict]:
          """Return a one-element list when the containment ratio drops below the floor."""
          floor = gate.get("containment_min")
          if floor is None:
              return []
          ratio = _containment_ratio(ctx)
          if ratio is not None and ratio < floor:
              return [{"metric": "containment", "value": round(ratio, 3), "threshold": floor}]
          return []
      
      
      def check_diff_regression(ctx: dict, gate: dict) -> list[dict]:
          """Return a regression breach when the cross-run diff reports a worsening.
      
          Only fires when ``fail_on_regression`` is set AND there is a reliable diff
          against a prior committed snapshot. ``assess_core`` already computed the diff
          (graduated / new / regressed) into the run-context; "regressed" is its term
          for hotspots whose complexity or churn increased since the prior run. On a
          first run (no prior) or an unreliable diff (version-mismatched filter), it
          returns nothing - a freshly-adopted repo can't trip a regression gate.
          """
          if not gate.get("fail_on_regression"):
              return []
          if not ctx.get("prior_stats_exists") or not ctx.get("diff_reliable", True):
              return []
          regressed = ctx.get("diff_detail", {}).get("regressed", [])
          if not regressed:
              return []
          return [{
              "metric": "regressed_hotspots",
              "count": len(regressed),
              "paths": [r.get("path", "?") for r in regressed[:5]],
          }]
      
      
      def evaluate(ctx: dict, gate: dict) -> dict:
          """Run every check and return a structured verdict.
      
          ``failed`` is the gate decision; ``warnings`` are reported but never fail.
          A disabled gate collects the same diagnostics (so the log is honest) but
          always reports ``failed = False``.
          """
          failures, warnings = check_finding_regressions(ctx, gate)
          threshold_breaches = (
              check_complexity_threshold(ctx, gate)
              + check_containment_threshold(ctx, gate)
              + check_diff_regression(ctx, gate)
          )
          blocking = bool(failures or threshold_breaches)
          return {
              "enabled": gate.get("enabled", True),
              "failed": blocking and gate.get("enabled", True),
              "failures": failures,
              "warnings": warnings,
              "threshold_breaches": threshold_breaches,
              # Carried through so the verdict log can disclose findings the config
              # excludes suppressed - the gate must never read clean when a real
              # finding was filtered out by an exclude.
              "excluded_by_config": ctx.get("excluded_by_config"),
              # Files the treemap dropped as generated, so the log names the reasons.
              "excluded_generated": ctx.get("excluded_generated"),
          }
      
      
      def format_verdict(verdict: dict) -> str:
          """Render a human-readable summary for the CI log."""
          lines: list[str] = ["/assess gate"]
          if not verdict["enabled"]:
              lines.append("  gate disabled in config - reporting only, never failing")
          for breach in verdict["threshold_breaches"]:
              if breach["metric"] == "regressed_hotspots":
                  sample = ", ".join(breach["paths"])
                  lines.append(
                      f"  FAIL regression: {breach['count']} hotspot(s) worsened since "
                      f"the prior snapshot ({sample})"
                  )
              else:
                  lines.append(
                      f"  FAIL threshold: {breach['metric']} = {breach['value']} "
                      f"(max/min {breach['threshold']})"
                  )
          for f in verdict["failures"]:
              sample = ", ".join(f["paths"])
              lines.append(f"  FAIL finding: {f['finding']} x{f['count']} ({sample})")
          for w in verdict["warnings"]:
              sample = ", ".join(w["paths"])
              lines.append(f"  warn finding: {w['finding']} x{w['count']} ({sample})")
          if not verdict["failures"] and not verdict["threshold_breaches"] and not verdict["warnings"]:
              lines.append("  no findings fired - clean snapshot")
          disclosure = _format_exclusion_disclosure(verdict.get("excluded_by_config"))
          if disclosure:
              lines.append(disclosure)
          generated = _format_generated_disclosure(verdict.get("excluded_generated"))
          if generated:
              lines.append(generated)
          lines.append("  RESULT: " + ("FAIL" if verdict["failed"] else "PASS"))
          return "\n".join(lines)
      
      
      def _format_exclusion_disclosure(excluded_by_config: dict | None) -> str:
          """One indented line disclosing findings the config excludes suppressed.
      
          Returns ``""`` when nothing was suppressed, so a clean run's log is
          unchanged. When a path that would have been a finding was filtered out by a
          config exclude, the suppression is stated with the excluding dirs/patterns so
          a reader never mistakes a filtered PASS for a genuinely clean one.
          """
          block = excluded_by_config or {}
          count = block.get("count", 0)
          if not isinstance(count, int) or count <= 0:
              return ""
          dirs = ", ".join(block.get("dirs", [])) or "none"
          patterns = ", ".join(block.get("patterns", [])) or "none"
          noun = "finding" if count == 1 else "findings"
          return (
              f"  {count} {noun} suppressed by config excludes "
              f"(dirs: {dirs}; patterns: {patterns})"
          )
      
      
      # Per-file lines the gate log lists before summarising the rest as "+N more".
      GENERATED_DISCLOSURE_MAX_PATHS = 10
      
      
      def _format_generated_disclosure(excluded_generated: list | None) -> str:
          """A summary line naming each distinct reason files were excluded as
          generated, then one indented ``path (reason)`` line per file (capped at
          ``GENERATED_DISCLOSURE_MAX_PATHS``), or ``""`` when none were, so a clean
          run's log is unchanged."""
          rows = [
              r for r in (excluded_generated or [])
              if isinstance(r, dict) and r.get("path") and r.get("reason")
          ]
          if not rows:
              return ""
          reasons = sorted({r["reason"] for r in rows})
          noun = "file" if len(rows) == 1 else "files"
          lines = [
              f"  {len(rows)} {noun} excluded from scoring as generated "
              f"(reasons: {', '.join(reasons)})"
          ]
          shown = rows[:GENERATED_DISCLOSURE_MAX_PATHS]
          lines.extend(f"    {r['path']} ({r['reason']})" for r in shown)
          if len(rows) > len(shown):
              lines.append(f"    +{len(rows) - len(shown)} more")
          return "\n".join(lines)
      
      
      def load_context(repo_root: Path) -> dict[str, Any]:
          """Load ``.assess/run-context.json`` from a repo root."""
          ctx_path = repo_root / ".assess" / "run-context.json"
          return json.loads(ctx_path.read_text(encoding="utf-8"))
      
      
      def main(argv: list[str] | None = None) -> int:
          args = list(sys.argv[1:] if argv is None else argv)
          # --config points the gate at a config file outside the conventional
          # .assess/config.toml. Consume both the flag and its value so the value
          # isn't mistaken for the positional repo root.
          config_path: str | None = None
          positional: list[str] = []
          i = 0
          while i < len(args):
              arg = args[i]
              if arg == "--config":
                  if i + 1 < len(args):
                      config_path = args[i + 1]
                  i += 2
                  continue
              if arg.startswith("-"):
                  i += 1
                  continue
              positional.append(arg)
              i += 1
          if not positional:
              print("Usage: assess_gate.py <repo_root> [--config <path>]", file=sys.stderr)
              return 2
          repo_root = Path(positional[0]).resolve()
          try:
              ctx = load_context(repo_root)
          except (OSError, json.JSONDecodeError) as e:
              # The run-context is missing or corrupt because the deterministic core
              # failed to produce it - an infrastructure failure, not an AI-readiness
              # finding. Skip with a clear notice and exit 0 so a broken render never
              # fails a PR that has nothing to do with the breakage. The gate runs
              # again on the next push, when the core has a clean run-context.
              print(
                  f"/assess gate skipped: infrastructure failure "
                  f"({type(e).__name__}: {e}).\n"
                  "This is an infra issue, not a finding. The gate will run on the "
                  "next push.",
                  file=sys.stderr,
              )
              return 0
          gate = (
              load_gate_config_file(Path(config_path))
              if config_path is not None
              else load_gate_config(repo_root)
          )
          verdict = evaluate(ctx, gate)
          print(format_verdict(verdict))
          return 1 if verdict["failed"] else 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • assess_report.py 22.8 KB
      """Deterministic report renderer for /assess.
      
      Reads ``.assess/run-context.json`` and renders a Markdown report from a stdlib
      ``string.Template``. This is the *frozen harness*: it runs with zero model
      tokens and produces the same report for the same context, on every PR forever.
      
      It renders:
      - a metrics dashboard (complexity profile, churn window, total LOC),
      - the top-hotspots table,
      - the keyhole-readiness summary + the six cross-layer findings, and
      - the regression deltas (graduated / new / regressed / persistent).
      
      It deliberately does NOT reproduce (these require LLM judgement and are written
      elsewhere by the orchestrator, see SKILL.md):
      - the 0-8 layered readiness score,
      - the per-layer present/partial/missing prose, or
      - the Top 3 Actions priority narrative.
      
      The findings section, the keyhole summary, and the prescribed actions are
      already serialised into ``run-context.json`` by the deterministic core
      (``assess_core.build_run_context`` -> ``keyhole_signals``). This renderer
      *consumes* those products verbatim; it does not re-derive them.
      
      Run:
          uv run assess_report.py <repo_root>            # writes .assess/deterministic-report.md
          uv run assess_report.py <repo_root> --stdout   # prints the report to stdout
      """
      # /// script
      # requires-python = ">=3.11"
      # ///
      from __future__ import annotations
      
      import json
      import sys
      from pathlib import Path
      from string import Template
      from typing import Any
      
      # Caps on rows rendered so a pathological repo can't bloat the frozen report.
      # The structured arrays in run-context.json keep the full lists.
      MAX_HOTSPOT_ROWS = 10
      MAX_DIFF_ROWS = 10
      
      # Diff categories rendered in order, with the gloss shown beside each count.
      DIFF_CATEGORIES = [
          ("graduated", "Graduated", "left the hotspot list"),
          ("new", "New", "entered the hotspot list"),
          ("regressed", "Regressed", "complexity or churn increased"),
          ("persistent", "Persistent", "still in the hotspot list"),
      ]
      
      # Caps on listed empty-ownership patterns so a pathologically broken ownership
      # map can't bloat the frozen report. The full list stays in run-context.json.
      MAX_EMPTY_PATTERNS_RENDERED = 20
      
      # Tier 1 grouping-disagreement metrics, in render order. Each row is the
      # run-context key stem (the ``_count`` suffix is added at read time), a
      # human label, and the one-line interpretation - the disagreement (or
      # agreement) the metric measures between the declared boundary, the static
      # import graph, and the co-change history. The objective count is rendered
      # first, the interpretation second (SKILL.md deterministic-findings rule).
      TIER1_METRICS = [
          ("human_grouped_static_splits",
           "declared together, import graph splits them",
           "a boundary the dependency structure no longer backs"),
          ("human_split_static_fuses",
           "import graph fuses them, no declared boundary does",
           "cohesion the ownership map misses"),
          ("human_grouped_never_cochange",
           "declared together, commit log never couples them",
           "a boundary history does not exercise as a unit"),
          ("human_split_but_cochange",
           "keep co-changing, no declared boundary groups them",
           "a hidden seam the map omits (folded into hidden_coupling above)"),
          ("human_static_agree",
           "declared and dependency lenses agree",
           "boundary backed by the import graph"),
          ("human_cochange_agree",
           "declared and historical lenses agree",
           "boundary exercised as a unit by history"),
      ]
      
      REPORT_TEMPLATE = Template("""# Deterministic Assessment Snapshot: $repo_name
      
      _Generated $run_date by `/assess` v$plugin_version.${commit_note}_
      
      ## Metrics Dashboard
      
      - **Files scored:** $files_scored
      - **Total LOC:** $loc_total
      - **Complexity profile:** p95 LOC $loc_p95 (max $loc_max$loc_split), p95 CCN $ccn_p95 (max $ccn_max)
      - **Churn window:** $churn_window
      
      ### Top Hotspots
      
      $hotspots_table
      
      ## Keyhole Readiness
      
      $keyhole_summary
      
      $findings_section
      $structure_drift_section
      ## Changes Since Last Run
      
      $diff_section
      
      ---
      
      _Deterministic portion of the assessment: metrics, hotspots, and cross-layer findings. The 0-8 layer scores, the per-layer prose, and the Top 3 Actions priority narrative require LLM judgement and are written separately._
      """)
      
      
      def _fmt(value: Any) -> str:
          """Format a metric for display: ints as ints, whole floats without a
          trailing ``.0``, fractional floats to one decimal, ``None`` as ``?``."""
          if value is None:
              return "?"
          if isinstance(value, bool):
              return str(value)
          if isinstance(value, float):
              return str(int(value)) if value.is_integer() else f"{value:.1f}"
          return str(value)
      
      
      def render_hotspots_table(ctx: dict) -> str:
          """Render the top hotspots as a Markdown table (capped at MAX_HOTSPOT_ROWS)."""
          hotspots = ctx.get("stats_summary", {}).get("top_hotspots", [])
          if not hotspots:
              return "_No hotspots identified._"
          lines = ["| Path | LOC | CCN | Commits |", "|------|-----|-----|---------|"]
          for h in hotspots[:MAX_HOTSPOT_ROWS]:
              lines.append(
                  f"| `{h.get('path', '?')}` | {_fmt(h.get('loc'))} | "
                  f"{_fmt(h.get('ccn'))} | {_fmt(h.get('commits'))} |"
              )
          return "\n".join(lines)
      
      
      def render_keyhole_summary(ctx: dict) -> str:
          """Return the pre-built keyhole-readiness summary line (consumed verbatim)."""
          summary = ctx.get("keyhole_summary") or {}
          text = summary.get("summary_text")
          return text if text else "_Keyhole readiness summary unavailable._"
      
      
      def render_exclusion_disclosure(ctx: dict) -> str:
          """Lines disclosing config-excluded findings and archive paths, or ``""``.
      
          Config excludes silently drop paths from every scan; when at least one path
          that would have been a finding is filtered out, this makes the suppression
          visible - a reader must never mistake a filtered report for a clean one.
          Returns ``""`` when nothing was suppressed, so a clean run's report is byte
          identical to before this disclosure existed. A second line names the paths
          kept out of the attention list because they sit under an ``archive/``,
          ``archived/`` or ``attic/`` directory (``excluded_as_archive``), and a
          third the git-history paths pruned because they no longer exist
          (``pruned_finding_paths``), and a last one says when ``attention_low_signal``
          cut the prescribed actions to rank 1.
          """
          lines: list[str] = []
          block = ctx.get("excluded_by_config") or {}
          count = block.get("count", 0)
          if isinstance(count, int) and count > 0:
              dirs = ", ".join(block.get("dirs", [])) or "none"
              patterns = ", ".join(block.get("patterns", [])) or "none"
              noun = "finding" if count == 1 else "findings"
              lines.append(
                  f"_{count} {noun} suppressed by config excludes "
                  f"(dirs: {dirs}; patterns: {patterns})._"
              )
          archived = ctx.get("excluded_as_archive") or {}
          a_count = archived.get("count", 0)
          if isinstance(a_count, int) and a_count > 0:
              # Name at most five, as assess_gate does; the full list stays in
              # run-context.json.
              named = list(archived.get("affected_finding_paths", []))
              paths = ", ".join(named[:5])
              if len(named) > 5:
                  paths += f" +{len(named) - 5} more"
              noun = "path" if a_count == 1 else "paths"
              lines.append(
                  f"_{a_count} archived {noun} left out of the attention list: {paths}._"
              )
          pruned = ctx.get("pruned_finding_paths") or {}
          p_count = pruned.get("count", 0)
          if isinstance(p_count, int) and p_count > 0:
              named = list(pruned.get("paths", []))
              paths = ", ".join(named[:5])
              if len(named) > 5:
                  paths += f" +{len(named) - 5} more"
              verbs = "path no longer exists and was" if p_count == 1 else (
                  "paths no longer exist and were")
              lines.append(
                  f"_{p_count} git-history {verbs} left out of the findings: {paths}._"
              )
          if pruned.get("rename_map_complete") is False:
              lines.append(
                  "_Renames could not be read from git history: findings may name "
                  "pre-rename paths, and none were pruned._"
              )
          if ctx.get("attention_low_signal") is True:
              lines.append(
                  "_Attention ranking is low signal (no attention row lands in more than one "
                  "finding): only rank 1 is prescribed._"
              )
          return "\n\n".join(lines)
      
      
      # Generated-file rows shown on the report surface before the rest are folded.
      GENERATED_DISCLOSURE_VISIBLE = 10
      
      
      def render_generated_disclosure(ctx: dict) -> str:
          """Name each file the treemap excluded as generated, with its reason.
      
          One line per file (``- `path` (reason)``) under a count line, or ``""`` when
          ``excluded_generated`` is empty or absent, so a run with nothing excluded
          renders exactly as before. The first ``GENERATED_DISCLOSURE_VISIBLE`` rows
          sit on the report surface; the rest go in a ``<details>`` fold, so every
          path stays named without hundreds of bullets displacing the findings.
          """
          rows = [
              r for r in (ctx.get("excluded_generated") or [])
              if isinstance(r, dict) and r.get("path") and r.get("reason")
          ]
          if not rows:
              return ""
          noun = "file" if len(rows) == 1 else "files"
          lines = [f"_{len(rows)} {noun} excluded from scoring as generated:_", ""]
          visible = rows[:GENERATED_DISCLOSURE_VISIBLE]
          rest = rows[GENERATED_DISCLOSURE_VISIBLE:]
          lines.extend(f"- `{r['path']}` ({r['reason']})" for r in visible)
          if rest:
              lines += ["", f"<details><summary>{len(rest)} more</summary>", ""]
              lines.extend(f"- `{r['path']}` ({r['reason']})" for r in rest)
              lines += ["", "</details>"]
          return "\n".join(lines)
      
      
      def render_findings_section(ctx: dict) -> str:
          """Return the pre-rendered cross-layer findings section, verbatim.
      
          The deterministic core already renders the six findings + attention list
          into ``findings_markdown``; this renderer copies it so the section appears
          whether or not an LLM is in the loop.
          """
          markdown = ctx.get("findings_markdown")
          if not markdown or not markdown.strip():
              return "_No cross-layer findings recorded._"
          return markdown.strip()
      
      
      def _render_tier0_drift(tier_0: dict) -> list[str]:
          """Tier 0 ownership-map drift: declared globs matching zero tracked files.
      
          Each empty pattern is a slice of the tree the ownership map *claims* to
          cover but no file satisfies, so any edit there silently skips the declared
          owner's review. Lists the pattern, where it was declared, and the owners it
          would have routed to. Returns ``[]`` (caller omits the section) when Tier 0
          is unavailable or no pattern is empty - absence reads as "nothing stale",
          never a broken half-section.
          """
          if not tier_0.get("available"):
              return []
          patterns = tier_0.get("empty_ownership_patterns") or []
          if not patterns:
              return []
          ordered = sorted(
              patterns,
              key=lambda p: (p.get("pattern", ""), p.get("declared_in", "")),
          )
          lines = [
              "### Tier 0 - Ownership Map Drift",
              "",
              (f"{len(patterns)} declared ownership pattern"
               f"{'' if len(patterns) == 1 else 's'} match zero tracked files. "
               "Stale ownership silently drops review coverage: an edit under one of "
               "these patterns routes to no declared owner."),
              "",
          ]
          for p in ordered[:MAX_EMPTY_PATTERNS_RENDERED]:
              owners = p.get("owners") or []
              owners_text = ", ".join(owners) if owners else "_no owners declared_"
              lines.append(
                  f"- `{p.get('pattern', '?')}` "
                  f"(declared in {p.get('declared_in', '?')}; owners: {owners_text})"
              )
          overflow = len(ordered) - MAX_EMPTY_PATTERNS_RENDERED
          if overflow > 0:
              lines.append(f"- ...and {overflow} more")
          lines.append("")
          return lines
      
      
      def _render_tier1_disagreement(tier_1: dict, repo_name: str) -> list[str]:
          """Tier 1 grouping disagreement: six set-algebra counts over the declared,
          static-import, and co-change groupings, plus the seam-allowlist note.
      
          The objective counts lead; the interpretation follows each (SKILL.md
          deterministic-findings rule). The hidden-seam direction
          (``human_split_but_cochange``) already folds into the ``hidden_coupling``
          finding above, so this block surfaces the explicit magnitudes and the
          allowlist transparency line rather than re-rendering that finding. Returns
          ``[]`` (caller omits) when the static lens was unavailable.
          """
          if not tier_1.get("available"):
              return []
          lines = [
              "### Tier 1 - Grouping Disagreement",
              "",
              ("Set-algebra over three groupings - the declared boundary, the static "
               "import graph, and the co-change history - counting the file pairs each "
               "lens pair agrees or disagrees on:"),
              "",
          ]
          for key, label, interpretation in TIER1_METRICS:
              count = tier_1.get(f"{key}_count", 0)
              lines.append(f"- **{count}** `{key}` - {label}: {interpretation}")
          lines.append("")
          if tier_1.get("seam_allowlist_applied"):
              n = tier_1.get("allowlist_pairs_count", 0)
              note = (
                  f"Seam allowlist applied: {n} owned seam pair"
                  f"{'' if n == 1 else 's'} excluded from the disagreement counts "
                  "before they were reported."
              )
              if _is_self_assessment(repo_name):
                  note += " See `lib/README.md` for the owned seams."
              lines.append(note)
              lines.append("")
          return lines
      
      
      def _is_self_assessment(repo_name: str) -> bool:
          """True when /assess is assessing its own repo - the only case where the
          ``lib/README.md`` owned-seams footnote points at a file that exists."""
          return repo_name == "ai-native-toolkit"
      
      
      def format_structure_drift_findings(
          structure_drift: dict | None, repo_name: str = "",
      ) -> str:
          """Render the run-context ``structure_drift`` block as a Markdown section.
      
          Two tiers, each independently omitted when it has nothing to say:
          - **Tier 0** lists declared ownership patterns matching no tracked file.
          - **Tier 1** surfaces the six grouping-disagreement counts and the
            seam-allowlist transparency line.
      
          Counts lead, interpretation follows; the harness never auto-prescribes
          regenerating the ownership map - that is a human decision, stated here only
          so the human can make it. Returns ``""`` (caller omits the heading) when the
          block is absent or both tiers are empty - graceful degrade, no broken
          markdown.
          """
          if not structure_drift:
              return ""
          body: list[str] = []
          body += _render_tier0_drift(structure_drift.get("tier_0") or {})
          body += _render_tier1_disagreement(
              structure_drift.get("tier_1") or {}, repo_name,
          )
          if not body:
              return ""
          return "## Structure Drift\n\n" + "\n".join(body).rstrip() + "\n"
      
      
      def _format_transition(category: str, entry: dict) -> str:
          """Render one hotspot transition as a bullet, with deltas for regressions."""
          path = entry.get("path", "?")
          if category != "regressed":
              return f"  - `{path}`"
          bits = []
          ccn_delta = entry.get("ccn_delta", 0)
          loc_delta = entry.get("loc_delta", 0)
          if ccn_delta:
              bits.append(f"CCN {ccn_delta:+d}")
          if loc_delta:
              bits.append(f"LOC {loc_delta:+d}")
          suffix = f" ({', '.join(bits)})" if bits else ""
          return f"  - `{path}`{suffix}"
      
      
      def render_diff_section(ctx: dict) -> str:
          """Render the regression deltas, honouring the first-run and reliability flags.
      
          No prior snapshot -> say so. An unreliable diff (plugin-version mismatch in
          the file filter) is suppressed with its note rather than shown, mirroring the
          SKILL.md rule that phantom transitions must not read as real improvement.
          """
          if not ctx.get("prior_stats_exists"):
              return "_No prior run to compare against - this is the first recorded snapshot._"
          if not ctx.get("diff_reliable", True):
              note = ctx.get("diff_version_note") or "prior and current snapshots are not comparable"
              if ctx.get("diff_trend_reset"):
                  # A MAJOR version bump broke comparability: state the reset explicitly
                  # so a suppressed diff isn't misread as a clean, unchanged run.
                  return (
                      f"_Trend baseline reset: {note}. Prior hotspot history is not "
                      "comparable across a major version; the trend restarts from this "
                      "snapshot._"
                  )
              return f"_Diff suppressed: {note}._"
      
          summary = ctx.get("diff", {})
          detail = ctx.get("diff_detail", {})
          lines: list[str] = []
          for key, label, gloss in DIFF_CATEGORIES:
              entries = detail.get(key, [])
              count = summary.get(key, len(entries))
              lines.append(f"- **{label}** ({gloss}): {count}")
              for entry in entries[:MAX_DIFF_ROWS]:
                  lines.append(_format_transition(key, entry))
              overflow = len(entries) - MAX_DIFF_ROWS
              if overflow > 0:
                  lines.append(f"  - ...and {overflow} more")
          return "\n".join(lines)
      
      
      def _render_commit_note(ctx: dict) -> str:
          """Render the measured-commit provenance suffix for the generated-by line.
      
          Pins the SHA the absolute LOC/CCN numbers were measured at and warns when
          HEAD is dirty or behind upstream, so the figures aren't read as current
          when they describe an uncommitted or stale tree. Returns a leading-space
          string (it follows ``v<version>.``) or ``""`` when provenance is unknown.
          """
          commit = ctx.get("measured_commit") or {}
          if not commit.get("available"):
              return ""
          short = commit.get("head_short") or str(commit.get("head_sha", ""))[:7]
          note = f" Measured at `{short}`"
          subject = commit.get("subject")
          if subject:
              note += f' ("{subject}")'
          warnings = []
          if commit.get("dirty"):
              warnings.append("working tree dirty")
          behind = commit.get("behind")
          if isinstance(behind, int) and behind > 0:
              warnings.append(f"{behind} commit(s) behind upstream")
          if warnings:
              note += f" - {', '.join(warnings)}"
          return note + "."
      
      
      def _render_churn_window(ctx: dict) -> str:
          """Surface the churn-window label the treemap/doc-staleness pass settled on.
      
          When the history is degenerate (every file ~1 commit - a snapshot with no
          usable history), the label carries a caveat so the churn axis, the saturation
          treemap channel, and any churn-derived finding are read as inactive rather
          than as a live signal (issue #172).
          """
          doc_staleness = ctx.get("doc_staleness") or {}
          window = doc_staleness.get("churn_window")
          label = window if window else "unavailable"
          if ctx.get("churn_degenerate"):
              return f"{label} - snapshot / no usable history, churn signal flat"
          return label
      
      
      def _render_loc_split(loc: dict) -> str:
          """The code and data LOC maxima (scc language split in the stats file), as
          ``; code N, data M`` after the overall max, so a large JSON fixture is not
          read as the largest source file. Empty on a snapshot written before the
          split, which leaves the line in its older shape."""
          if "max_code" not in loc or "max_data" not in loc:
              return ""
          return f"; code {_fmt(loc['max_code'])}, data {_fmt(loc['max_data'])}"
      
      
      def render_report(ctx: dict, repo_name: str) -> str:
          """Render the full deterministic Markdown report from a run-context dict.
      
          Pure: takes the loaded context and the repo name, returns the report string.
          ``Template.substitute`` is strict - a missing key raises, which surfaces a
          template/data drift at the seam rather than silently emitting ``$placeholder``.
          """
          stats = ctx.get("stats_summary", {})
          loc = stats.get("loc", {})
          ccn = stats.get("ccn", {})
          # The keyhole summary line, with two disclosures appended when they apply:
          # findings suppressed by config excludes, and files excluded from scoring as
          # generated (each empty otherwise, so a clean run renders exactly as before).
          keyhole_summary = render_keyhole_summary(ctx)
          disclosure = render_exclusion_disclosure(ctx)
          if disclosure:
              keyhole_summary = f"{keyhole_summary}\n\n{disclosure}"
          generated = render_generated_disclosure(ctx)
          if generated:
              keyhole_summary = f"{keyhole_summary}\n\n{generated}"
          report = REPORT_TEMPLATE.substitute(
              repo_name=repo_name,
              run_date=ctx.get("run_date", "unknown"),
              plugin_version=ctx.get("plugin_version", "unknown"),
              commit_note=_render_commit_note(ctx),
              files_scored=stats.get("files_scored", 0),
              loc_total=_fmt(loc.get("total")),
              loc_p95=_fmt(loc.get("p95")),
              loc_max=_fmt(loc.get("max")),
              loc_split=_render_loc_split(loc),
              ccn_p95=_fmt(ccn.get("p95")),
              ccn_max=_fmt(ccn.get("max")),
              churn_window=_render_churn_window(ctx),
              hotspots_table=render_hotspots_table(ctx),
              keyhole_summary=keyhole_summary,
              findings_section=render_findings_section(ctx),
              structure_drift_section=_structure_drift_section(ctx, repo_name),
              diff_section=render_diff_section(ctx),
          )
          return report.rstrip() + "\n"
      
      
      def _structure_drift_section(ctx: dict, repo_name: str) -> str:
          """Template-ready structure-drift block: the rendered section followed by a
          blank line, or ``""`` when there's nothing to render (the surrounding blank
          line in the template then collapses on the final ``rstrip``)."""
          section = format_structure_drift_findings(ctx.get("structure_drift"), repo_name)
          return f"\n{section.rstrip()}\n" if section else ""
      
      
      def load_context(repo_root: Path) -> dict:
          """Load ``.assess/run-context.json`` from a repo root."""
          ctx_path = repo_root / ".assess" / "run-context.json"
          return json.loads(ctx_path.read_text(encoding="utf-8"))
      
      
      def main(argv: list[str] | None = None) -> int:
          args = list(sys.argv[1:] if argv is None else argv)
          to_stdout = "--stdout" in args
          positional = [a for a in args if not a.startswith("-")]
          if not positional:
              print("Usage: assess_report.py <repo_root> [--stdout]", file=sys.stderr)
              return 2
          repo_root = Path(positional[0]).resolve()
          try:
              ctx = load_context(repo_root)
          except (OSError, json.JSONDecodeError) as e:
              # Infrastructure failure (the core never wrote run-context.json, or it is
              # corrupt), not a finding. Emit a skip notice and succeed so a broken
              # snapshot never renders a red check on an unrelated PR - the assessment
              # runs again on the next push.
              print(
                  f"/assess report skipped: infrastructure failure "
                  f"({type(e).__name__}: {e}).\n"
                  "This is an infra issue, not a finding. The report will render on "
                  "the next push.",
                  file=sys.stderr,
              )
              return 0
          report = render_report(ctx, repo_root.name)
          if to_stdout:
              sys.stdout.write(report)
          else:
              out_path = repo_root / ".assess" / "deterministic-report.md"
              out_path.write_text(report, encoding="utf-8")
              print(str(out_path))
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • complexity-treemap.py 61.4 KB
      # /// script
      # requires-python = ">=3.12"
      # dependencies = [
      #     # Pinned to an EXACT version. lizard produces the per-function cyclomatic
      #     # complexity that drives the treemap hue and the regression baseline; a
      #     # floating version could shift complexity-stats.json between runs and move
      #     # the regression gate with no change in the assessed tree (the same risk
      #     # ci_workflow.py pins scc against). The captured version is stamped into
      #     # complexity-stats.json (`lizard_version`) so a diff can detect a change.
      #     "lizard==1.23.0",
      #     "squarify",
      #     "matplotlib",
      #     "numpy",
      # ]
      # ///
      """
      complexity-treemap.py - Codecov-style hotspot treemap for any folder.
      
      Each rectangle is one file, grouped by folder. The combined view encodes
      three signals on one canvas (Adam Tornhill "hotspot" pattern, CodeScene-
      style saturation):
      
        - Size        -> estimated tokens (~chars/4; the keyhole size unit. LOC is
                         kept in the hover tooltip). LOC undercounts dense/wide files
                         and overcounts sparse code; tokens track context burden.
        - Hue         -> cyclomatic complexity (dark red = complex, pale = simple;
                         colour-blind-safe OrRd ramp, no red-green)
        - Saturation  -> recent git churn (vivid = active, grey = stable)
      
      Vivid red = complex AND actively changing = highest migration risk.
      Faded grey = code that hasn't moved lately, regardless of complexity.
      
      Scoring tiers (size + complexity):
        1. lizard    -> real per-function cyclomatic complexity (Java, Python, JS,
                        TS, Go, C/C++, C#, Scala, Kotlin, Ruby, Swift, Rust, PHP).
        2. scc       -> keyword-heuristic complexity, covers 200+ languages.
                        Skipped for files lizard already scored. Optional.
           Dart files scc scored also get an approximate per-function breakdown
           from lib/dart_complexity.py (the `dart-scanner` backend), since lizard
           has no Dart reader.
      
      Churn window auto-widens (12mo -> 24mo -> 5y -> all-time) until at least
      10% of files have any activity. If the path isn't inside a git repo, the
      saturation signal is dropped and files render fully vivid.
      
      The hue and saturation gradients are independently capped at the 95th
      percentile when the distribution has wild outliers (max > 5x p95);
      otherwise the full max-of-data range is used so small/well-behaved data
      sees the full gradient.
      
      Output is a single self-contained SVG with hover tooltips on every block
      (file path, estimated tokens, LOC, complexity, recent commit count). Pass
      --labels to also annotate large blocks with text.
      
      Build artifacts (main.dart.js, *.min.js, *.bundle.js, *.map, files under
      node_modules/dist/build/.next/.nuxt/etc.) and generated code (*.pb.go,
      *.connect.go, *_pb.ts, wire_gen.go, zz_generated_*.go, *.freezed.dart,
      *.designer.cs, etc.), and generated test reports (Playwright html-report/,
      Lighthouse, ZAP, *.jsonl under a nested fixtures/) are filtered by default
      so compiled bundles and protoc-emitted bindings don't dominate the "most
      complex" lists with code nobody wrote by hand. If a single remaining file
      still holds >30% of total LOC, a warning prints to stderr suggesting it
      might be a build artifact that needs .gitignore; if the 5 largest files are
      all scc-scored data files with complexity 0, a hint names
      .assess/config.toml as the place to exclude them. Pass --include-artifacts
      to disable the filter entirely.
      
      Usage:
          uv run skills/assess/scripts/complexity-treemap.py <path> [-o out.svg] [--labels] [--include-artifacts]
      """
      from __future__ import annotations
      
      import argparse
      import fnmatch
      import json
      import math
      import shutil
      import subprocess
      import sys
      import uuid
      from datetime import datetime
      from pathlib import Path
      
      import lizard
      import matplotlib.pyplot as plt
      import numpy as np
      
      # Shared layout/SVG primitives and churn machinery live in lib/ so the code
      # heatmap, the docs heatmap, and the Layer 0 staleness metric reuse one
      # implementation. Only the colour mapping differs per heatmap and stays local.
      sys.path.insert(0, str(Path(__file__).resolve().parent))
      from lib.assess_config import resolve_excludes  # noqa: E402
      from lib.dart_complexity import (  # noqa: E402
          BACKEND_NAME as DART_BACKEND,
          dart_function_scores,
      )
      from lib.git_churn import (  # noqa: E402
          churn_is_degenerate,
          git_churn_scores,
          pick_churn_window,
      )
      from lib.generated_files import (  # noqa: E402
          GENERATED_NAME_PATTERNS,
          generated_reason,
      )
      from lib.treemap_render import (  # noqa: E402
          adaptive_cap,
          blend_to_grey,
          build_tree,
          layout,
          write_svg,
      )
      
      
      EXCLUDE_DIRS = {".git", "node_modules", "dist", "build", "target", "vendor",
                      ".venv", "venv", "__pycache__", ".gradle", ".idea", ".mvn",
                      "worktree", ".understand-anything", ".obsidian",
                      ".taskmaster", ".claude",
                      # /assess's own output directory. Without this the prior
                      # run's run-context.json (often 2,000+ LOC) gets picked up
                      # as a top-large file on every re-run - circular pollution.
                      ".assess",
                      # modern web framework build outputs
                      ".next", ".nuxt", ".output", ".svelte-kit", ".astro",
                      "out", "coverage", "htmlcov",
                      # iOS/Xcode
                      "Pods", "DerivedData",
                      # Flutter web build output (lives under web/ or public/)
                      "flutter_assets",
                      # Playwright HTML test reports: a few lines holding a base64
                      # bundle, committed under web/public/tests/... they took the
                      # largest treemap blocks on a real repo (issue #336)
                      "html-report", "playwright-report"}
      
      # Filenames that match these glob patterns are treated as build artifacts
      # or generated code and excluded from scoring. Two motivations:
      #   - Compiled bundles (main.dart.js etc.) eat 78% of LOC and skew
      #     every percentile (see PR #12).
      #   - Generated bindings (.pb.go, *_pb.ts etc.) dominate "most complex"
      #     lists with machine-emitted switch statements and getters that
      #     nobody wrote by hand (see meridian PR #2212 - 8/10 most-complex
      #     files were protobuf bindings).
      # Files that match no glob but declare themselves generated (a header marker)
      # or carry payload-length lines are caught by content in `collect`, via
      # lib.generated_files, and listed in the stats file's `excluded_generated`.
      # Pass --include-artifacts to disable.
      EXCLUDE_FILE_PATTERNS = [
          # --- build artifacts ---
          # Minified / bundled JS-CSS
          "*.min.js", "*.min.mjs", "*.min.css",
          "*.bundle.js", "*.bundle.mjs", "*.bundle.css",
          "*.chunk.js", "*.chunk.mjs",
          "*-bundle.js", "*-min.js",
          # Sourcemaps and build metadata
          "*.map", "*.tsbuildinfo",
          # Flutter web outputs. `--web-renderer canvaskit` always emits the
          # canvaskit/skwasm runtime bundles (framework code, churn=1, not source);
          # basename globs catch them wherever the build nests them
          # (e.g. canvaskit/chromium/canvaskit.js).
          "main.dart.js", "flutter_service_worker.js", "flutter.js",
          "canvaskit.js", "skwasm*.js",
          # PWA / service worker stubs
          "service-worker.js", "sw.js", "workbox-*.js",
      
          # --- generated code ---
          # Go protobuf / gRPC / grpc-gateway / Connect
          "*.pb.go", "*_grpc.pb.go", "*.pb.gw.go", "*.connect.go",
          # JS/TS protobuf / Connect (buf, ts-proto, protoc-gen-grpc-web)
          "*_pb.ts", "*_pb.d.ts", "*_pb.js",
          "*_connect.ts", "*_connect.d.ts", "*_connect.js",
          # Python protobuf
          "*_pb2.py", "*_pb2_grpc.py",
          # C++ protobuf
          "*.pb.cc", "*.pb.h",
          # Go generators (wire, controller-gen, mockgen, bindata)
          "*.gen.go", "*.generated.go",
          # Any-language generator naming, and Supabase/GraphQL codegen outputs
          # (`supabase gen types` writes database.types.ts).
          *GENERATED_NAME_PATTERNS,
          "wire_gen.go",
          "zz_generated_*.go",
          "bindata.go", "bindata_assetfs.go",
          # .NET designer / source generators
          "*.designer.cs", "*.g.cs", "*.g.i.cs",
          # Dart/Flutter codegen (freezed, json_serializable, riverpod, get_it)
          "*.freezed.dart", "*.g.dart", "*.gr.dart", "*.config.dart",
      
          # --- generated test-tool reports ---
          # Lighthouse and OWASP ZAP output committed beside the tests (issue #336).
          # `zap-report.*` is ZAP's default report name in every output format; the
          # underscore spelling is pinned to report formats so a hand-written
          # `zap_report.py` that runs the scan stays scored.
          "lighthouse-report.html", "lighthouse-results.json",
          "zap-report.*",
          "zap_report.html", "zap_report.json", "zap_report.xml", "zap_report.md",
      ]
      
      # Path-aware defaults: (directory name, basename glob). A file matches when the
      # glob fits its basename and the directory name is one of its parent
      # directories below the repo root. A same-named directory at the top level
      # does not count: a bare top-level `fixtures/` often holds hand-kept reference
      # data, while a nested `fixtures/` (`test/fixtures/`, `mcp/test/fixtures/`)
      # holds recorded tool output. Path-aware like `EXCLUDE_PATH_SEQUENCES` in
      # lib/doc_graph.py, but broader: any `fixtures` component below the top level
      # matches, whatever its parent, since recorded JSONL sits under `mcp/test/`,
      # `src/` or `e2e/` as often as under `tests/`.
      EXCLUDE_NESTED_PATH_PATTERNS: tuple[tuple[str, str], ...] = (
          # Recorded JSONL fixtures (API captures, event logs), issue #336.
          ("fixtures", "*.jsonl"),
      )
      
      
      def _is_build_artifact(rel: Path) -> bool:
          """True if repo-relative ``rel`` matches an EXCLUDE_FILE_PATTERNS glob
          (basename match) or an EXCLUDE_NESTED_PATH_PATTERNS rule."""
          name = rel.name
          if any(fnmatch.fnmatch(name, pat) for pat in EXCLUDE_FILE_PATTERNS):
              return True
          nested_dirs = rel.parts[1:-1]
          return any(
              d in nested_dirs and fnmatch.fnmatch(name, pat)
              for d, pat in EXCLUDE_NESTED_PATH_PATTERNS
          )
      
      
      def _is_user_excluded(rel: Path, extra_dirs: set[str],
                            extra_patterns: list[str]) -> bool:
          """True if `rel` matches a user-supplied exclude (CLI `--exclude` or
          `.assess/config.toml`).
      
          `extra_dirs` is matched exactly against any path component (mirrors how
          `EXCLUDE_DIRS` works). `extra_patterns` is matched as a basename glob
          (mirrors `EXCLUDE_FILE_PATTERNS`).
          """
          if extra_dirs and any(part in extra_dirs for part in rel.parts):
              return True
          if extra_patterns and any(
              fnmatch.fnmatch(rel.name, pat) for pat in extra_patterns
          ):
              return True
          return False
      
      
      def lizard_scores(
          root: Path, include_artifacts: bool = False,
          extra_exclude_dirs: set[str] | None = None,
          extra_exclude_patterns: list[str] | None = None,
          fn_names: dict[Path, str] | None = None,
      ) -> dict[Path, tuple[int, float, list[float]]]:
          """Return ``{abs_path: (loc, ccn_sum, fn_ccns)}`` for scoreable files.
      
          ``fn_names``, when a dict, receives the name of each file's worst function
          (the first with the highest ccn) for every file with at least one function.
      
          Two distinct complexity signals come out of lizard, and conflating them is
          exactly the failure mode issue #58 reported:
      
          - ``ccn_sum`` - the **file-level aggregate** (sum of every function's
            cyclomatic complexity). This is the treemap's hue and the composite
            hotspot score's complexity axis. It is NOT comparable to a per-function
            linter threshold like ``cyclop: 15``; a file of twelve simple functions
            can sum past 100 without any single function violating anything.
          - ``fn_ccns`` - the **per-function** values. ``max(fn_ccns)`` is the worst
            single function and is what a per-function linter threshold actually
            gates. ``write_stats`` carries both so the report can say "ccn 136
            aggregate; worst function 13, under the 15 threshold" instead of
            mislabelling the aggregate as a per-function violation.
          """
          extra_dirs = extra_exclude_dirs or set()
          extra_pats = extra_exclude_patterns or []
          scores: dict[Path, tuple[int, float, list[float]]] = {}
          for f in lizard.analyze(paths=[str(root)], exclude_pattern=[]):
              path = Path(f.filename).resolve()
              try:
                  rel = path.relative_to(root)
              except ValueError:
                  continue
              if any(part in EXCLUDE_DIRS for part in rel.parts):
                  continue
              if not include_artifacts and _is_build_artifact(rel):
                  continue
              if _is_user_excluded(rel, extra_dirs, extra_pats):
                  continue
              fn_ccns = [float(fn.cyclomatic_complexity) for fn in f.function_list]
              ccn_sum = sum(fn_ccns) or 1.0
              scores[path] = (f.nloc, float(ccn_sum), fn_ccns)
              if fn_names is not None and fn_ccns:
                  fn_names[path] = f.function_list[fn_ccns.index(max(fn_ccns))].name
          return scores
      
      
      def scc_scores(
          root: Path, include_artifacts: bool = False,
          extra_exclude_dirs: set[str] | None = None,
          extra_exclude_patterns: list[str] | None = None,
          languages: dict[Path, str] | None = None,
      ) -> dict[Path, tuple[int, float]]:
          """Return ``{abs_path: (loc, complexity)}`` for the files scc scores.
      
          ``languages``, when a dict, receives scc's per-language ``Name`` (``JSON``,
          ``YAML``, ``Python``) for every returned path.
          """
          if shutil.which("scc") is None:
              return {}
          extra_dirs = extra_exclude_dirs or set()
          extra_pats = extra_exclude_patterns or []
          # scc's --exclude-dir wants a comma-separated list. Merge defaults with
          # user-supplied dirs so scc skips them at scan time (cheaper than
          # filtering in Python after the fact).
          excludes = ",".join(sorted(EXCLUDE_DIRS | extra_dirs))
          try:
              raw = subprocess.run(
                  ["scc", "--by-file", "--format", "json",
                   f"--exclude-dir={excludes}", str(root)],
                  capture_output=True, text=True, check=True,
              ).stdout
          except (subprocess.CalledProcessError, FileNotFoundError) as e:
              print(f"warning: scc failed ({e}); continuing without scc scores",
                    file=sys.stderr)
              return {}
          try:
              payload = json.loads(raw)
          except json.JSONDecodeError:
              print("warning: scc returned invalid JSON; continuing without scc scores",
                    file=sys.stderr)
              return {}
          scores: dict[Path, tuple[int, float]] = {}
          for lang_block in payload:
              for f in lang_block.get("Files", []):
                  path = Path(f["Location"]).resolve()
                  try:
                      rel = path.relative_to(root)
                  except ValueError:
                      continue
                  if not include_artifacts and _is_build_artifact(rel):
                      continue
                  # scc honoured extra_dirs at the --exclude-dir level, but
                  # exclude_patterns are basename-only so we filter them here.
                  if _is_user_excluded(rel, set(), extra_pats):
                      continue
                  scores[path] = (int(f["Code"]), float(f["Complexity"]))
                  if languages is not None:
                      languages[path] = str(lang_block.get("Name", ""))
          return scores
      
      
      def _git_not_found_warning(root: Path) -> None:
          print(
              f"warning: no git history found for {root}\n"
              "         (path may not be a git repo, or the repo is "
              "empty / has a broken HEAD).\n"
              "         Rendering pure complexity (no saturation signal). "
              "If your code lives in a subdirectory\n"
              "         that IS a git repo (e.g. a -main subfolder), point "
              "the path at that.",
              file=sys.stderr,
          )
      
      
      def _add_dart_scores(files: list[tuple[Path, int, float, str]],
                           fn_ccn_by_path: dict[Path, list[float]],
                           fn_names: dict[Path, str] | None,
                           fn_backends: dict[Path, str] | None) -> None:
          """Give scc-scored ``.dart`` files the approximate per-function breakdown.
      
          lizard has no Dart reader, so scc scores Dart at file level only; the
          ``dart-scanner`` backend (lib/dart_complexity.py) fills ``fn_ccn_by_path``,
          the worst function's name and the backend name for each such file.
          """
          for path, _loc, _metric, src in files:
              if src != "scc" or path.suffix != ".dart":
                  continue
              fn_ccns, worst = dart_function_scores(path)
              if not fn_ccns:
                  # No function found: the file stays scc-only, so a Dart file with
                  # decision points and no breakdown keeps backend_by_language null.
                  continue
              fn_ccn_by_path[path] = fn_ccns
              if fn_names is not None and worst is not None:
                  fn_names[path] = worst
              if fn_backends is not None:
                  fn_backends[path] = DART_BACKEND
      
      
      def collect(root: Path, by: str = "complexity",
                  include_artifacts: bool = False,
                  extra_exclude_dirs: set[str] | None = None,
                  extra_exclude_patterns: list[str] | None = None,
                  scope: Path | None = None,
                  excluded_generated: list[dict] | None = None,
                  scc_languages: dict[Path, str] | None = None,
                  fn_names: dict[Path, str] | None = None,
                  fn_backends: dict[Path, str] | None = None,
                  ) -> tuple[list[tuple[Path, int, float, str]], str,
                             dict[Path, int] | None, str | None,
                             dict[Path, list[float]]]:
          """Returns (files, effective_by, aux_data, aux_label, fn_ccn_by_path).
      
          - files: [(path, loc, metric, source)] - metric depends on mode
          - effective_by: may differ from `by` if we fell back (e.g. no git)
          - aux_data: secondary per-file signal (hotspot mode only); None otherwise
          - aux_label: human label for aux signal in tooltips
          - fn_ccn_by_path: per-function cyclomatic-complexity lists for the files a
            per-function backend scored: lizard, and the approximate Dart scanner for
            scc-scored ``.dart`` files (scc reports file-level complexity with no
            function breakdown, so its other paths are absent here). Threaded to `write_stats` so the report can
            separate per-function violations from file-level aggregates (issue #58).
      
          `scope` (an absolute path under `root`) restricts scoring to a subtree for
          `/assess <path>` monorepo scoping. The scan still roots at `root` so the
          config/exclude resolution and churn windowing are unchanged; the scored file
          list, dominance check, and churn axis then see only the subtree - so a scoped
          treemap carries no complexity or churn signal from a sibling directory. Omit
          it (the default) for a whole-repo run.
      
          `excluded_generated`, when a list, receives one ``{"path", "reason"}`` entry
          (repo-relative path) per file the content checks in lib.generated_files
          dropped: a generator header in the first lines (``generated-header``) or a
          payload-length average line (``long-lines``). ``include_artifacts`` skips
          those checks as it skips the filename globs.
      
          `scc_languages`, when a dict, receives scc's language name per scc-scored
          path (see `scc_scores`); `write_stats` uses it to split code from data.
      
          `fn_names`, when a dict, receives the worst function's name per path a
          per-function backend scored (see `lizard_scores`); `write_stats` writes it
          as each row's `max_fn_name`.
      
          `fn_backends`, when a dict, receives the backend name of every path scored
          by a backend other than lizard (`write_stats` defaults the rest to lizard).
          """
          lz = lizard_scores(
              root, include_artifacts=include_artifacts,
              extra_exclude_dirs=extra_exclude_dirs,
              extra_exclude_patterns=extra_exclude_patterns,
              fn_names=fn_names,
          )
          sc = scc_scores(
              root, include_artifacts=include_artifacts,
              extra_exclude_dirs=extra_exclude_dirs,
              extra_exclude_patterns=extra_exclude_patterns,
              languages=scc_languages,
          )
          files: list[tuple[Path, int, float, str]] = []
          fn_ccn_by_path: dict[Path, list[float]] = {}
          for path, (loc, ccn, fn_ccns) in lz.items():
              files.append((path, loc, ccn, "lizard"))
              fn_ccn_by_path[path] = fn_ccns
          for path, (loc, cx) in sc.items():
              if path not in lz:
                  files.append((path, loc, cx, "scc"))
          files = [f for f in files if f[1] > 0]
          if scope is not None:
              scope_abs = scope.resolve()
              files = [f for f in files if f[0].resolve().is_relative_to(scope_abs)]
              fn_ccn_by_path = {
                  p: v for p, v in fn_ccn_by_path.items()
                  if p.resolve().is_relative_to(scope_abs)
              }
          if not include_artifacts:
              kept: list[tuple[Path, int, float, str]] = []
              for f in files:
                  reason = generated_reason(f[0])
                  if reason is None:
                      kept.append(f)
                      continue
                  fn_ccn_by_path.pop(f[0], None)
                  if excluded_generated is not None:
                      try:
                          rel = f[0].relative_to(root).as_posix()
                      except ValueError:
                          rel = f[0].as_posix()
                      excluded_generated.append({"path": rel, "reason": reason})
              files = kept
              if excluded_generated is not None:
                  excluded_generated.sort(key=lambda e: e["path"])
      
          # After the scope and generated-file filters, so it reads only kept files.
          _add_dart_scores(files, fn_ccn_by_path, fn_names, fn_backends)
      
          effective_by = by
          aux_data: dict[Path, int] | None = None
          aux_label: str | None = None
      
          if by == "churn":
              churn = git_churn_scores(root, scope=scope)
              if not churn:
                  _git_not_found_warning(root)
                  effective_by = "complexity"
              else:
                  files = [(p, loc, float(churn.get(p, 0)), src)
                           for p, loc, _m, src in files]
          elif by == "hotspot":
              aux_data, aux_label = pick_churn_window(root, [f[0] for f in files])
              if aux_data is None:
                  _git_not_found_warning(root)
                  effective_by = "complexity"
      
          return files, effective_by, aux_data, aux_label, fn_ccn_by_path
      
      
      DOMINANCE_WARN_THRESHOLD = 0.30  # one file >30% of total LOC = suspicious
      
      
      def _warn_if_dominated_by_one_file(
          files: list[tuple[Path, int, float, str]],
      ) -> None:
          """If a single file is >30% of total LOC, flag it as likely-build-artifact.
      
          Compiled bundles (main.dart.js, *.min.js) that slip past the filter
          skew every percentile and dominate the treemap. The size signal alone
          is enough to flag suspects - human-written codebases rarely have one
          file holding a third of the LOC.
          """
          if len(files) < 2:
              return
          total_loc = sum(f[1] for f in files)
          if total_loc == 0:
              return
          biggest = max(files, key=lambda f: f[1])
          share = biggest[1] / total_loc
          if share < DOMINANCE_WARN_THRESHOLD:
              return
          print(
              f"warning: one file holds {share:.0%} of scoreable LOC "
              f"({biggest[1]:,} of {total_loc:,}).\n"
              f"         {biggest[0].name} - looks like a build artifact or "
              f"generated file.\n"
              f"         If so, add it to .gitignore and re-run. To score it "
              f"anyway, pass --include-artifacts.",
              file=sys.stderr,
          )
      
      
      SCC_ONLY_HINT_TOP_N = 5  # the largest blocks a reader sees first
      
      
      def _hint_if_largest_files_scc_only(
          files: list[tuple[Path, int, float, str]],
          tokens: dict[Path, int],
          languages: dict[Path, str],
          n: int = SCC_ONLY_HINT_TOP_N,
      ) -> None:
          """Hint at config excludes when the ``n`` largest files by estimated tokens
          are all scc-scored data files (``DATA_LANGUAGES``) with complexity 0.
      
          No single file need pass the dominance threshold for the treemap's biggest
          blocks to be data an agent never edits (issue #336). Scoped to data
          languages because scc also reports complexity 0 for Markdown, HTML and CSS:
          on a docs-first repository those blocks are the deliverable, and advising
          to exclude them would be wrong. Silent below ``n`` files and when any of
          the ``n`` is lizard-scored, carries complexity, or is not a data language.
          """
          if len(files) < n:
              return
          largest = sorted(files, key=lambda f: -tokens.get(f[0], f[1]))[:n]
          if any(f[3] != "scc" or f[2] > 0
                 or languages.get(f[0]) not in DATA_LANGUAGES for f in largest):
              return
          names = ", ".join(f[0].name for f in largest)
          print(
              f"hint: the {n} largest files by estimated tokens are all data files "
              f"(JSON / YAML / JSONL)\n"
              f"      scored by scc with complexity 0: {names}.\n"
              f"      If agents never edit them, add their directories or globs to "
              f".assess/config.toml\n"
              f"      (`exclude_dirs` / `exclude_patterns`) and re-run.",
              file=sys.stderr,
          )
      
      
      # Survivor-density overlay thresholds. A file whose mutation survivor density
      # (survivors / mutants, from the test_pressure block) clears these stops
      # rendering as safe green: >30% gets a diagonal hatch, >50% a cross-hatch.
      SURVIVOR_DIAG_THRESHOLD = 0.30
      SURVIVOR_CROSS_THRESHOLD = 0.50
      
      
      def _hatch_for_density(density: float | None) -> str:
          """Map a survivor density to a hatch level. "" when below threshold or
          unknown - so absent/empty data silently renders no overlay."""
          if density is None:
              return ""
          if density > SURVIVOR_CROSS_THRESHOLD:
              return "cross"
          if density > SURVIVOR_DIAG_THRESHOLD:
              return "diag"
          return ""
      
      
      def _survivor_overrides(
          files: list[tuple[Path, int, float, str]],
          survivor_density: dict[Path, float] | None,
      ) -> dict[Path, dict]:
          """Build the per-file Node overrides (``{path: {"hatch": ...}}``) for the
          survivor-density overlay. Keys in ``survivor_density`` are matched against
          each file's resolved path (``files[i][0]``). Returns an empty dict when no
          data is supplied or nothing clears the threshold - the caller treats that
          as "no overlay"."""
          if not survivor_density:
              return {}
          overrides: dict[Path, dict] = {}
          for f in files:
              hatch = _hatch_for_density(survivor_density.get(f[0]))
              if hatch:
                  overrides[f[0]] = {"hatch": hatch}
          return overrides
      
      
      def render(files: list[tuple[Path, int, float, str]],
                 root: Path, out_path: Path, title: str,
                 show_labels: bool = False,
                 by: str = "complexity",
                 aux_data: dict[Path, int] | None = None,
                 aux_label: str | None = None,
                 survivor_density: dict[Path, float] | None = None,
                 tokens_by_path: dict[Path, int] | None = None,
                 churn_degenerate: bool = False) -> None:
          metric_label = "commits" if by == "churn" else "ccn"
          metrics = [f[2] for f in files]
          cap, cap_kind = adaptive_cap(metrics)
          # OrRd (ColorBrewer): colour-blind-safe sequential ramp, pale = simple ->
          # dark red = complex. Avoids the red-green of RdYlGn (the most common CVD).
          cmap = plt.get_cmap("OrRd")
      
          # Degenerate churn = a flat saturation axis (every file ~1 commit), which
          # would render as uniform full-saturation and read as a live signal. Drop it:
          # the blocks render fully vivid (pure complexity), matching the no-git path,
          # and the one-line summary below states the axis is inactive rather than
          # legending a meaningless gradient.
          if churn_degenerate:
              aux_data = None
      
          aux_cap = 1.0
          aux_cap_kind = ""
          if aux_data is not None:
              aux_values = [float(aux_data.get(f[0], 0)) for f in files]
              aux_cap, aux_cap_kind = adaptive_cap(aux_values)
      
          files_colored = []
          for f in files:
              # Floor the ramp at 0.12 so the calm (low-complexity) end is a visible
              # pale orange, not near-white that washes out against the white canvas.
              base = cmap(0.12 + 0.88 * min(f[2] / cap, 1.0))
              if aux_data is not None:
                  aux_val = float(aux_data.get(f[0], 0))
                  color = blend_to_grey(base, aux_val / aux_cap)
              else:
                  color = base
              files_colored.append((f[0], f[1], f[2], f[3], color))
      
          # Block area is estimated tokens (the keyhole size unit), not LOC: size_by
          # overrides the layout area per file while each leaf keeps its real LOC for
          # the tooltip. Falls back to LOC area when no token map is supplied.
          tokens = (tokens_by_path if tokens_by_path is not None
                    else est_tokens_by_path(files))
          overrides = _survivor_overrides(files, survivor_density)
          tree = build_tree(files_colored, root, aux_data, aux_label or "",
                            node_overrides=overrides or None, size_by=tokens)
          W, H = 1600.0, 1000.0
          rects: list = []
          layout(tree, 0, 0, W, H, rects)
      
          write_svg(rects, root, W, H, out_path, show_labels, metric_label,
                    show_survivor_legend=bool(overrides))
      
          print(f"wrote {out_path}  ({len(files)} files, "
                f"{sum(1 for f in files if f[3] == 'lizard')} lizard, "
                f"{sum(1 for f in files if f[3] == 'scc')} scc)")
          mx = float(max(metrics)) if metrics else 0.0
          print(f"hue: {metric_label}; range 0-{mx:.0f}; "
                f"cap {cap:.0f} ({cap_kind})")
          if churn_degenerate:
              print("saturation: churn signal flat (degenerate history - every file "
                    "~1 commit); axis inactive, rendering pure complexity.")
          elif aux_data is not None:
              aux_max = max((float(aux_data.get(f[0], 0)) for f in files),
                             default=0.0)
              print(f"saturation: {aux_label}; range 0-{aux_max:.0f}; "
                    f"cap {aux_cap:.0f} ({aux_cap_kind})")
      
          biggest = sorted(files, key=lambda f: -tokens.get(f[0], 0))[:5]
          if biggest:
              print("biggest files (estimated tokens dominate layout):")
              for path, loc, metric, src in biggest:
                  try:
                      rel = path.relative_to(root)
                  except ValueError:
                      rel = path
                  aux_str = ""
                  if aux_data is not None:
                      aux_str = f"  {aux_label} {aux_data.get(path, 0):>4d}"
                  print(f"  {tokens.get(path, 0):>9,} est.tok  {loc:>7} loc  "
                        f"{metric_label} {metric:>5.0f}{aux_str}  [{src:6}]  {rel}")
      
      
      # Artifact schema version for complexity-stats.json - the run_id provenance
      # schema (distinct from STATS_SCHEMA_VERSION below, which versions the stats
      # *layout* for diff comparability). Mirrors assess_core's
      # ARTIFACT_SCHEMA_VERSION - the treemap runs as a separate process, so the
      # constant is duplicated rather than imported (no dependency on assess_core).
      ARTIFACT_SCHEMA_VERSION = "1.1.0"
      
      
      def _new_run_id() -> str:
          """A unique id for this stats emission: sortable wall-clock stamp + random
          suffix (``YYYYMMDDHHMMSS-<8 hex>``), so each complexity-stats.json is
          traceable to the run that wrote it."""
          return f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}"
      
      
      def _read_plugin_version() -> str:
          """Read the plugin version from .claude-plugin/plugin.json.
      
          plugin.json lives three directories up from this script:
              scripts/complexity-treemap.py -> scripts/ -> skills/assess/ -> skills/ -> repo root
          Stamped into the stats sidecar so a later run can detect when its prior
          snapshot came from a differently-filtered plugin and suppress a misleading
          diff (filter-mismatched "graduated" ghosts). Returns "unknown" if absent.
          """
          plugin_json = Path(__file__).resolve().parents[3] / ".claude-plugin" / "plugin.json"
          try:
              data = json.loads(plugin_json.read_text(encoding="utf-8"))
              return str(data.get("version", "unknown"))
          except (FileNotFoundError, json.JSONDecodeError):
              return "unknown"
      
      
      # Version of the complexity-stats.json layout the diff compares. A cross-run
      # diff is only trustworthy when both snapshots share this schema; a bump here
      # is a structural change to the sidecar shape (a metric added/removed/redefined)
      # that voids the diff against an older snapshot until the next clean run
      # re-seeds the baseline (assess_core._diff_is_reliable reads it).
      STATS_SCHEMA_VERSION = 5  # 2: generated-file content excludes + excluded_generated
                                # 3: generated test-report excludes + loc/est_tokens max_code/max_data
                                # 4: fn_ccn.source list + backend_by_language, rows max_fn_name
                                # 5: dart-scanner fills max_fn_ccn for Dart rows, moving their score
      
      # scc language names counted as data, not code, for the `max_code` / `max_data`
      # split in the stats file. Data files stay in the treemap: a large hand-kept
      # fixture is weight an agent may have to read.
      DATA_LANGUAGES = frozenset({"JSON", "YAML", "JSONL"})
      
      # Per-function complexity backends, by name, with whether their counts are
      # approximate. `fn_ccn.source` in the stats file lists the ones that scored a
      # file in the run; a path's backend defaults to lizard (see `write_stats`).
      FN_BACKENDS = {"lizard": False, DART_BACKEND: True}
      
      
      def _lizard_version() -> str:
          """Capture the installed lizard version at runtime.
      
          lizard exposes no ``__version__`` attribute, so the version comes from the
          installed-package metadata. Stamped into complexity-stats.json so a later
          run can tell that the complexity backend moved (a lizard release can shift
          cyclomatic scores) and flag the diff against the prior snapshot as not
          comparable. Returns "unknown" if the metadata can't be read.
          """
          try:
              from importlib.metadata import version
              return version("lizard")
          except Exception:
              # Defensive: a metadata quirk must never fail the scan, only the stamp.
              return "unknown"
      
      
      def _scc_version() -> str | None:
          """Capture the installed scc version, or ``None`` when scc is not on PATH.
      
          scc prints ``scc version 3.7.0``; the trailing token is the version. Only
          stamped into the sidecar when scc actually scored files, so its absence in
          the stats is honest (scc contributed nothing) rather than a false "unknown".
          """
          if shutil.which("scc") is None:
              return None
          try:
              out = subprocess.run(
                  ["scc", "--version"], capture_output=True, text=True, check=True,
              ).stdout.strip()
          except (subprocess.SubprocessError, OSError):
              return None
          if not out:
              return None
          return out.split()[-1]
      
      
      def _tool_versions(files: list[tuple[Path, int, float, str]]) -> dict[str, str]:
          """Map the complexity backends that scored this run to their versions.
      
          Always carries ``lizard``; adds ``scc`` only when at least one file was
          scored by scc (the sidecar's own ``scoring_coverage`` is the source of that
          truth), so the version set matches the tools that actually shaped the data.
          """
          versions: dict[str, str] = {"lizard": _lizard_version()}
          if any(f[3] == "scc" for f in files):
              scc_v = _scc_version()
              if scc_v is not None:
                  versions["scc"] = scc_v
          return versions
      
      
      # Weight of the per-function worst case in the hotspot composite. The effective
      # complexity is a weighted geometric mean of the file aggregate and the file's
      # worst single function, leaning toward the per-function offender (issue #115).
      PER_FUNCTION_WEIGHT = 0.7
      
      # Token estimation. /assess measures the keyhole in tokens, not lines: LOC
      # undercounts dense/wide files (long identifiers, comments, data tables, prose)
      # and overcounts sparse code. A treemap and a ranking need only *relative* size,
      # so the standard ~4-chars-per-token heuristic is visually and ordinally
      # equivalent to a real tokenizer while staying deterministic, dependency-free,
      # and model-agnostic (a tokenizer is model-specific - OpenAI != Claude - and
      # adds a reproducibility caveat). Always labelled "estimated tokens": an honest
      # stable estimate, never an exact count.
      CHARS_PER_TOKEN = 4
      
      # A subtree (or single file) above this many estimated tokens no longer fits one
      # context-window keyhole - the literal "does the relevant slice fit?" measure.
      # An estimate, labelled as such; a documented default, not a model-exact limit.
      CONTEXT_WINDOW_BUDGET_TOKENS = 200_000
      
      
      def est_token_count(path: Path, loc: int) -> int:
          """Estimated token count for one file: ``ceil(len(text) / 4)``.
      
          Reads the file text and applies the ~4-chars-per-token heuristic. On an
          unreadable file (binary, vanished, decode error) it falls back to ``loc`` -
          a conservative floor that can't inflate a benign file's rank. Always >= 1 so
          a file never has zero area.
          """
          try:
              text = path.read_text(encoding="utf-8", errors="ignore")
          except (OSError, ValueError):
              return max(1, loc)
          return max(1, math.ceil(len(text) / CHARS_PER_TOKEN))
      
      
      def est_tokens_by_path(
          files: list[tuple[Path, int, float, str]],
      ) -> dict[Path, int]:
          """``{path: est_tokens}`` for the already-filtered scoreable files.
      
          Called after the artifact filter (``collect``), so a minified/generated
          bundle that never reaches the treemap never contributes to the token totals
          or the keyhole budget either.
          """
          return {f[0]: est_token_count(f[0], f[1]) for f in files}
      
      
      def _keyhole_budget_rollup(
          tokens: dict[Path, int], root: Path,
          budget: int = CONTEXT_WINDOW_BUDGET_TOKENS,
      ) -> dict:
          """Roll per-file estimated tokens into the keyhole-budget finding.
      
          Reports the repo total and how many individual files / top-level subtrees
          exceed one context-window budget - the most on-thesis signal /assess emits:
          "does the relevant slice fit one keyhole?". The budget is an estimate
          (char-based tokens against a documented default), labelled as such.
          """
          total = sum(tokens.values())
          files_over = sum(1 for t in tokens.values() if t > budget)
          subtree_totals: dict[str, int] = {}
          for path, t in tokens.items():
              try:
                  rel = path.relative_to(root)
              except ValueError:
                  continue
              # First path component: a top-level file maps to itself, anything under
              # a directory maps to that directory.
              top = rel.parts[0] if rel.parts else rel.name
              subtree_totals[top] = subtree_totals.get(top, 0) + t
          over_subtrees = sorted(
              ({"path": name, "est_tokens": tot}
               for name, tot in subtree_totals.items() if tot > budget),
              key=lambda s: -s["est_tokens"],
          )
          return {
              "total": total,
              "budget": budget,
              "budget_basis": "estimate (~4 chars/token, documented default)",
              "chars_per_token": CHARS_PER_TOKEN,
              "files_over_budget": files_over,
              "subtrees_over_budget": len(over_subtrees),
              "over_budget_subtrees": over_subtrees[:10],
          }
      
      
      def _effective_ccn(ccn: float, max_fn_ccn: float | None) -> float:
          """Complexity used to rank hotspots, re-weighted toward the worst function.
      
          The treemap hue and the ``ccn`` field stay file-aggregate, but the hotspot
          composite ranks on this value instead. For class-per-file languages
          (Java/Kotlin/C#) a broad coordinator class spreads complexity across many
          small methods, so its aggregate over-ranks it above a genuinely complex
          single method living in a leaner file (issue #115: an aggregate-107 / worst-
          function-14 coordinator out-ranked a ccn-28 DAO method). Blending toward
          ``max_fn_ccn`` corrects that.
      
          For a lizard file ``max_fn_ccn <= ccn`` by construction (it is the largest
          term of the sum). A Dart file takes ``ccn`` from scc and ``max_fn_ccn`` from
          the Dart scanner, whose counting rules differ (scc skips ``case``, ``catch``
          and the per-function +1), so ``max_fn_ccn`` is clamped to ``ccn`` and the
          effective value never exceeds the aggregate. For single-function-dominant
          files (Python/Go, where ``max_fn_ccn`` is at or near the aggregate) the blend
          collapses back to the aggregate, so their ranking is unchanged. Files with no
          function breakdown (``max_fn_ccn is None``) keep the raw aggregate.
          """
          if max_fn_ccn:
              max_fn_ccn = min(max_fn_ccn, ccn)
          if not max_fn_ccn:  # None (no breakdown) or 0 -> no usable signal
              return ccn
          w = PER_FUNCTION_WEIGHT
          return float(max_fn_ccn ** w * ccn ** (1.0 - w))
      
      
      def write_stats(files: list[tuple[Path, int, float, str]],
                      aux_data: dict[Path, int] | None,
                      aux_label: str | None,
                      root: Path, out_path: Path,
                      fn_ccn_by_path: dict[Path, list[float]] | None = None,
                      tokens_by_path: dict[Path, int] | None = None,
                      churn_degenerate: bool = False,
                      excluded_generated: list[dict] | None = None,
                      languages_by_path: dict[Path, str] | None = None,
                      fn_name_by_path: dict[Path, str] | None = None,
                      fn_backend_by_path: dict[Path, str] | None = None) -> None:
          """Write a JSON stats sidecar summarising the treemap data.
      
          Consumed by the /assess skill: percentiles drive Layer 3 (linter) scoring,
          top hotspot lists become the named files in the actions table.
          Composite hotspot score = sqrt(effective_ccn) * sqrt(1 + commits) *
          sqrt(est_tokens): a sub-linear geometric mean of complexity, recent churn,
          and context-window size. All three axes are sqrt-damped, so a file high on
          *multiple* axes - big AND complex AND churning - is the worst keyhole and
          leads; a frozen-but-complex file ranks below an equally-sized active one;
          a trivially-simple-but-churny file can't top on churn alone; and a
          big-but-simple-stable file (a long config or data table) can't top on size
          alone (low ccn * low churn * big size stays moderate). ``effective_ccn``
          re-weights the file aggregate toward the worst single function (see
          ``_effective_ccn``) so a broad coordinator class can't out-rank a genuinely
          complex single method (issue #115).
      
          ``tokens_by_path`` carries the per-file estimated token counts (computed
          once by ``main`` post-artifact-filter). When omitted it is derived here from
          the same files, so the sidecar is self-consistent whoever calls it.
      
          The ``ccn`` block and every row's ``ccn`` field are **file-level aggregates**
          (sum of per-function complexity). A per-function linter threshold (cyclop 15,
          gocognit, etc.) is NOT comparable to those - so each row also carries
          ``max_fn_ccn`` (the file's worst single function, or null for scc-scored
          files with no function breakdown), and the top-level ``fn_ccn`` block reports
          the per-function distribution. Layer 3 compares the linter threshold against
          ``fn_ccn`` / ``max_fn_ccn``, never the aggregate (issue #58).
      
          ``excluded_generated`` (the list ``collect`` filled) is written as the
          top-level ``excluded_generated`` key, always present and empty when nothing
          was dropped, so the exclusion stays visible downstream.
      
          ``languages_by_path`` (scc's language name per path, from ``collect``)
          splits the ``loc`` and ``est_tokens`` maxima into ``max_code`` and
          ``max_data``: a file whose language is in ``DATA_LANGUAGES`` is data,
          everything else code. A side with no files reports 0.
      
          ``fn_name_by_path`` gives each row's ``max_fn_name`` (null wherever
          ``max_fn_ccn`` is null). ``fn_backend_by_path`` names the per-function
          backend of each path in ``fn_ccn_by_path`` (lizard when omitted);
          ``fn_ccn.source`` lists the backends that scored a file, as
          ``{name, approximate}`` objects from ``FN_BACKENDS``, and
          ``fn_ccn.backend_by_language`` maps each scc language to its backend, or to
          null when any of its files with decision points was scored by scc at file
          level only (so partial coverage reads as null). A language gets a key when a
          backend scored one of its files or scc counted a decision point in one;
          data and markup (JSON, YAML, Markdown), where scc counts none, get no key,
          but CSS maps to null because scc counts decision points in it.
          """
          fn_ccn_by_path = fn_ccn_by_path or {}
          fn_names = fn_name_by_path or {}
          backend_of = {p: (fn_backend_by_path or {}).get(p, "lizard")
                        for p in fn_ccn_by_path}
          tokens = tokens_by_path if tokens_by_path is not None else est_tokens_by_path(files)
          locs = [f[1] for f in files]
          token_vals = [tokens.get(f[0], est_token_count(f[0], f[1])) for f in files]
          ccns = [f[2] for f in files]
          langs = languages_by_path or {}
          is_data = [langs.get(f[0]) in DATA_LANGUAGES for f in files]
      
          def side_max(values: list, data: bool) -> float:
              side = [v for v, d in zip(values, is_data) if d is data]
              return float(max(side)) if side else 0.0
      
          churns = ([float(aux_data.get(f[0], 0)) for f in files]
                    if aux_data is not None else [])
          # Per-function population, per-function backends only. Other scc paths are
          # absent from fn_ccn_by_path, so they don't contribute - the block
          # self-labels its sources so a reader knows what it omits.
          fn_population: list[float] = []
          for vals in fn_ccn_by_path.values():
              fn_population.extend(vals)
      
          def pct(values: list[float], q: float) -> float:
              return float(np.percentile(values, q)) if values else 0.0
      
          def rel(p: Path) -> str:
              # Forward slashes on every host, matching `excluded_generated` (built
              # in `collect`), so assess_core can compare the two path sets on Windows.
              try:
                  return p.relative_to(root).as_posix()
              except ValueError:
                  return p.as_posix()
      
          def max_fn(path: Path) -> float | None:
              vals = fn_ccn_by_path.get(path)
              return float(max(vals)) if vals else None
      
          backends_used = sorted({backend_of[f[0]] for f in files
                                  if f[0] in backend_of})
          covered: dict[str, str] = {}
          uncovered: set[str] = set()
          for path, _loc, metric, _src in files:
              lang = langs.get(path)
              if not lang:
                  continue
              if path in backend_of:
                  covered[lang] = backend_of[path]
              elif metric > 0 and lang not in DATA_LANGUAGES:
                  uncovered.add(lang)
          # A language counts as covered only when no file of it with decision points
          # fell back to scc: partial coverage reads as null, not as the backend.
          backend_by_language: dict[str, str | None] = {
              lang: (None if lang in uncovered else covered[lang])
              for lang in covered.keys() | uncovered
          }
      
          enriched = []
          for path, loc, ccn, src in files:
              churn = float(aux_data.get(path, 0)) if aux_data else 0.0
              est_tokens = tokens.get(path, est_token_count(path, loc))
              enriched.append({
                  "path": rel(path),
                  "loc": int(loc),
                  # Estimated tokens (~chars/4), the size unit the treemap blocks and
                  # the hotspot composite use. `loc` is kept alongside (tooltip +
                  # back-compat). An estimate, not a model-exact count.
                  "est_tokens": int(est_tokens),
                  # File-level aggregate (sum of per-function ccn). See `max_fn_ccn`
                  # for the per-function worst case the linter threshold gates.
                  "ccn": float(ccn),
                  "ccn_basis": "file-aggregate",
                  "max_fn_ccn": max_fn(path),
                  # Name of the function whose ccn is max_fn_ccn; null with it.
                  "max_fn_name": (fn_names.get(path)
                                  if max_fn(path) is not None else None),
                  # Named `commits` to match what every consumer reads (stats_diff,
                  # assess_core, the hotspot template). None when churn is unavailable
                  # (no git), so a missing value is distinct from a real 0.
                  "commits": int(churn) if aux_data else None,
                  "source": src,
                  # Ranked on the per-function-weighted effective complexity, recent
                  # churn, AND context-window size - each sqrt-damped so the worst
                  # keyhole (high on multiple axes) leads and no single axis can top
                  # the list alone (issue #115 for the ccn re-weight; PRD 2026-06 for
                  # the token axis). The `ccn`/`loc` fields above stay raw for the hue
                  # and the Layer 3 comparison.
                  "_score": math.sqrt(_effective_ccn(ccn, max_fn(path)))
                  * math.sqrt(1.0 + churn)
                  * math.sqrt(est_tokens),
              })
      
          def strip(rows: list[dict]) -> list[dict]:
              return [{k: v for k, v in r.items() if k != "_score"} for r in rows]
      
          by_score = sorted(enriched, key=lambda f: -f["_score"])
          by_ccn = sorted(enriched, key=lambda f: -f["ccn"])
          by_loc = sorted(enriched, key=lambda f: -f["loc"])
      
          tool_versions = _tool_versions(files)
          stats: dict = {
              # Run provenance: the artifact schema and a unique id for this emission
              # (distinct from schema_version below, which versions the stats layout).
              "artifact_schema_version": ARTIFACT_SCHEMA_VERSION,
              "run_id": _new_run_id(),
              "plugin_version": _read_plugin_version(),
              # Layout version of this sidecar. A cross-run diff is only comparable
              # when both snapshots share it (assess_core._diff_is_reliable).
              "schema_version": STATS_SCHEMA_VERSION,
              # The complexity backends and their captured versions. A backend version
              # change can shift scores, so a later run flags the diff as not
              # comparable and names the tool. lizard is always present; scc only when
              # it scored files.
              "lizard_version": tool_versions["lizard"],
              **({"scc_version": tool_versions["scc"]} if "scc" in tool_versions else {}),
              "files_scored": len(files),
              # Files dropped by content (generator header, payload-length lines):
              # [{path, reason}]. assess_core copies it into run-context.json.
              "excluded_generated": list(excluded_generated or []),
              "scoring_coverage": {
                  "lizard": sum(1 for f in files if f[3] == "lizard"),
                  "scc": sum(1 for f in files if f[3] == "scc"),
              },
              "churn_window": aux_label,
              # Churn-measurement reliability (lib.git_churn.churn_is_degenerate). True
              # when the history is degenerate - every file ~1 commit - so the
              # saturation axis carries no signal and the hotspot composite's churn
              # term (sqrt(1 + commits)) is near-constant. A reader (and the report)
              # treats the `commits` column and saturation axis as inactive here.
              "churn_degenerate": bool(churn_degenerate),
              "loc": {
                  "p50": pct(locs, 50),
                  "p95": pct(locs, 95),
                  "max": float(max(locs)) if locs else 0.0,
                  # The maxima split by scc language (DATA_LANGUAGES), so a large
                  # JSON fixture cannot pass for the largest source file.
                  "max_code": side_max(locs, False),
                  "max_data": side_max(locs, True),
                  "total": sum(locs),
              },
              # Estimated tokens (~chars/4) - the keyhole size unit. Sized the treemap
              # blocks and feeds the hotspot composite. `budget` rolls the per-file
              # totals into the "does the relevant slice fit one keyhole?" finding.
              "est_tokens": {
                  "p50": pct(token_vals, 50),
                  "p95": pct(token_vals, 95),
                  "max": float(max(token_vals)) if token_vals else 0.0,
                  "max_code": side_max(token_vals, False),
                  "max_data": side_max(token_vals, True),
                  "total": sum(token_vals),
                  "budget": _keyhole_budget_rollup(tokens, root),
              },
              # File-level aggregate complexity (sum per file). Drives the treemap hue
              # and the hotspot composite - NOT comparable to a per-function linter
              # threshold. Use `fn_ccn` for that comparison.
              "ccn": {
                  "basis": "file-aggregate",
                  "p50": pct(ccns, 50),
                  "p95": pct(ccns, 95),
                  "max": float(max(ccns)) if ccns else 0.0,
              },
              # Per-function complexity distribution (the unit a linter threshold like
              # cyclop:15 actually gates). Per-function backends only; scc files
              # contribute no function breakdown. `function_count` is 0 when only scc
              # scored the repo. `backend_by_language` null = no per-function data.
              "fn_ccn": {
                  "basis": "per-function",
                  "source": [{"name": n, "approximate": FN_BACKENDS.get(n, False)}
                             for n in backends_used],
                  "backend_by_language": dict(sorted(backend_by_language.items())),
                  "function_count": len(fn_population),
                  "p50": pct(fn_population, 50),
                  "p95": pct(fn_population, 95),
                  "max": float(max(fn_population)) if fn_population else 0.0,
              },
              "churn": ({
                  "p50": pct(churns, 50),
                  "p95": pct(churns, 95),
                  "max": float(max(churns)) if churns else 0.0,
              } if aux_data is not None else None),
              "top_hotspots": strip(by_score[:10]),
              "top_complex": strip(by_ccn[:10]),
              "top_large": strip(by_loc[:10]),
          }
      
          out_path.write_text(json.dumps(stats, indent=2), encoding="utf-8")
          print(f"wrote {out_path}")
      
      
      def load_survivor_density(run_context_path: Path,
                                root: Path) -> dict[Path, float]:
          """Build a ``{resolved_path: density}`` map from a run-context.json's
          ``test_pressure`` block, for the survivor-density overlay.
      
          Per-file density is ``survived / total`` taken from ``test_pressure.per_file``
          (the only source carrying per-file totals; ``survivor_density.by_file`` holds
          raw survivor *counts*). Entries without a total (e.g. mutmut, which lists
          only survivors) are skipped - we hatch on a real density or not at all.
      
          File paths reported by mutation tools are resolved against ``root`` so they
          match the treemap's resolved file paths. Degrades silently to ``{}`` on any
          error or when no ``test_pressure`` data is present - an absent or empty block
          means no overlay, no warning.
          """
          try:
              ctx = json.loads(run_context_path.read_text(encoding="utf-8"))
          except (FileNotFoundError, json.JSONDecodeError, OSError):
              return {}
          if not isinstance(ctx, dict):
              return {}
          tp = ctx.get("test_pressure")
          if not isinstance(tp, dict):
              return {}
          per_file = tp.get("per_file") or []
          density: dict[Path, float] = {}
          for entry in per_file:
              if not isinstance(entry, dict):
                  continue
              total = entry.get("total")
              survived = entry.get("survived")
              file_str = entry.get("file")
              if not file_str or not total:  # None or 0 total -> no derivable density
                  continue
              try:
                  resolved = (root / file_str).resolve()
                  density[resolved] = (survived or 0) / total
              except (TypeError, ValueError, OSError):
                  continue
          return density
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(
              description=(
                  "Render a Codecov-style hotspot treemap of any folder. "
                  "Hue = cyclomatic complexity, saturation = recent git churn "
                  "(auto-windowed: 12mo -> 24mo -> 5y -> all-time). "
                  "Vivid red = complex AND active = highest risk."
              ))
          ap.add_argument("path", type=Path, help="Directory to analyse")
          ap.add_argument(
              "-o", "--out", type=Path,
              help=("Output SVG path. If omitted, writes "
                    "./hotspot-<folder>.svg in the current working directory."),
          )
          ap.add_argument("--labels", action="store_true",
                          help=("Annotate large blocks with filename, estimated "
                                "tokens and metric"))
          ap.add_argument(
              "--stats", type=Path,
              help=("Write a JSON stats sidecar (file count, estimated-token / "
                    "LOC / CCN percentiles, keyhole-budget rollup, top "
                    "hotspot/complex/large files). Used by /assess to score "
                    "Layer 3 and surface specific improvement actions."),
          )
          ap.add_argument(
              "--include-artifacts", action="store_true",
              help=("Score known build artifacts that are normally filtered "
                    "(main.dart.js, *.min.js, *.bundle.js, *.map, etc.) and "
                    "files excluded as generated by content (a generator "
                    "header in the first 5 lines, or payload-length lines). "
                    "Use this only when you specifically want to visualise "
                    "the build output - typically you'd .gitignore these instead."),
          )
          ap.add_argument(
              "--test-pressure", type=Path, metavar="RUN_CONTEXT_JSON",
              help=("Path to a run-context.json. When its `test_pressure` block "
                    "carries per-file mutation results, files with high survivor "
                    "density are hatched (>30%% diagonal, >50%% cross-hatch) so "
                    "covered-but-unpinned code stops rendering as safe green. "
                    "Absent or empty test_pressure data -> no overlay (silent)."),
          )
          ap.add_argument(
              "--exclude", action="append", default=[], metavar="PATTERN",
              help=("Skip files / directories that match PATTERN. Repeatable. "
                    "A plain string (`regulatory-raw`) is treated as a directory "
                    "name; a glob (`*.csv`) is matched against the basename. "
                    "Extends the built-in defaults rather than replacing them. "
                    "For a durable per-repo exclude list, use "
                    "`.assess/config.toml` (top-level `exclude_dirs` / "
                    "`exclude_patterns`)."),
          )
          ap.add_argument(
              "--scope", type=Path, metavar="SUBDIR",
              help=("Restrict scoring to a subtree of the repo (for `/assess <path>` "
                    "monorepo scoping). A path under the analysed root; the scan still "
                    "roots at the repo (so excludes and churn windowing are unchanged) "
                    "but only files under this subtree are scored, so the treemap "
                    "carries no signal from a sibling directory. Omit for a whole-repo "
                    "run."),
          )
          args = ap.parse_args()
      
          root = args.path.resolve()
          if not root.is_dir():
              print(f"error: {root} is not a directory", file=sys.stderr)
              return 1
      
          scope: Path | None = None
          if args.scope is not None:
              scope = args.scope if args.scope.is_absolute() else (root / args.scope)
              scope = scope.resolve()
              if not scope.exists():
                  print(f"error: scope path {scope} does not exist", file=sys.stderr)
                  return 1
              if not scope.is_relative_to(root):
                  print(f"error: scope path {scope} is not under {root}",
                        file=sys.stderr)
                  return 1
      
          # Resolve user excludes from `.assess/config.toml` first, then layer the
          # CLI `--exclude` on top. Both extend the built-in defaults; the CLI is
          # not "ad-hoc only" - it just doesn't need to be remembered between runs
          # the way the config does. A CLI pattern containing a glob char goes to
          # exclude_patterns; everything else goes to exclude_dirs (so the same
          # `--exclude regulatory-raw` shape works as a dir match without needing
          # the user to pick the right list). `resolve_excludes` is the single shared
          # resolution path - the doc-graph SVG uses it too, so every artifact
          # computes over the identical set (issue #177); the orchestrator drives the
          # read-side scans via the same config - see `assess_core.build_run_context`.
          extra_dirs, extra_patterns = resolve_excludes(root, args.exclude)
      
          excluded_generated: list[dict] = []
          scc_languages: dict[Path, str] = {}
          fn_names: dict[Path, str] = {}
          fn_backends: dict[Path, str] = {}
          files, effective_by, aux_data, aux_label, fn_ccn_by_path = collect(
              root, by="hotspot", include_artifacts=args.include_artifacts,
              extra_exclude_dirs=extra_dirs,
              extra_exclude_patterns=extra_patterns,
              scope=scop
    • doc-graph-svg.py 26.3 KB
      # /// script
      # requires-python = ">=3.12"
      # dependencies = [
      #     "networkx",
      #     "numpy",
      #     "matplotlib",
      # ]
      # ///
      """
      doc-graph-svg.py - the unified doc map: connectivity by structure, staleness by colour.
      
      This single graph carries both Layer 0 doc signals, with one channel each so
      neither is overloaded:
      
        - **Structure** (position + edges) = navigability. A radial layout rings docs
          by link-distance from the entry point: the navigable core is central, and
          docs no traversal can reach are banished to the rim. Orphans float free;
          islands sit as detached clusters. The topology *is* the reachability story,
          so colour is freed for the other signal.
        - **Colour** = staleness, in the exact grammar of the docs-staleness heatmap:
          hue = days since the doc changed (red = stale), blended toward grey by the
          churn of the code it describes. Vivid red = a frozen doc beside churning
          code = a lying map; pale/grey = stable or low-churn.
        - **Size** = file length (lines).
        - The entry point carries a blue ring so the navigation root is obvious even
          though colour now means staleness.
      
      Reuses ``lib.doc_graph`` (structure) and ``lib.doc_staleness`` (the staleness
      metric) so the picture matches the Layer 0 score exactly, and folds the separate
      docs-staleness treemap into this one artifact.
      
      Usage:
          uv run skills/assess/scripts/doc-graph-svg.py <path> [-o out.svg]
              [--layout radial|web] [--size lines|centrality] [--colour staleness|status]
      """
      from __future__ import annotations
      
      import argparse
      import html
      import math
      import sys
      from pathlib import Path
      
      import matplotlib.pyplot as plt
      import networkx as nx
      import numpy as np
      
      sys.path.insert(0, str(Path(__file__).resolve().parent))
      from lib.doc_graph import (  # noqa: E402
          build_doc_graph,
          classify_node,
          group_broken_links,
          radial_shells,
      )
      from lib.assess_config import load_working_notes_config, resolve_excludes  # noqa: E402
      from lib.doc_staleness import analyze_doc_staleness  # noqa: E402
      from lib.treemap_render import adaptive_cap, blend_to_grey, rgba_to_hex  # noqa: E402
      
      # Colour-blind-safe by default. The status palette uses the Okabe-Ito set
      # (distinguishable under all common colour-vision deficiencies); the staleness
      # fill uses the OrRd sequential ramp (CVD-safe, varies in luminance) rather than
      # red-green. Markers also carry non-colour cues (rings, dashes) so the graph
      # never relies on hue alone.
      STALENESS_CMAP = "OrRd"      # pale = fresh/neutral -> dark red = stale lying-map
      COLOR_ENTRY = "#0072B2"      # Okabe-Ito blue
      COLOR_REACHABLE = "#009E73"  # Okabe-Ito bluish-green
      COLOR_ISLAND = "#E69F00"     # Okabe-Ito orange
      COLOR_ORPHAN = "#D55E00"     # Okabe-Ito vermillion
      EDGE_COLOR = "#9aa0a6"
      # Edge kinds. A link is a markdown link; a reference is a backticked doc path
      # that names a file on disk. A reference is drawn dotted: dashes already mean the
      # ghost tether (4,3) and the orphan and ghost rings (3,2), so a dot pattern is
      # the one line style left that collides with neither. Round caps add half the
      # stroke width to each end of a dash, so a near-zero dash paints a round dot and
      # the 4-unit gap keeps a visible break after the caps take their 1.6 units.
      _EDGE_STYLE = {
          "link": {"stroke": EDGE_COLOR, "stroke-dasharray": None,
                   "stroke-width": "1.2", "opacity": "0.6"},
          "reference": {"stroke": EDGE_COLOR, "stroke-dasharray": "0.1,4",
                        "stroke-width": "1.6", "opacity": "0.8"},
      }
      ENTRY_RING = "#0072B2"       # blue ring marks the entry node when colour = staleness
      ORPHAN_RING = "#1a1a1a"      # dark dashed ring marks orphans when colour = staleness
      # A doc with no staleness measurement: white with grey hatching, outside the
      # OrRd ramp and the churn-blend grey, so it never reads as a measured value.
      UNMEASURED_FILL = "url(#unmeasured)"
      _UNMEASURED_PATTERN = (
          '<pattern id="unmeasured" width="5" height="5" patternUnits="userSpaceOnUse" '
          'patternTransform="rotate(45)"><rect width="5" height="5" fill="#ffffff"/>'
          '<line x1="0" y1="0" x2="0" y2="5" stroke="#8c8c8c" stroke-width="1.6"/></pattern>'
      )
      GHOST_COLOR = "#CC79A7"      # Okabe-Ito reddish-purple: broken-link "ghost" nodes
      
      W, H = 1600.0, 1000.0
      MARGIN = 70.0
      R_MIN, R_MAX = 4.0, 24.0
      
      
      _STATUS_COLOR = {
          "entry": COLOR_ENTRY, "reachable": COLOR_REACHABLE,
          "island": COLOR_ISLAND, "orphan": COLOR_ORPHAN,
      }
      
      
      def _fit_rect(pos: dict, nodes: list[str], rect: tuple[float, float, float, float]) -> dict:
          """Normalise spring-layout coords into a sub-rectangle (x, y, w, h)."""
          rx, ry, rw, rh = rect
          xs = np.array([pos[n][0] for n in nodes])
          ys = np.array([pos[n][1] for n in nodes])
          x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
          sx = rw / (x1 - x0) if x1 > x0 else 0.0
          sy = rh / (y1 - y0) if y1 > y0 else 0.0
          # Centre when an axis is degenerate (single column/row).
          return {n: (rx + ((pos[n][0] - x0) * sx if sx else rw / 2),
                      ry + ((pos[n][1] - y0) * sy if sy else rh / 2)) for n in nodes}
      
      
      def _grid_positions(nodes: list[str], rect: tuple[float, float, float, float]) -> dict:
          """Lay nodes out in a tidy grid inside (x, y, w, h)."""
          rx, ry, rw, rh = rect
          count = len(nodes)
          if count == 0:
              return {}
          cols = max(1, round(math.sqrt(count * rw / rh)))
          rows = math.ceil(count / cols)
          cw = rw / cols
          ch = rh / max(rows, 1)
          out = {}
          for i, node in enumerate(nodes):
              c, r = i % cols, i // cols
              out[node] = (rx + cw * (c + 0.5), ry + ch * (r + 0.5))
          return out
      
      
      def _doc_lines(repo_root: Path, rel: str) -> int:
          try:
              return max(1, (repo_root / rel).read_text(encoding="utf-8", errors="ignore").count("\n") + 1)
          except OSError:
              return 1
      
      
      def _radial_positions(graph, entries: set[str], cx: float, cy: float, fit: float) -> dict:
          """Concentric rings by link-distance from the entry points.
      
          Centre = entry; ring k = docs k hops away (following links); everything
          unreachable is banished to the outer rings. The picture is the navigability
          claim made literal: the navigable core is central, the lost docs are at the
          rim. The plot is centred at (cx, cy) and scaled to radius `fit`.
          """
          # Shell assignment (the BFS/distance logic) lives in
          # lib.doc_graph.radial_shells, which is unit-tested; here we only turn the
          # shells into x/y coordinates.
          shells = radial_shells(graph, entries)
          if not shells:
              return {}
      
          raw = nx.shell_layout(graph, nlist=shells, rotate=0.3)
          max_r = max((math.hypot(x, y) for x, y in raw.values()), default=1.0) or 1.0
          return {n: (cx + x / max_r * fit, cy + y / max_r * fit) for n, (x, y) in raw.items()}
      
      
      def _render_ghosts(broken_links: list[dict], pos: dict, radius,
                         show_labels: bool = False) -> str:
          """Draw one 'ghost' node per missing file — not per broken link. Several links
          to the same absent target (e.g. README.md and CONTRIBUTING.md both pointing at
          a missing CLAUDE.md) collapse to a single ghost they all tether to, so the map
          shows one missing file rather than a cloud of duplicates.
      
          Each ghost is a hollow dashed circle sitting just outside the centroid of the
          sources that reference it, joined to each by a dashed tether. The missing name
          lives in the hover tooltip; it is only drawn as a text label when
          ``show_labels`` is set, so ghosts read like every other node (clean by
          default, named on hover)."""
          if not broken_links:
              return ""
          out: list[str] = []
          for gi, group in enumerate(group_broken_links(broken_links)):
              key = group["target"]
              sources = [s for s in group["sources"] if s in pos]
              if not sources:
                  continue
              # Anchor the shared ghost at the centroid of its sources, pushed radially
              # outward so it clears the cluster; fan distinct ghosts apart.
              cx = sum(pos[s][0] for s in sources) / len(sources)
              cy = sum(pos[s][1] for s in sources) / len(sources)
              ang = 0.6 + gi * 0.9
              off = max(radius(s) for s in sources) + 40
              gx, gy = cx + off * math.cos(ang), cy + off * math.sin(ang)
              for s in sources:
                  sx, sy = pos[s]
                  out.append(
                      f'<line x1="{sx:.1f}" y1="{sy:.1f}" x2="{gx:.1f}" y2="{gy:.1f}" '
                      f'stroke="{GHOST_COLOR}" stroke-width="1.2" stroke-dasharray="4,3" opacity="0.85"/>'
                  )
              n = len(sources)
              srcs = ", ".join(sources)
              tip = html.escape(
                  f"BROKEN LINK ({n} source{'s' if n != 1 else ''})\n"
                  f"{srcs} -> {key}\nmissing file: create it or fix the links",
                  quote=False)
              out.append(
                  f'<circle cx="{gx:.1f}" cy="{gy:.1f}" r="7" fill="#ffffff" '
                  f'stroke="{GHOST_COLOR}" stroke-width="1.6" stroke-dasharray="3,2">'
                  f'<title>{tip}</title></circle>'
              )
              if show_labels:
                  out.append(
                      f'<text x="{gx:.1f}" y="{gy - 11:.1f}" font-size="10" fill="{GHOST_COLOR}" '
                      f'text-anchor="middle">{html.escape(Path(key).name)}</text>'
                  )
          return "\n".join(out)
      
      
      def _normalize_edge_kind(kind: str) -> str:
          """An edge with no kind, or an unknown one, draws as a link."""
          return kind if kind in _EDGE_STYLE else "link"
      
      
      def _edge_attrs(kind: str) -> str:
          """Presentation attributes for one edge kind; an unknown kind draws as a link."""
          kind = _normalize_edge_kind(kind)
          style = " ".join(f'{k}="{v}"' for k, v in _EDGE_STYLE[kind].items() if v is not None)
          cap = ' stroke-linecap="round"' if _EDGE_STYLE[kind]["stroke-dasharray"] else ""
          return f'{style}{cap}'
      
      
      def _edge_legend(mid: float, y: float) -> list[str]:
          """One centred row with a sample line per edge kind, styled as the edges."""
          items = [("link", "link"), ("reference", "reference (backticked path)")]
          out: list[str] = []
          x = mid - 150
          for kind, label in items:
              out.append(f'<line data-legend-kind="{kind}" x1="{x:.0f}" y1="{y - 4:.0f}" '
                         f'x2="{x + 28:.0f}" y2="{y - 4:.0f}" {_edge_attrs(kind)}/>')
              out.append(f'<text x="{x + 34:.0f}" y="{y:.0f}" font-size="13">{label}</text>')
              x += 110
          return out
      
      
      def render(result, out_path: Path, repo_root: Path, *, layout: str = "radial",  # noqa: C901  # SVG layout + colour-mode branching; ccn 18, ratchet target
                 size_mode: str = "lines", colour: str = "staleness",
                 staleness: dict | None = None, show_labels: bool = False) -> None:
          graph = result.graph
          nodes = list(graph.nodes())
          n = len(nodes)
          pr = result.pagerank or {x: 1.0 / max(n, 1) for x in nodes}
          in_deg = dict(graph.in_degree())
          out_deg = dict(graph.out_degree())
      
          entries = set(result.entry_points)
          unreachable = set(result.unreachable)
          orphans = set(result.orphans)
      
          # Node size metric: file length (lines) or link-graph centrality.
          if size_mode == "lines":
              sizes = {x: _doc_lines(repo_root, x) for x in nodes}
              size_label = "file length (lines)"
          else:
              sizes = {x: pr.get(x, 0.0) for x in nodes}
              size_label = "link-graph centrality"
          size_max = max(sizes.values(), default=1.0) or 1.0
      
          # Fill colour. Default "staleness" reuses the docs-staleness heatmap grammar
          # (hue = days stale, blended toward grey by the churn of the code the doc
          # describes) so the two doc views speak one colour language. "status" is the
          # older navigability-by-colour mode, kept as an option.
          staleness = staleness or {}
          cmap = plt.get_cmap(STALENESS_CMAP)
          days = {x: float(staleness.get(x, {}).get("last_commit_days") or 0) for x in nodes}
          churn = {x: float(staleness.get(x, {}).get("code_churn_in_window") or 0) for x in nodes}
          # A node the staleness scan never measured (a `.claude/` doc a reference
          # brought in) is hatched, not painted as if a 0d / zero-churn value were known.
          unmeasured = {x: UNMEASURED_FILL for x in nodes if staleness and x not in staleness}
          day_cap, _ = adaptive_cap([days[x] for x in nodes if x not in unmeasured])
          churn_cap, _ = adaptive_cap([churn[x] for x in nodes if x not in unmeasured])
      
          def fill(node: str) -> str:
              if colour == "status":
                  return _STATUS_COLOR[classify_node(node, entries, unreachable, orphans)]
              base = cmap(min(days[node] / day_cap, 1.0) if day_cap else 0.0)
              sat = (churn[node] / churn_cap) if churn_cap else 0.0
              return unmeasured.get(node) or rgba_to_hex(blend_to_grey(base, sat))
      
          # Canvas: square-ish for the radial layout (it's circular, so a wide canvas
          # wastes the sides); wide for the web two-panel. Header = centred title;
          # footer = centred legend (radial only).
          if layout == "radial":
              cw, ch, header, footer = 1180.0, 1230.0, 92.0, 104.0
          else:
              cw, ch, header, footer = 1600.0, 1000.0, 110.0, 0.0
      
          pos: dict = {}
          has_isolated = False
          if layout == "radial":
              cx, cy = cw / 2, header + (ch - header - footer) / 2
              fit = min(cw, ch - header - footer) / 2 - R_MAX - 10
              pos = _radial_positions(graph, entries, cx, cy, fit)
          else:
              # Two-panel: linked web (force) + isolated-docs grid.
              linked = [x for x in nodes if in_deg.get(x, 0) + out_deg.get(x, 0) > 0]
              isolated = [x for x in nodes if x not in set(linked)]
              has_isolated = bool(isolated)
              web_right = (0.60 * cw) if has_isolated else (cw - MARGIN)
              web_rect = (MARGIN, 110, web_right - MARGIN, ch - 110 - MARGIN)
              if linked:
                  sub = graph.subgraph(linked).to_undirected()
                  raw = nx.spring_layout(sub, k=1.2, iterations=250, seed=np.random.RandomState(42))
                  pos.update(_fit_rect(raw, linked, web_rect))
              if has_isolated:
                  orphan_rect = (web_right + 40, 140, (cw - MARGIN) - (web_right + 40), ch - 140 - MARGIN)
                  pos.update(_grid_positions(sorted(isolated), orphan_rect))
      
          def radius(node: str) -> float:
              return R_MIN + (R_MAX - R_MIN) * math.sqrt(sizes.get(node, 0.0) / size_max)
      
          parts: list[str] = [
              '<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
              f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {cw:.0f} {ch:.0f}" '
              f'width="{cw:.0f}" height="{ch:.0f}" preserveAspectRatio="xMidYMid meet" '
              'role="img">',
              # A11y: <title>/<desc> as the first children of the root <svg> give the
              # image an accessible name and description (SVG accessibility contract).
              '<title>Documentation Navigability Graph</title>',
              '<desc>Graph showing documentation structure, reachability from entry '
              'point, and staleness indicators</desc>',
              '<style>',
              '  text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #1a1a1a; }',
              '  circle:hover { stroke: #000; stroke-width: 2; }',
              '</style>',
              f'<rect x="0" y="0" width="{cw:.0f}" height="{ch:.0f}" fill="#ffffff"/>',
              '<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" '
              'markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
              f'<path d="M0,0 L10,5 L0,10 z" fill="{EDGE_COLOR}"/></marker></defs>',
          ]
      
          # Panels: a labelled bin for the isolated docs, separated from the web.
          if has_isolated:
              ox = web_right + 16
              parts.append(
                  f'<rect x="{ox:.0f}" y="104" width="{(cw - MARGIN) - ox:.0f}" '
                  f'height="{ch - 104 - MARGIN + 16:.0f}" rx="10" fill="#fcf0f0" stroke="#f1c0c0"/>'
              )
              parts.append(
                  f'<text x="{ox + 16:.0f}" y="130" font-size="15" font-weight="600" fill="#86181d">'
                  f'{len(isolated)} isolated docs — no link in or out</text>'
              )
      
          # Edges (drawn first, under the nodes). Pull the arrow back to the target rim.
          for u, v, kind in graph.edges(data="kind", default="link"):
              if u not in pos or v not in pos:
                  continue
              x1, y1 = pos[u]
              x2, y2 = pos[v]
              dx, dy = x2 - x1, y2 - y1
              dist = math.hypot(dx, dy) or 1.0
              rt = radius(v) + 3
              ex, ey = x2 - dx / dist * rt, y2 - dy / dist * rt
              parts.append(
                  f'<line data-edge-kind="{_normalize_edge_kind(kind)}" '
                  f'x1="{x1:.1f}" y1="{y1:.1f}" x2="{ex:.1f}" y2="{ey:.1f}" '
                  f'{_edge_attrs(kind)} marker-end="url(#arrow)"/>'
              )
      
          def size_text(node: str) -> str:
              return (f"{sizes.get(node, 0):.0f} lines" if size_mode == "lines"
                      else f"centrality {sizes.get(node, 0):.3f}")
      
          # Nodes. The title (path + stats) lives in a hover tooltip; no inline text.
          label_nodes: list[tuple[float, float, float, str]] = []
          for node in nodes:
              x, y = pos[node]
              r = radius(node)
              status = classify_node(node, entries, unreachable, orphans)
              is_entry = status == "entry"
              # Staleness mode frees colour for staleness, so the entry root and
              # orphans are called out by stroke (a non-colour cue), not fill. A faint
              # grey base stroke keeps pale (fresh) nodes visible on the white canvas.
              dash = ""
              if is_entry:
                  stroke, sw = ENTRY_RING, 3.5
              elif colour == "staleness" and status == "orphan":
                  stroke, sw, dash = ORPHAN_RING, 1.8, ' stroke-dasharray="3,2"'
              else:
                  stroke, sw = "#b8b8b8", 1.0
              days_txt = "" if colour != "staleness" else (
                  "staleness not measured" if node in unmeasured
                  else f"{days[node]:.0f}d stale, subject churn {churn[node]:.0f}")
              tip = html.escape(
                  f"{node}\n{size_text(node)} · in {in_deg.get(node, 0)} · "
                  f"out {out_deg.get(node, 0)} · {status}"
                  + (f" · {days_txt}" if days_txt else ""),
                  quote=False)
              parts.append(
                  f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r:.1f}" fill="{fill(node)}" '
                  f'stroke="{stroke}" stroke-width="{sw}"{dash}><title>{tip}</title></circle>'
              )
              # Inline labels are opt-in only (--labels); default is hover-only.
              if show_labels and (is_entry or in_deg.get(node, 0) + out_deg.get(node, 0) > 0):
                  label_nodes.append((x, y, r, Path(node).name))
      
          for x, y, r, name in label_nodes:
              parts.append(
                  f'<text x="{x:.1f}" y="{y - r - 3:.1f}" font-size="11" '
                  f'text-anchor="middle">{html.escape(name)}</text>'
              )
      
          parts.append(_render_ghosts(result.broken_links, pos, radius, show_labels))
          parts.append(_title(result, n, cw))
          parts.append(_legend(size_label, layout, colour, cw, ch, footer))
          parts.append('</svg>')
          out_path.write_text("\n".join(parts), encoding="utf-8")
      
          print(f"wrote {out_path}  ({n} docs, {graph.number_of_edges()} edges, "
                f"{result.island_count} islands)")
          print(f"orphan-rate {result.orphan_rate:.0%}  reachable-from-entry "
                f"{result.reachability_pct:.0%}  entries={sorted(entries)}")
      
      
      def _title(result, n: int, cw: float) -> str:
          """Two centred lines at the top: headline + one-line stats."""
          mid = cw / 2
          links = len(result.broken_links)
          ghosts = len(group_broken_links(result.broken_links))
          if not links:
              broken_clause = ""
          elif ghosts < links:
              # Several links point at the same absent file; show the link total and
              # the smaller count of distinct missing files they collapse to.
              broken_clause = f' · {links} broken links to {ghosts} missing files'
          else:
              broken_clause = f' · {links} broken links'
          return "\n".join([
              f'<text x="{mid:.0f}" y="40" font-size="24" font-weight="600" '
              f'text-anchor="middle">Doc map — {n} docs, {result.graph.number_of_edges()} '
              f'edges, {result.island_count} islands</text>',
              f'<text x="{mid:.0f}" y="66" font-size="14" fill="#555" text-anchor="middle">'
              f'{result.orphan_rate:.0%} orphaned · {result.reachability_pct:.0%} '
              f'reachable from the entry point'
              + broken_clause
              + '</text>',
          ])
      
      
      def _legend(size_label: str, layout: str, colour: str, cw: float, ch: float,
                  footer: float) -> str:
          """Centred key. For the radial layout it sits in the bottom footer band; for
          the web layout it falls back to a top-left strip."""
          mid = cw / 2
          y = (ch - footer / 2) if footer else 86
          out: list[str] = []
          if colour == "staleness":
              struct = ("rings = link-distance from entry · rim = unreachable"
                        if layout == "radial" else "loose dots = orphans")
              out.append(
                  '<defs><linearGradient id="stalegrad" x1="0" y1="0" x2="1" y2="0">'
                  '<stop offset="0" stop-color="#fff7ec"/><stop offset="0.5" stop-color="#fc8d59"/>'
                  '<stop offset="1" stop-color="#7f0000"/></linearGradient>'
                  + _UNMEASURED_PATTERN + '</defs>'
              )
              # Row 1, centred: gradient (flanked by plain words, no arrow glyph that
              # some SVG renderers tofu) + entry + orphan markers.
              gx = mid - 330
              out.append(f'<text x="{gx - 6:.0f}" y="{y - 16:.0f}" font-size="12" fill="#555" '
                         'text-anchor="end">stable</text>')
              out.append(f'<rect x="{gx:.0f}" y="{y - 27:.0f}" width="110" height="12" rx="3" fill="url(#stalegrad)"/>')
              out.append(f'<text x="{gx + 116:.0f}" y="{y - 16:.0f}" font-size="12" fill="#555">lying map</text>')
              ux = mid - 125
              out.append(f'<circle cx="{ux:.0f}" cy="{y - 20:.0f}" r="8" fill="{UNMEASURED_FILL}" '
                         'stroke="#b8b8b8" stroke-width="1"/>')
              out.append(f'<text x="{ux + 14:.0f}" y="{y - 16:.0f}" font-size="13">not measured</text>')
              ex = mid + 10
              out.append(f'<circle cx="{ex:.0f}" cy="{y - 20:.0f}" r="8" fill="#dddddd" stroke="{ENTRY_RING}" stroke-width="3"/>')
              out.append(f'<text x="{ex + 14:.0f}" y="{y - 16:.0f}" font-size="13">entry</text>')
              ox = mid + 105
              out.append(f'<circle cx="{ox:.0f}" cy="{y - 20:.0f}" r="8" fill="#dddddd" stroke="{ORPHAN_RING}" '
                         'stroke-width="2" stroke-dasharray="3,2"/>')
              out.append(f'<text x="{ox + 14:.0f}" y="{y - 16:.0f}" font-size="13">orphan</text>')
              gh = mid + 205
              out.append(f'<circle cx="{gh:.0f}" cy="{y - 20:.0f}" r="8" fill="#ffffff" stroke="{GHOST_COLOR}" '
                         'stroke-width="1.8" stroke-dasharray="3,2"/>')
              out.append(f'<text x="{gh + 14:.0f}" y="{y - 16:.0f}" font-size="13">ghost (broken link)</text>')
              out.extend(_edge_legend(mid, y + 3))
              out.append(f'<text x="{mid:.0f}" y="{y + 20:.0f}" font-size="12" fill="#555" '
                         f'text-anchor="middle">colour = staleness · size = {size_label} · {struct}</text>')
              return "\n".join(out)
          # Status mode: discrete swatches, centred.
          swatches = [(COLOR_ENTRY, "entry"), (COLOR_REACHABLE, "reachable"),
                      (COLOR_ISLAND, "island"), (COLOR_ORPHAN, "orphan")]
          total = sum(34 + len(lbl) * 7.2 for _, lbl in swatches)
          x = mid - total / 2
          for color, label in swatches:
              out.append(f'<circle cx="{x + 6:.0f}" cy="{y - 4:.0f}" r="7" fill="{color}"/>')
              out.append(f'<text x="{x + 20:.0f}" y="{y:.0f}" font-size="13">{label}</text>')
              x += 34 + len(label) * 7.2
          out.extend(_edge_legend(mid, y + 24))
          return "\n".join(out)
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(
              description="Render the doc link-graph as a navigability node diagram.")
          ap.add_argument("path", type=Path, help="Directory to analyse")
          ap.add_argument("-o", "--out", type=Path,
                          help="Output SVG path (default ./doc-graph-<folder>.svg)")
          ap.add_argument("--layout", choices=["radial", "web"], default="radial",
                          help="radial: concentric rings by link-distance from entry "
                               "(rim = unreachable). web: force-directed cluster + "
                               "isolated-docs bin.")
          ap.add_argument("--size", choices=["lines", "centrality"], default="lines",
                          help="Node size metric (default file length in lines).")
          ap.add_argument("--colour", "--color", choices=["staleness", "status"],
                          default="staleness", dest="colour",
                          help="staleness: docs-staleness heatmap grammar (default). "
                               "status: navigability colours (entry/reachable/island/orphan).")
          ap.add_argument("--labels", action="store_true",
                          help="Add inline filename labels (default hover-only).")
          ap.add_argument(
              "--exclude", action="append", default=[], metavar="PATTERN",
              help=("Skip files / directories that match PATTERN. Repeatable. "
                    "A plain string (`regulatory-raw`) is treated as a directory "
                    "name; a glob (`*.csv`) is matched against the basename. "
                    "Extends the built-in defaults. For a durable per-repo list use "
                    "`.assess/config.toml` (`exclude_dirs` / `exclude_patterns`); "
                    "those are always honoured so the SVG and the scorer compute over "
                    "the identical doc set (issue #177)."),
          )
          args = ap.parse_args()
      
          root = args.path.resolve()
          if not root.is_dir():
              print(f"error: {root} is not a directory", file=sys.stderr)
              return 1
      
          # Honour the same excludes the scorer applies (`.assess/config.toml` plus
          # any `--exclude`) so the SVG and `lib.doc_graph` compute over the identical
          # doc set rather than reporting different doc counts for one run (issue #177).
          extra_dirs, extra_patterns = resolve_excludes(root, args.exclude)
          working_notes = load_working_notes_config(root)
      
          result = build_doc_graph(
              root,
              extra_exclude_dirs=extra_dirs,
              extra_exclude_patterns=extra_patterns,
              working_notes_dirs=working_notes.dirs,
              working_notes_ignore=working_notes.ignore,
          )
          if not result.available:
              print(f"error: doc graph unavailable - {result.reason}", file=sys.stderr)
              return 1
          if result.doc_count == 0 or result.graph is None:
              print("error: no docs to graph", file=sys.stderr)
              return 1
      
          # Staleness data for colour (reuses the same metric as the heatmap).
          staleness_block = analyze_doc_staleness(
              root, doc_to_code_edges=result.doc_to_code_edges,
              extra_exclude_dirs=extra_dirs,
              extra_exclude_patterns=extra_patterns)
          staleness = {d["path"]: d for d in staleness_block.get("docs", [])}
      
          out = args.out or Path(f"doc-graph-{root.name}.svg")
          render(result, out, root, layout=args.layout, size_mode=args.size,
                 colour=args.colour, staleness=staleness,
                 show_labels=args.labels)
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • templates
    • assess-gate.yml.template 2.2 KB · in bundle
    • hotspot.md.template 547 B · in bundle
    • index.md.template 815 B · in bundle
    • log_entry.md.template 352 B · in bundle
  • tests
    • fixtures
      • golden
        • assess-report-baseline.md 21.6 KB
          # Codebase Assessment: ai-native-toolkit
          
          _Generated <<normalized>>._
          
          **Score: 6.0 / 8 - Solid** - a readiness snapshot, not a verdict · Keyhole: 5 structural concerns (5 hidden coupling), 1 safe zone.
          
          The write-side here is genuinely enforced, not decorative: a ruff complexity ratchet, a mypy type gate, and three blocking pytest jobs under branch protection catch a bad change before it merges, and per-function complexity actually sits under the bar (p95 9 against a 15 cap). The clearest opportunity is behaviour-constraint - 26 tests run in CI but no coverage or mutation gate yet proves they pin behaviour - which is exactly what would carry this from Solid toward AI-Native.
          
          _Note: Mutation testing was not run. Layer 6 (Coverage) is capped at Partial and truth-pressure remains unproven._
          
          > **Agents start here.** The prioritized Top 3 actions below are also machine-readable in `.assess/actions.json` (schema v2: every entry carries `rank`, `action`, `done_when`, and `scope_fence`, plus the lifecycle fields `status` / `claimed_by` / `completed_sha` and a derived execution `mode`). Read that file to pick up the work - even with a smaller model - without parsing this report's prose.
          
          ## Top 3 Actions
          
          The mandatory attention paths (the `hidden_coupling` seams) are covered by Actions 1-2; Action 3 addresses the single highest-leverage scorecard gap (L0 navigability), which moves the overall score from 6.0 toward 7.0.
          
          | # | Action | Layer | Effort | Command / First Step | Hotspot files this addresses | Issue |
          |---|--------|-------|--------|---------------------|------------------------------|-------|
          | 1 | Add a coverage step with a patch-coverage floor, then add mutation testing on the deterministic core to confirm the 26 test files actually pin behaviour | 6 | medium | Add `pytest --cov` to `tests.yml` with a patch floor; pilot `mutmut`/`cosmic-ray` on `skills/assess/scripts/lib/` | `skills/assess/scripts/lib/doc_graph.py`, `skills/assess/scripts/assess_core.py` | - |
          | 2 | Extend the `mypy` gate from `scripts/lib/` to the orchestrator scripts so the most-churned files are type-checked too | 2 | small | Widen the `mypy` target in `skills/assess/pyproject.toml` / the lint job to include `assess_core.py`, `complexity-treemap.py`, `doc-graph-svg.py` | `skills/assess/scripts/assess_core.py`, `skills/assess/scripts/complexity-treemap.py` | - |
          | 3 | Wire a small Map-of-Content (or extend `README.md`) linking skills, agents, and the plan archive, and close the missing cross-references so the README becomes a real index | 0 | small | Add an `index.md` MOC or a "Map" section to `README.md` linking `skills/*/SKILL.md`, `agents/*.md`, `docs/superpowers/plans/` | - | - |
          
          ### Why these three?
          
          Action 1 is the highest-leverage gap: write-side enforcement is strong everywhere except behaviour-constraint - 26 test files run in CI but nothing proves they pin behaviour rather than just execute lines, and the deterministic core is exactly the kind of code (high aggregate complexity, heavy churn) where a survivor cluster would hide. Action 2 closes the remaining type-safety gap on the files that change most often (`assess_core.py` leads churn at 14 commits) - the config comment already names this as the next ratchet step. Action 3 is low-priority human-wayfinding polish: an agent navigates this repo fine by convention, but a human browsing on GitHub gets no map.
          
          ## Snapshots
          
          ### Complexity - riskiest to change
          
          [![Complexity hotspot](./complexity-heatmap.svg)](./complexity-heatmap.svg)
          
          Every hotspot is in `skills/assess/scripts/` - the assessment engine is the most complex, most-churned surface in the repo; per-function ccn p95 9 sits under the C901 gate of 15, so the high file-aggregate numbers are sums of many small functions, not monster functions.
          
          ### Doc navigability - can an agent find its way?
          
          [![Doc map](./doc-graph.svg)](./doc-graph.svg)
          
          Only 11% link-reachable (89% orphan, 35 islands), but this is curation, not access: most "docs" are skill-trigger / agent-persona files Claude Code loads by convention, not by link-traversal. No credible lying maps.
          
          <details>
          <summary>📈 Snapshot detail (commit, hotspots, navigability, lying maps)</summary>
          
          #### Complexity profile
          
          - **Measured at commit:** <<normalized>>
          - **Files scored:** 58 (56 lizard, 2 scc)
          - **Churn window chosen:** last 12mo
          - **Complexity profile:** per-function ccn p95 9 (max 38); file-aggregate ccn p95 107 (max 169); p95 LOC 484 (max 761)
          - **Top hotspots** (composite `sqrt(ccn) × sqrt(1 + commits)`). `ccn` here is the **file aggregate**; the worst single function per file is in parentheses:
            1. `skills/assess/scripts/assess_core.py` - 493 LOC, aggregate ccn 106 (worst function 38), 14 commits in window
            2. `skills/assess/scripts/lib/doc_graph.py` - 514 LOC, aggregate ccn 169 (worst function 23), 7 commits in window
            3. `skills/assess/scripts/complexity-treemap.py` - 482 LOC, aggregate ccn 115 (worst function 19), 10 commits in window
          
          The hotspots are exactly the deterministic core of `/assess` itself - the most-churned, most-complex files are the scanners. That is expected for an actively-developed analysis tool, and the per-function complexity is fenced: ccn p95 is 9, well under the C901 threshold of 15, with the genuine offenders (worst function 38 in `assess_core.py`) carrying explicit `# noqa: C901` ratchet markers. The high *aggregate* ccn (169 on `doc_graph.py`) is many simple functions summed, not one monster function.
          
          Size encodes lines of code, colour encodes cyclomatic complexity (dark red = high), saturation encodes recent git churn (vivid = active). Vivid red blocks are the migration risk.
          
          No hatching visible - mutation analysis was not run. The hatching would mark covered-but-unpinned code (tests that execute without constraining). Run `/assess` and accept the mutation offer to enable it.
          
          #### Where to focus testing
          
          Coverage data: none found - test signals are heuristic-only.
          
          The cheap, always-on read of which risky files most need test work. No coverage report exists for this repo, so no file is called covered: each risky file has a conventionally named test file, and the next step is measuring coverage rather than guessing it.
          
          | File | Risk | Test Signal | Suggested Action |
          |------|------|-------------|------------------|
          | `skills/assess/scripts/assess_core.py` | High | Test file present, coverage unmeasured | Measure coverage |
          | `skills/assess/scripts/lib/doc_graph.py` | High | Test file present, coverage unmeasured | Measure coverage |
          | `skills/assess/scripts/complexity-treemap.py` | High | Test file present, coverage unmeasured | Measure coverage |
          | `skills/assess/tests/test_assess_core.py` | Medium | Test file present, coverage unmeasured | Measure coverage |
          | `skills/assess/scripts/lib/doc_staleness.py` | Medium | Test file present, coverage unmeasured | Measure coverage |
          | `skills/assess/scripts/lib/liveness_scan.py` | Medium | Test file present, coverage unmeasured | Measure coverage |
          | `skills/assess/tests/test_doc_graph.py` | Medium | Test file present, coverage unmeasured | Measure coverage |
          | `skills/assess/scripts/doc-graph-svg.py` | Low | Test file present, coverage unmeasured | Measure coverage |
          
          (8 of 10 focus targets shown; the full ranked list is in `run-context.json` under `.test_focus.entries`.)
          
          These are the **cheap** signals - risk band plus the absence of any coverage report. The **expensive** confirmation lives in the cross-layer findings: the `untrusted_hotspot` finding confirms which files mutation testing proved hollow, and the Layer 6 green-but-hollow row in the Lying Signals table pairs coverage against the mutation score. Both are silent on this run because mutation wasn't collected - which is the Layer 6 gap itself, and the reason every signal above reads "unknown" rather than a confirmed hollow.
          
          #### Doc navigability
          
          Of 37 docs, **11% are reachable** by following links from the entry points (`CLAUDE.md`, `README.md`); the rest sit in **35 disconnected islands** (89% orphan rate, only 2 inter-doc links total). At face value that reads alarming, but read it as **curation, not access**: this is a Claude Code *plugin* repo, and most of its "docs" are not prose articles - they are skill-trigger files (`skills/*/SKILL.md`), agent-persona prompts (`agents/*.md`), and dated planning records under `docs/superpowers/plans/`. Claude Code loads these by *trigger and convention*, not by link-traversal, so the absence of cross-links is largely by design. An agent can still `ls skills/` and open any file by path - nothing is hidden.
          
          The honest gap: a human browsing on GitHub has no map. The `README.md` is rich but doesn't function as a wired index - it links out once. Adding a Map-of-Content that links the skills, agents, and plan archive would help human wayfinding. This is a **wayfinding improvement, not a blocker** - priority is low for an agent-loaded plugin repo. The scanner also flags **missing cross-references** (docs that name another doc's filename in prose without linking it, e.g. several plans naming `skills/pr-review-merge/SKILL.md`) - low-effort wins if you want the README to become a real index.
          
          Colour = staleness (vivid red = a frozen doc beside churning code); structure = reachability (centre = entry, rim = unreachable, dashed ring = orphan); size = file length. Open the SVG directly for per-node hover tooltips.
          
          #### What changed since last run
          
          _Diff suppressed - the prior snapshot was produced by an earlier plugin version, and file-filter differences across versions surface phantom graduated/new transitions that didn't really happen. Cross-run comparison resumes once two runs share a plugin version._
          
          </details>
          
          <details>
          <summary>📊 Full scorecard (per-layer evidence & gaps)</summary>
          
          The two headline metrics measure **different things and are never combined.** The 0-8 score answers _"is the scaffolding in place to catch problems?"_ The Keyhole Readiness summary answers _"where is today's structural pain?"_ - a count of structural concerns and safe zones from the eight cross-layer findings. Here: strong scaffolding (6.0/8) over a small, cohesive, actively-developed subsystem whose files change together.
          
          | Layer | What it asks | Band | Status | Evidence | Gap |
          |-------|--------------|------|--------|----------|-----|
          | 0: Agent Instructions & Navigability | Can I build a true map of this codebase before I touch it? | read | Partial | `CLAUDE.md` grades A (184 lines, lean), `.github/claude-review-instructions.md` grades A; 6 skills factored for progressive disclosure; no broken/untracked refs, no sensitive content | Doc set is link-fragmented (89% orphan, 35 islands, missing xrefs) - strong instructions, weak human wayfinding map |
          | 1: Runtime Legibility / Liveness | Can I see which parts are live, which need attention, and which are dead weight? | read | Partial | Honest rung 0: `instrumented: false`. No deployed runtime - this is an on-demand script/prompt repo, so liveness reads through the complexity heatmap + churn + reachability, not telemetry. `vulture` absent so intra-repo dead-code not run | No telemetry to instrument (expected for a prompt/tooling repo); install `vulture` to enable the Python dead-code scan |
          | 2: Code Design | Will the type-checker catch my mistakes? | write | Present | Python type hints throughout; `mypy` gate enforced in CI on `scripts/lib/`; degrade-gracefully fallbacks carry typed ignores | mypy scoped to `lib/` - orchestrator scripts (`assess_core.py`, `complexity-treemap.py`, `doc-graph-svg.py`) not yet type-gated |
          | 3: Linters | Are complexity and style bounds enforced, or will my code drift? | write | Present | `ruff` with `C901` complexity gate (threshold 15) enforced in CI; per-function ccn p95 9 sits under the threshold; offenders carry explicit `# noqa` ratchet notes | - |
          | 4: Architecture Tests | Are the structural conventions executable, or just folklore? | write | Partial | `plugin contract pytest` enforces the plugin structural contract (required check on `main`): every skill has a valid `SKILL.md`, every marketplace entry exists, internal links resolve; C901 ratchet fences complexity | No import-boundary / file-size architecture enforcement on the repo's own code (grimp scans *targets*, not self) |
          | 5: CI Pipeline | Does something automatically catch a bad change before it merges? | write | Present | `tests.yml` (3 pytest jobs + lint), `pr-lint.yml`, `claude-review.yml`, `build-standalone-skills.yml`; branch protection on `main` with `enforce_admins: true`; `skills/assess pytest`, `scripts/ pytest`, `plugin contract pytest`, `Validate PR title` are required, blocking | `ruff + mypy gates` job runs but is **not** a required status check - lint can fail without blocking merge; no coverage step |
          | 6: Coverage Gates | Do the tests constrain behaviour, or just execute lines? | write | Missing | 26 test files run in CI, but no coverage config, no threshold, no patch-coverage gate, no mutation testing | No behaviour-constraint enforcement - coverage could regress silently |
          | 7: Code Review Bots | Is there design-level feedback on every change? | write | Present | `claude-review.yml` runs automated Claude PR review with repo-specific guidelines in `.github/claude-review-instructions.md` (graded A); CodeRabbit also reviews PRs | - |
          | 8: AI Project Mgmt (capstone) | Do learnings feed back into the contracts, or evaporate? | meta | Partial | Task Master (`.taskmaster/`) drives multi-task marathons; dated plan files under `docs/superpowers/plans/` capture intent; issue-driven contract evolution traced in code comments (#58, #59, #62) - learnings feed back into contracts | No dedicated in-repo retro/learnings log (retro path is per-machine under `~/.claude/`); Task Master state isn't committed |
          
          ### Score derivation (worked)
          
          Present = 1, Partial = 0.5, Missing = 0:
          
          `L0 0.5 + L1 0.5 + L2 1 + L3 1 + L4 0.5 + L5 1 + L6 0 + L7 1 + L8 0.5 = 6.0` raw → `min(6.0, 8)` = **6.0 / 8**.
          
          ### Maturity Level
          
          | Score | Level | Description |
          |-------|-------|-------------|
          | 0-2 | Not Ready | Agent will produce inconsistent, unvalidated code |
          | 3-4 | Basic | Norms exist but aren't enforced. Agent works but drifts |
          | 5-6 | **Solid** | Contracts catch most issues. Agent is productive |
          | 7-8 | AI-Native | System self-improves. Agents work reliably at scale |
          
          This repo sits at the top of **Solid**. The write-side enforcement (types, linting, CI, review bots) is genuinely strong - the thing holding it back from AI-Native is a coverage/behaviour-constraint gate (L6) and two read-side gaps that are mostly benign for a plugin repo (L1 has no runtime to instrument; L0's doc-link fragmentation is curation, not inaccessibility).
          
          </details>
          
          <details>
          <summary>🔎 Cross-layer findings & lying signals (keyhole detail)</summary>
          
          ### Lying Signals
          
          The most dangerous failure mode is an artefact that looks true but isn't. This run produced **none** - a clean result, and a useful contrast with the 2026-05-31 baseline where the tool emitted two false positives about itself (since fixed):
          
          - **L0 stale hub doc:** no entry clears the `ratio > 2.0` AND `confidence != "low"` bar - every stale-hub candidate is `repo-baseline`/`confidence: low` (whole-repo churn proxy) and edited within 3 days.
          - **L1 dead-but-present:** the intra-repo dead-code scan didn't run (`vulture` absent), so there is no candidate to surface - degrade, not a lie.
          - **L6 green-but-hollow:** mutation data wasn't collected (opt-in), so survivor density is unknown - which is itself the L6 gap, not a lie.
          
          ## Cross-Layer Findings (Keyhole Readiness)
          
          These are the axis-crossing signals no single layer surfaces - where the static structure and the git history disagree. The dominant signal here is benign-by-context: the assessment engine's scripts and their tests change together because they *are* one cohesive, actively-developed subsystem.
          
          ### hidden_coupling
          
          Action: investigate the seam
          
          Paths:
          - scripts
          - scripts/tests
          - skills/assess/scripts
          - skills/assess/scripts/lib
          - skills/assess/tests
          
          ### refactor_boundary
          
          Action: safe to hand an agent in isolation
          
          Paths:
          - commands
          
          ### Attention List (Priority Order)
          
          - scripts (score 1): hidden_coupling
          - scripts/tests (score 1): hidden_coupling
          - skills/assess/scripts (score 1): hidden_coupling
          - skills/assess/scripts/lib (score 1): hidden_coupling
          - skills/assess/tests (score 1): hidden_coupling
          
          **Reading these:** every concern is `hidden_coupling` within the `/assess` engine - `scripts/lib` modules and their `tests/` move in the same commits. For a tightly-cohesive subsystem under active development this is *expected*, not a defect. The one **safe zone** is `commands/` (containment 0.92) - edits there stay local, so it is the directory you can hand an agent in isolation with the least risk.
          
          </details>
          
          <details>
          <summary>✅ Strengths & further opportunities</summary>
          
          ### Strengths
          
          - **Real write-side enforcement, not theatre.** `ruff` C901 complexity ratchet (threshold 15, with documented `# noqa` exceptions) and a `mypy` type gate both run in CI - and the per-function complexity actually sits under the bar (p95 9 across 777 functions).
          - **Branch protection with teeth:** `enforce_admins: true` and 3 blocking pytest jobs + PR-title lint on `main`. CI failure means a real regression.
          - **Dogfooded AI review:** `claude-review.yml` runs automated review against repo-specific guidelines (`.github/claude-review-instructions.md`, graded A), and CodeRabbit reviews PRs - the project reviews its own PRs with the kind of tooling it advocates.
          - **Issue-driven contract evolution:** code comments trace decisions back to issues (#58, #59, #62), and dated plan files under `docs/superpowers/plans/` capture intent - the feedback loop the L8 capstone looks for is visibly working.
          - **Excellent agent instructions:** `CLAUDE.md` (A, 184 lean lines) plus 6 factored skills for progressive disclosure - lean pointers over a monolith.
          - **A safe refactor zone:** `derived_findings.refactor_boundary` flags `commands/` as high-containment - edits stay local, so it is safe to hand an agent in isolation.
          
          ### Additional Opportunities
          
          - **L5:** add the `ruff + mypy gates` job to required status checks on `main` - it runs today but a lint/type failure won't block merge.
          - **L1:** `uv tool install vulture` to enable the Python intra-repo dead-code scan - it degrades silently today, so a dead export in the scanners wouldn't be flagged.
          - **Hidden coupling (keyhole finding):** `derived_findings.hidden_coupling` flags `skills/assess/scripts`, `skills/assess/scripts/lib`, `skills/assess/tests`, `scripts`, and `scripts/tests` as changing together historically despite being separate directories - expected for a tool whose scanners, their tests, and the standalone-build scripts evolve in lockstep, but worth watching as a seam before trusting those boundaries.
          
          </details>
          
          <details>
          <summary>🧭 How to read this report (framing & method)</summary>
          
          **Meta-assessment.** This is `/assess` run against the repository that *owns* `/assess`, captured as the Phase-0 dogfood baseline for the `assess-dogfooded` work (teeth + frozen harness + decomposition). Two false positives the tool previously produced about itself - a self-referential Layer-1 rung-3 and a `lying_map` flag on a same-day-edited doc - are now fixed: this run scores observability at rung 0 (honest: no deployed runtime) and emits an empty `lying_map`. The snapshot is preserved as the regression baseline for the decomposition parity tests.
          
          This is an improvement roadmap, not a verdict. It measures one thing: **is the codebase kept honest, not just scaffolded.** It pairs three views:
          
          - **Where the codebase is today** - the complexity heatmap shows current complexity and churn. Vivid red = complex AND actively changing = the files most likely to bite an agent (or a human) next week.
          - **Whether an agent can navigate it** - the doc graph shows the docs' link structure: how much is reachable from the entry point, and which docs are stale maps of churning code.
          - **What keeps it from getting worse** - the AI Readiness score (0-8) across three bands: read-side foundation, write-side enforcement, and meta.
          
          A codebase can be 8/8 and still on fire, or 2/8 with a calm treemap. The views matter together.
          
          **How it's measured.** This is an AI-readiness review run almost entirely on *traditional* tooling - static analysis, git history, and graph metrics over the docs and code. The model only writes the prose around those numbers; it does no scanning itself. That keeps a full run fast and close to zero in model tokens, and makes the structural findings reproducible run-to-run.
          
          </details>
          
          <details>
          <summary>🤖 Machine-readable data (for agents)</summary>
          
          If you are an agent working in this repo, the `.assess/` directory is actionable feedback written for you - the first place to look when deciding where to point effort. It is a compounding, AI-readable record an agent can ingest directly, without re-parsing this prose:
          
          - `.assess/assess-report.md` - this report: the scorecard, the lying signals, and the Top 3 Actions with exact commands and file paths.
          - `.assess/run-context.json` - the full data bus (findings, attention, keyhole summary, prescribed actions, stats, diff).
          - `.assess/complexity-stats.json` - complexity percentiles plus the ranked file lists (`top_hotspots`, `top_complex`, `top_large`).
          - `.assess/hotspots/<file>.md` - per-file briefings, each with a **Suggested actions** section. Read a file's briefing before you change it.
          - `.assess/index.md` - the catalog of every hotspot ever flagged (current and graduated).
          - `.assess/log.md` - append-only run history, so a hotspot's trajectory (regressing / persistent / improving) is visible across runs.
          
          </details>
          
          ---
          
          _Report generated by [`/ai-native-toolkit:assess`](https://github.com/bjcoombs/ai-native-toolkit). Install in any Claude Code session: `/plugin marketplace add https://github.com/bjcoombs/ai-native-toolkit` then `/plugin install ai-native-toolkit@ai-native-toolkit`._
          
        • decomposition-parity-report.md 893 B
          # Deterministic Assessment Snapshot: parity-fixture
          
          _Generated <<normalized>>._
          
          ## Metrics Dashboard
          
          - **Files scored:** 3
          - **Total LOC:** 600
          - **Complexity profile:** p95 LOC 200 (max 500), p95 CCN 8 (max 20)
          - **Churn window:** unavailable
          
          ### Top Hotspots
          
          | Path | LOC | CCN | Commits |
          |------|-----|-----|---------|
          | `src/a.py` | 500 | 20 | 5 |
          | `src/b.py` | 80 | 6 | 2 |
          
          ## Keyhole Readiness
          
          No structural concerns, 0 safe zones.
          
          ## Cross-Layer Findings (Keyhole Readiness)
          
          _No cross-layer findings surfaced - no path crossed an axis boundary._
          
          ## Changes Since Last Run
          
          _No prior run to compare against - this is the first recorded snapshot._
          
          ---
          
          _Deterministic portion of the assessment: metrics, hotspots, and cross-layer findings. The 0-8 layer scores, the per-layer prose, and the Top 3 Actions priority narrative require LLM judgement and are written separately._
          
        • run-context-baseline.json 121.9 KB
          {
            "ancestor_instruction_files": [
              "CLAUDE.md (5 level(s) above repo root)",
              "~/.claude/CLAUDE.md (global user instructions)"
            ],
            "anomalies": [],
            "attention": [
              {
                "findings": [
                  "hidden_coupling"
                ],
                "path": "scripts",
                "score": 1
              },
              {
                "findings": [
                  "hidden_coupling"
                ],
                "path": "scripts/tests",
                "score": 1
              },
              {
                "findings": [
                  "hidden_coupling"
                ],
                "path": "skills/assess/scripts",
                "score": 1
              },
              {
                "findings": [
                  "hidden_coupling"
                ],
                "path": "skills/assess/scripts/lib",
                "score": 1
              },
              {
                "findings": [
                  "hidden_coupling"
                ],
                "path": "skills/assess/tests",
                "score": 1
              }
            ],
            "findings_markdown": "## Cross-Layer Findings (Keyhole Readiness)\n\n### hidden_coupling\n\nAction: investigate the seam\n\nPaths:\n- scripts\n- scripts/tests\n- skills/assess/scripts\n- skills/assess/scripts/lib\n- skills/assess/tests\n\n### refactor_boundary\n\nAction: safe to hand an agent in isolation\n\nPaths:\n- commands\n\n### Attention List (Priority Order)\n\n- scripts (score 1): hidden_coupling\n- scripts/tests (score 1): hidden_coupling\n- skills/assess/scripts (score 1): hidden_coupling\n- skills/assess/scripts/lib (score 1): hidden_coupling\n- skills/assess/tests (score 1): hidden_coupling\n",
            "keyhole_summary": {
              "concerns": [
                {
                  "name": "hidden_coupling",
                  "count": 5
                }
              ],
              "safe_zones": 1,
              "total_concerns": 5,
              "summary_text": "5 structural concerns (5 hidden coupling), 1 safe zone."
            },
            "prescribed_actions": [
              {
                "path": "scripts",
                "action": "investigate the seam",
                "findings": [
                  "hidden_coupling"
                ],
                "rank": 1
              },
              {
                "path": "scripts/tests",
                "action": "investigate the seam",
                "findings": [
                  "hidden_coupling"
                ],
                "rank": 2
              },
              {
                "path": "skills/assess/scripts",
                "action": "investigate the seam",
                "findings": [
                  "hidden_coupling"
                ],
                "rank": 3
              }
            ],
            "behaviour": {
              "available": true,
              "change_coupling_pairs": [
                {
                  "co_change_count": 17,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/SKILL.md",
                  "support_pct": 7.91
                },
                {
                  "co_change_count": 14,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "README.md",
                  "support_pct": 6.51
                },
                {
                  "co_change_count": 12,
                  "file_a": "black-hat.md",
                  "file_b": "blue-hat.md",
                  "support_pct": 5.58
                },
                {
                  "co_change_count": 12,
                  "file_a": "black-hat.md",
                  "file_b": "green-hat.md",
                  "support_pct": 5.58
                },
                {
                  "co_change_count": 12,
                  "file_a": "black-hat.md",
                  "file_b": "white-hat.md",
                  "support_pct": 5.58
                },
                {
                  "co_change_count": 12,
                  "file_a": "blue-hat.md",
                  "file_b": "green-hat.md",
                  "support_pct": 5.58
                },
                {
                  "co_change_count": 12,
                  "file_a": "blue-hat.md",
                  "file_b": "white-hat.md",
                  "support_pct": 5.58
                },
                {
                  "co_change_count": 12,
                  "file_a": "green-hat.md",
                  "file_b": "white-hat.md",
                  "support_pct": 5.58
                },
                {
                  "co_change_count": 11,
                  "file_a": "README.md",
                  "file_b": "skills/assess/SKILL.md",
                  "support_pct": 5.12
                },
                {
                  "co_change_count": 11,
                  "file_a": "blue-hat.md",
                  "file_b": "red-hat.md",
                  "support_pct": 5.12
                },
                {
                  "co_change_count": 11,
                  "file_a": "skills/assess/SKILL.md",
                  "file_b": "skills/assess/scripts/assess_core.py",
                  "support_pct": 5.12
                },
                {
                  "co_change_count": 11,
                  "file_a": "skills/assess/scripts/assess_core.py",
                  "file_b": "skills/assess/tests/test_assess_core.py",
                  "support_pct": 5.12
                },
                {
                  "co_change_count": 10,
                  "file_a": "blue-hat.md",
                  "file_b": "yellow-hat.md",
                  "support_pct": 4.65
                },
                {
                  "co_change_count": 10,
                  "file_a": "red-hat.md",
                  "file_b": "yellow-hat.md",
                  "support_pct": 4.65
                },
                {
                  "co_change_count": 9,
                  "file_a": "black-hat.md",
                  "file_b": "red-hat.md",
                  "support_pct": 4.19
                },
                {
                  "co_change_count": 9,
                  "file_a": "black-hat.md",
                  "file_b": "yellow-hat.md",
                  "support_pct": 4.19
                },
                {
                  "co_change_count": 9,
                  "file_a": "commands/fix-pr.md",
                  "file_b": "commands/tm.md",
                  "support_pct": 4.19
                },
                {
                  "co_change_count": 9,
                  "file_a": "green-hat.md",
                  "file_b": "red-hat.md",
                  "support_pct": 4.19
                },
                {
                  "co_change_count": 9,
                  "file_a": "green-hat.md",
                  "file_b": "yellow-hat.md",
                  "support_pct": 4.19
                },
                {
                  "co_change_count": 9,
                  "file_a": "red-hat.md",
                  "file_b": "white-hat.md",
                  "support_pct": 4.19
                },
                {
                  "co_change_count": 9,
                  "file_a": "skills/assess/SKILL.md",
                  "file_b": "skills/assess/scripts/complexity-treemap.py",
                  "support_pct": 4.19
                },
                {
                  "co_change_count": 9,
                  "file_a": "white-hat.md",
                  "file_b": "yellow-hat.md",
                  "support_pct": 4.19
                },
                {
                  "co_change_count": 8,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "CLAUDE.md",
                  "support_pct": 3.72
                },
                {
                  "co_change_count": 8,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "scripts/standalone_skill_config.py",
                  "support_pct": 3.72
                },
                {
                  "co_change_count": 8,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/scripts/assess_core.py",
                  "support_pct": 3.72
                },
                {
                  "co_change_count": 8,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/tests/test_assess_core.py",
                  "support_pct": 3.72
                },
                {
                  "co_change_count": 8,
                  "file_a": "skills/assess/SKILL.md",
                  "file_b": "skills/assess/tests/test_assess_core.py",
                  "support_pct": 3.72
                },
                {
                  "co_change_count": 7,
                  "file_a": "skills/assess/scripts/lib/doc_graph.py",
                  "file_b": "skills/assess/tests/test_doc_graph.py",
                  "support_pct": 3.26
                },
                {
                  "co_change_count": 6,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/scripts/lib/doc_staleness.py",
                  "support_pct": 2.79
                },
                {
                  "co_change_count": 6,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/huddle/SKILL.md",
                  "support_pct": 2.79
                },
                {
                  "co_change_count": 6,
                  "file_a": ".gitignore",
                  "file_b": "README.md",
                  "support_pct": 2.79
                },
                {
                  "co_change_count": 6,
                  "file_a": "skills/assess/SKILL.md",
                  "file_b": "skills/assess/scripts/lib/doc_staleness.py",
                  "support_pct": 2.79
                },
                {
                  "co_change_count": 6,
                  "file_a": "skills/assess/SKILL.md",
                  "file_b": "skills/assess/tests/test_doc_staleness.py",
                  "support_pct": 2.79
                },
                {
                  "co_change_count": 6,
                  "file_a": "skills/assess/scripts/lib/doc_staleness.py",
                  "file_b": "skills/assess/tests/test_doc_staleness.py",
                  "support_pct": 2.79
                },
                {
                  "co_change_count": 5,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": ".github/workflows/tests.yml",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": ".gitignore",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/scripts/lib/doc_graph.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/tests/test_doc_graph.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/tests/test_doc_staleness.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": ".gitignore",
                  "file_b": "skills/assess/SKILL.md",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "CLAUDE.md",
                  "file_b": "skills/assess/SKILL.md",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "blue-hat.md",
                  "file_b": "purple-hat.md",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "purple-hat.md",
                  "file_b": "red-hat.md",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "purple-hat.md",
                  "file_b": "yellow-hat.md",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "scripts/standalone_skill_config.py",
                  "file_b": "skills/assess/SKILL.md",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "skills/assess/SKILL.md",
                  "file_b": "skills/assess/scripts/lib/doc_graph.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "skills/assess/SKILL.md",
                  "file_b": "skills/assess/tests/test_doc_graph.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "skills/assess/scripts/assess_core.py",
                  "file_b": "skills/assess/scripts/complexity-treemap.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "skills/assess/scripts/assess_core.py",
                  "file_b": "skills/assess/scripts/lib/doc_staleness.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "skills/assess/scripts/assess_core.py",
                  "file_b": "skills/assess/tests/test_doc_staleness.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "skills/assess/scripts/complexity-treemap.py",
                  "file_b": "skills/assess/tests/test_complexity_treemap.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "skills/assess/scripts/lib/doc_graph.py",
                  "file_b": "skills/assess/scripts/lib/doc_staleness.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "skills/assess/scripts/lib/doc_staleness.py",
                  "file_b": "skills/assess/tests/test_assess_core.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 5,
                  "file_a": "skills/assess/scripts/lib/doc_staleness.py",
                  "file_b": "skills/assess/tests/test_doc_graph.py",
                  "support_pct": 2.33
                },
                {
                  "co_change_count": 4,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "scripts/tests/test_transform.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "scripts/transform_skill.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/scripts/complexity-treemap.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/scripts/lib/liveness_scan.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/tests/test_liveness_scan.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/tests/test_wiki_writer.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": ".gitignore",
                  "file_b": "skills/huddle/SKILL.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "README.md",
                  "file_b": "commands/tm.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "README.md",
                  "file_b": "scripts/standalone_skill_config.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "README.md",
                  "file_b": "skills/huddle/SKILL.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "agents/black-hat.md",
                  "file_b": "agents/red-hat.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "agents/black-hat.md",
                  "file_b": "agents/white-hat.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "agents/blue-hat.md",
                  "file_b": "agents/white-hat.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "agents/red-hat.md",
                  "file_b": "agents/white-hat.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "agents/white-hat.md",
                  "file_b": "agents/yellow-hat.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "black-hat.md",
                  "file_b": "purple-hat.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "commands/fix-develop.md",
                  "file_b": "commands/tm.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "green-hat.md",
                  "file_b": "purple-hat.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "purple-hat.md",
                  "file_b": "white-hat.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "scripts/standalone_skill_config.py",
                  "file_b": "skills/assess/scripts/complexity-treemap.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "skills/assess/SKILL.md",
                  "file_b": "skills/assess/tests/test_complexity_treemap.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "skills/assess/SKILL.md",
                  "file_b": "skills/huddle/SKILL.md",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "skills/assess/pyproject.toml",
                  "file_b": "skills/assess/tests/test_assess_core.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "skills/assess/scripts/complexity-treemap.py",
                  "file_b": "skills/assess/scripts/lib/doc_staleness.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "skills/assess/scripts/complexity-treemap.py",
                  "file_b": "skills/assess/tests/test_doc_staleness.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "skills/assess/scripts/lib/doc_graph.py",
                  "file_b": "skills/assess/tests/test_doc_staleness.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "skills/assess/scripts/lib/liveness_scan.py",
                  "file_b": "skills/assess/tests/test_liveness_scan.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "skills/assess/tests/test_assess_core.py",
                  "file_b": "skills/assess/tests/test_doc_staleness.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "skills/assess/tests/test_assess_core.py",
                  "file_b": "skills/assess/tests/test_wiki_writer.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 4,
                  "file_a": "skills/assess/tests/test_doc_graph.py",
                  "file_b": "skills/assess/tests/test_doc_staleness.py",
                  "support_pct": 1.86
                },
                {
                  "co_change_count": 3,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "scripts/build-standalone-skills.sh",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "scripts/tests/test_integration.py",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/pyproject.toml",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/scripts/assess_finalize.py",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/scripts/lib/wiki_writer.py",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/templates/log_entry.md.template",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/tests/test_anomaly_detector.py",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": ".claude-plugin/plugin.json",
                  "file_b": "skills/assess/tests/test_assess_finalize.py",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": ".github/workflows/tests.yml",
                  "file_b": "CLAUDE.md",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": ".github/workflows/tests.yml",
                  "file_b": "skills/assess/SKILL.md",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": "CLAUDE.md",
                  "file_b": "README.md",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": "CLAUDE.md",
                  "file_b": "skills/assess/scripts/assess_core.py",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": "CLAUDE.md",
                  "file_b": "skills/assess/tests/test_assess_core.py",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": "README.md",
                  "file_b": "commands/fix-develop.md",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": "README.md",
                  "file_b": "scripts/tests/test_transform.py",
                  "support_pct": 1.4
                },
                {
                  "co_change_count": 3,
                  "file_a": "agents/black-hat.md",
                  "file_b": "agents/blue-hat.md",
                  "support_pct": 1.4
                }
              ],
              "containment_by_dir": {
                ".claude-plugin": 0.1053,
                ".github": 0.1111,
                ".github/workflows": 0.0,
                "agents": 0.4286,
                "commands": 0.9245,
                "docs": 0.2667,
                "docs/superpowers": 0.3636,
                "docs/superpowers/plans": 0.3636,
                "scripts": 0.0,
                "scripts/tests": 0.0,
                "skills": 0.38,
                "skills/assess": 0.4222,
                "skills/assess/scripts": 0.0,
                "skills/assess/scripts/lib": 0.0,
                "skills/assess/tests": 0.0357,
                "skills/huddle": 0.0
              },
              "hidden_coupling_findings": [
                {
                  "containment_ratio": 0.0,
                  "finding": "hidden_coupling",
                  "path": "scripts",
                  "recommendation": "investigate the seam - the static boundary looks modular but its commits bleed outside it; the boundary is lying"
                },
                {
                  "containment_ratio": 0.0,
                  "finding": "hidden_coupling",
                  "path": "scripts/tests",
                  "recommendation": "investigate the seam - the static boundary looks modular but its commits bleed outside it; the boundary is lying"
                },
                {
                  "containment_ratio": 0.0,
                  "finding": "hidden_coupling",
                  "path": "skills/assess/scripts",
                  "recommendation": "investigate the seam - the static boundary looks modular but its commits bleed outside it; the boundary is lying"
                },
                {
                  "containment_ratio": 0.0,
                  "finding": "hidden_coupling",
                  "path": "skills/assess/scripts/lib",
                  "recommendation": "investigate the seam - the static boundary looks modular but its commits bleed outside it; the boundary is lying"
                },
                {
                  "containment_ratio": 0.0357,
                  "finding": "hidden_coupling",
                  "path": "skills/assess/tests",
                  "recommendation": "investigate the seam - the static boundary looks modular but its commits bleed outside it; the boundary is lying"
                }
              ],
              "refactor_boundaries": [
                {
                  "containment_ratio": 0.9245,
                  "finding": "refactor_boundary",
                  "path": "commands",
                  "recommendation": "safe to hand an agent in isolation - edits here stay contained (high containment)"
                }
              ],
              "static_history_disagreement": [
                {
                  "containment_ratio": 0.0,
                  "finding": "bleeding_module",
                  "path": ".github/workflows",
                  "recommendation": "edits here bleed outside the directory (low containment); no static import graph available to cross-check the boundary"
                },
                {
                  "containment_ratio": 0.0,
                  "finding": "hidden_coupling",
                  "path": "scripts",
                  "recommendation": "investigate the seam - the static boundary looks modular but its commits bleed outside it; the boundary is lying"
                },
                {
                  "containment_ratio": 0.0,
                  "finding": "hidden_coupling",
                  "path": "scripts/tests",
                  "recommendation": "investigate the seam - the static boundary looks modular but its commits bleed outside it; the boundary is lying"
                },
                {
                  "containment_ratio": 0.0,
                  "finding": "hidden_coupling",
                  "path": "skills/assess/scripts",
                  "recommendation": "investigate the seam - the static boundary looks modular but its commits bleed outside it; the boundary is lying"
                },
                {
                  "containment_ratio": 0.0,
                  "finding": "hidden_coupling",
                  "path": "skills/assess/scripts/lib",
                  "recommendation": "investigate the seam - the static boundary looks modular but its commits bleed outside it; the boundary is lying"
                },
                {
                  "containment_ratio": 0.0,
                  "finding": "bleeding_module",
                  "path": "skills/huddle",
                  "recommendation": "edits here bleed outside the directory (low containment); no static import graph available to cross-check the boundary"
                },
                {
                  "containment_ratio": 0.0357,
                  "finding": "hidden_coupling",
                  "path": "skills/assess/tests",
                  "recommendation": "investigate the seam - the static boundary looks modular but its commits bleed outside it; the boundary is lying"
                },
                {
                  "containment_ratio": 0.1053,
                  "finding": "bleeding_module",
                  "path": ".claude-plugin",
                  "recommendation": "edits here bleed outside the directory (low containment); no static import graph available to cross-check the boundary"
                },
                {
                  "containment_ratio": 0.1111,
                  "finding": "bleeding_module",
                  "path": ".github",
                  "recommendation": "edits here bleed outside the directory (low containment); no static import graph available to cross-check the boundary"
                },
                {
                  "containment_ratio": 0.2667,
                  "finding": "bleeding_module",
                  "path": "docs",
                  "recommendation": "edits here bleed outside the directory (low containment); no static import graph available to cross-check the boundary"
                }
              ],
              "static_modularity_projection": "repo-level (coarse)"
            },
            "broken_instruction_refs": [],
            "dead_code": {
              "available": false,
              "candidate_count": 0,
              "candidates": [],
              "caveat": "Static reachability proves nothing in THIS repo references the symbol; it cannot prove no external consumer (a mobile app, another service) calls it. Cross-boundary liveness needs telemetry or a named human.",
              "tools": [
                {
                  "language": "python",
                  "reason": "vulture not on PATH",
                  "status": "tool_absent",
                  "tool": "vulture"
                }
              ]
            },
            "derived_findings": [
              {
                "name": "hidden_coupling",
                "paths": [
                  "scripts",
                  "scripts/tests",
                  "skills/assess/scripts",
                  "skills/assess/scripts/lib",
                  "skills/assess/tests"
                ],
                "action": "investigate the seam"
              },
              {
                "name": "lying_map",
                "paths": [],
                "action": "fix or delete the doc"
              },
              {
                "name": "unexplained_complexity",
                "paths": [],
                "action": "write the missing contract (do NOT auto-generate)"
              },
              {
                "name": "untrusted_hotspot",
                "paths": [],
                "action": "strengthen tests to pin observable behaviour (not internal state)"
              },
              {
                "name": "self_referential_tests",
                "paths": [],
                "action": "request human review - tests verify internal consistency, not truth"
              },
              {
                "name": "orphaned_understanding",
                "paths": [],
                "action": "assign a human anchor before further change"
              },
              {
                "name": "candidate_dead_weight",
                "paths": [],
                "action": "verify liveness, then delete if dead"
              },
              {
                "name": "refactor_boundary",
                "paths": [
                  "commands"
                ],
                "action": "safe to hand an agent in isolation"
              }
            ],
            "diff": "<<normalized>>",
            "diff_detail": "<<normalized>>",
            "diff_reliable": "<<normalized>>",
            "diff_version_note": "<<normalized>>",
            "doc_graph": {
              "ambiguous_wikilinks": 0,
              "available": true,
              "broken_links": [],
              "dangling_links": 0,
              "declared_mocs": [],
              "doc_count": 37,
              "doc_to_code_edges": [
                {
                  "code": "skills/assess/scripts/complexity-treemap.py",
                  "doc": "README.md"
                },
                {
                  "code": "skills/assess/scripts/doc-graph-svg.py",
                  "doc": "README.md"
                },
                {
                  "code": "skills/ghsync/scripts/ghsync.sh",
                  "doc": "README.md"
                }
              ],
              "edge_count": 2,
              "entry_points": [
                "CLAUDE.md",
                "README.md"
              ],
              "hubs": [
                {
                  "in_degree": 1,
                  "out_degree": 0,
                  "pagerank": 0.0478,
                  "path": "docs/testing-a-branch-locally.md"
                },
                {
                  "in_degree": 1,
                  "out_degree": 0,
                  "pagerank": 0.0478,
                  "path": "skills/deslop/references/full-checklist.md"
                },
                {
                  "in_degree": 0,
                  "out_degree": 1,
                  "pagerank": 0.0258,
                  "path": "CLAUDE.md"
                },
                {
                  "in_degree": 0,
                  "out_degree": 1,
                  "pagerank": 0.0258,
                  "path": "README.md"
                },
                {
                  "in_degree": 0,
                  "out_degree": 0,
                  "pagerank": 0.0258,
                  "path": ".github/claude-review-instructions.md"
                },
                {
                  "in_degree": 0,
                  "out_degree": 0,
                  "pagerank": 0.0258,
                  "path": "agents/black-hat.md"
                },
                {
                  "in_degree": 0,
                  "out_degree": 0,
                  "pagerank": 0.0258,
                  "path": "agents/blue-hat.md"
                },
                {
                  "in_degree": 0,
                  "out_degree": 0,
                  "pagerank": 0.0258,
                  "path": "agents/green-hat.md"
                },
                {
                  "in_degree": 0,
                  "out_degree": 0,
                  "pagerank": 0.0258,
                  "path": "agents/red-hat.md"
                },
                {
                  "in_degree": 0,
                  "out_degree": 0,
                  "pagerank": 0.0258,
                  "path": "agents/scribe.md"
                }
              ],
              "island_count": 35,
              "missing_xrefs": [
                {
                  "from": ".github/claude-review-instructions.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "CLAUDE.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-22-assess-deterministic-wiki.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-22-assess-v1.5-real-use-fixes.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-23-standalone-skill-pipeline.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-27-assess-truth-pressure-signals.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-27-huddle-cli-team-mode-regression.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-28-assess-dismiss-false-positives.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-29-assess-keyhole-readiness.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-29-assess-write-side-truth-pressure.md",
                  "to": "docs/superpowers/plans/2026-05-27-assess-truth-pressure-signals.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-29-assess-write-side-truth-pressure.md",
                  "to": "docs/superpowers/plans/2026-05-29-assess-keyhole-readiness.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-29-assess-write-side-truth-pressure.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-29-issues-marathon-shared-skills.md",
                  "to": "commands/fix-pr.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-29-issues-marathon-shared-skills.md",
                  "to": "commands/tm.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-29-issues-marathon-shared-skills.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/plans/2026-05-31-assess-dogfooded.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "docs/superpowers/specs/2026-05-29-issues-marathon-shared-skills-design.md",
                  "to": "commands/tm.md"
                },
                {
                  "from": "docs/superpowers/specs/2026-05-29-issues-marathon-shared-skills-design.md",
                  "to": "commands/fix-pr.md"
                },
                {
                  "from": "docs/superpowers/specs/2026-05-29-issues-marathon-shared-skills-design.md",
                  "to": "commands/fix-develop.md"
                },
                {
                  "from": "docs/testing-a-branch-locally.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                },
                {
                  "from": "skills/deslop/references/full-checklist.md",
                  "to": "skills/pr-review-merge/SKILL.md"
                }
              ],
              "moc_named_but_not_wired": [],
              "obsidiantools_available": false,
              "orphan_rate": 0.892,
              "orphans": [
                ".github/claude-review-instructions.md",
                "agents/black-hat.md",
                "agents/blue-hat.md",
                "agents/green-hat.md",
                "agents/red-hat.md",
                "agents/scribe.md",
                "agents/white-hat.md",
                "agents/yellow-hat.md",
                "commands/6hats.md",
                "commands/fix-develop.md",
                "commands/fix-pr.md",
                "commands/issues.md",
                "commands/tm-marathon-config-example.md",
                "commands/tm.md",
                "commands/understand.md",
                "docs/superpowers/plans/2026-05-22-assess-deterministic-wiki.md",
                "docs/superpowers/plans/2026-05-22-assess-v1.5-real-use-fixes.md",
                "docs/superpowers/plans/2026-05-23-standalone-skill-pipeline.md",
                "docs/superpowers/plans/2026-05-27-assess-truth-pressure-signals.md",
                "docs/superpowers/plans/2026-05-27-huddle-broadcast-per-recipient.md",
                "docs/superpowers/plans/2026-05-27-huddle-cli-team-mode-regression.md",
                "docs/superpowers/plans/2026-05-28-assess-dismiss-false-positives.md",
                "docs/superpowers/plans/2026-05-29-assess-keyhole-readiness.md",
                "docs/superpowers/plans/2026-05-29-assess-write-side-truth-pressure.md",
                "docs/superpowers/plans/2026-05-29-issues-marathon-shared-skills.md",
                "docs/superpowers/plans/2026-05-31-assess-dogfooded.md",
                "docs/superpowers/specs/2026-05-29-issues-marathon-shared-skills-design.md",
                "skills/assess/SKILL.md",
                "skills/deslop/SKILL.md",
                "skills/ghsync/SKILL.md",
                "skills/huddle/SKILL.md",
                "skills/marathon/SKILL.md",
                "skills/pr-review-merge/SKILL.md"
              ],
              "reachability_pct": 0.108,
              "reason": "",
              "unreachable": [
                ".github/claude-review-instructions.md",
                "agents/black-hat.md",
                "agents/blue-hat.md",
                "agents/green-hat.md",
                "agents/red-hat.md",
                "agents/scribe.md",
                "agents/white-hat.md",
                "agents/yellow-hat.md",
                "commands/6hats.md",
                "commands/fix-develop.md",
                "commands/fix-pr.md",
                "commands/issues.md",
                "commands/tm-marathon-config-example.md",
                "commands/tm.md",
                "commands/understand.md",
                "docs/superpowers/plans/2026-05-22-assess-deterministic-wiki.md",
                "docs/superpowers/plans/2026-05-22-assess-v1.5-real-use-fixes.md",
                "docs/superpowers/plans/2026-05-23-standalone-skill-pipeline.md",
                "docs/superpowers/plans/2026-05-27-assess-truth-pressure-signals.md",
                "docs/superpowers/plans/2026-05-27-huddle-broadcast-per-recipient.md",
                "docs/superpowers/plans/2026-05-27-huddle-cli-team-mode-regression.md",
                "docs/superpowers/plans/2026-05-28-assess-dismiss-false-positives.md",
                "docs/superpowers/plans/2026-05-29-assess-keyhole-readiness.md",
                "docs/superpowers/plans/2026-05-29-assess-write-side-truth-pressure.md",
                "docs/superpowers/plans/2026-05-29-issues-marathon-shared-skills.md",
                "docs/superpowers/plans/2026-05-31-assess-dogfooded.md",
                "docs/superpowers/specs/2026-05-29-issues-marathon-shared-skills-design.md",
                "skills/assess/SKILL.md",
                "skills/deslop/SKILL.md",
                "skills/ghsync/SKILL.md",
                "skills/huddle/SKILL.md",
                "skills/marathon/SKILL.md",
                "skills/pr-review-merge/SKILL.md"
              ],
              "vault_detected": false
            },
            "doc_staleness": {
              "association": {
                "code_file_count": 57,
                "code_under_base_doc": 57,
                "doc_count": 37,
                "docs_mapping_to_code": 1,
                "methods": {
                  "nearest-ancestor": 1,
                  "repo-baseline": 36
                },
                "pct_code_under_base_doc": 1.0,
                "pct_docs_mapping_to_code": 0.027
              },
              "available": true,
              "churn_window": "commits (last 12mo)",
              "docs": [
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 1,
                  "path": ".github/claude-review-instructions.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 2,
                  "path": "commands/issues.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 265,
                  "path": "commands/understand.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 10,
                  "path": "docs/superpowers/plans/2026-05-22-assess-deterministic-wiki.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 9,
                  "path": "docs/superpowers/plans/2026-05-22-assess-v1.5-real-use-fixes.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 8,
                  "path": "docs/superpowers/plans/2026-05-23-standalone-skill-pipeline.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 4,
                  "path": "docs/superpowers/plans/2026-05-27-assess-truth-pressure-signals.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 4,
                  "path": "docs/superpowers/plans/2026-05-27-huddle-broadcast-per-recipient.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 4,
                  "path": "docs/superpowers/plans/2026-05-27-huddle-cli-team-mode-regression.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 3,
                  "path": "docs/superpowers/plans/2026-05-28-assess-dismiss-false-positives.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 3,
                  "path": "docs/superpowers/plans/2026-05-29-assess-keyhole-readiness.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 3,
                  "path": "docs/superpowers/plans/2026-05-29-assess-write-side-truth-pressure.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 2,
                  "path": "docs/superpowers/plans/2026-05-29-issues-marathon-shared-skills.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 0,
                  "path": "docs/superpowers/plans/2026-05-31-assess-dogfooded.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 2,
                  "path": "docs/superpowers/specs/2026-05-29-issues-marathon-shared-skills-design.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 3,
                  "path": "skills/deslop/references/full-checklist.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 0,
                  "path": "skills/ghsync/SKILL.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 2,
                  "path": "skills/marathon/SKILL.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 1,
                  "last_commit_days": 2,
                  "path": "skills/pr-review-merge/SKILL.md",
                  "ratio": 177.0,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 2,
                  "last_commit_days": 256,
                  "path": "agents/scribe.md",
                  "ratio": 88.5,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 2,
                  "last_commit_days": 2,
                  "path": "commands/tm-marathon-config-example.md",
                  "ratio": 88.5,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 2,
                  "last_commit_days": 2,
                  "path": "docs/testing-a-branch-locally.md",
                  "ratio": 88.5,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 2,
                  "last_commit_days": 2,
                  "path": "skills/deslop/SKILL.md",
                  "ratio": 88.5,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 4,
                  "last_commit_days": 59,
                  "path": "agents/black-hat.md",
                  "ratio": 44.25,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 4,
                  "last_commit_days": 59,
                  "path": "agents/green-hat.md",
                  "ratio": 44.25,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 4,
                  "last_commit_days": 59,
                  "path": "agents/red-hat.md",
                  "ratio": 44.25,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 4,
                  "last_commit_days": 59,
                  "path": "agents/yellow-hat.md",
                  "ratio": 44.25,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 5,
                  "last_commit_days": 59,
                  "path": "agents/blue-hat.md",
                  "ratio": 35.4,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 5,
                  "last_commit_days": 59,
                  "path": "agents/white-hat.md",
                  "ratio": 35.4,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 5,
                  "last_commit_days": 2,
                  "path": "commands/fix-develop.md",
                  "ratio": 35.4,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 7,
                  "last_commit_days": 2,
                  "path": "skills/huddle/SKILL.md",
                  "ratio": 25.29,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 8,
                  "last_commit_days": 1,
                  "path": "CLAUDE.md",
                  "ratio": 22.12,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 9,
                  "last_commit_days": 58,
                  "path": "commands/6hats.md",
                  "ratio": 19.67,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 13,
                  "last_commit_days": 2,
                  "path": "commands/fix-pr.md",
                  "ratio": 13.62,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 31,
                  "last_commit_days": 0,
                  "path": "skills/assess/SKILL.md",
                  "ratio": 5.71,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "high",
                  "doc_churn_in_window": 35,
                  "last_commit_days": 0,
                  "path": "README.md",
                  "ratio": 5.06,
                  "subject_code_count": 57,
                  "subject_method": "nearest-ancestor"
                },
                {
                  "code_churn_in_window": 177,
                  "confidence": "low",
                  "doc_churn_in_window": 78,
                  "last_commit_days": 2,
                  "path": "commands/tm.md",
                  "ratio": 2.27,
                  "subject_code_count": 57,
                  "subject_method": "repo-baseline"
                }
              ],
              "modularity": {
                "base_doc_coverage_when_present": 1.0,
                "base_doc_dir_ratio": 0.125,
                "code_file_count": 57,
                "large_repo": true,
                "module_dir_count": 8,
                "module_dirs_with_base_doc": 1
              }
            },
            "documentation": {
              "available": true,
              "complexity_coverage": {
                ".github/claude-review-instructions.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "CLAUDE.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "README.md": {
                  "complexity_summarised": 51.0,
                  "doc_value": -51.0,
                  "subject_code_count": 1
                },
                "agents/black-hat.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "agents/blue-hat.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "agents/green-hat.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "agents/red-hat.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "agents/scribe.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "agents/white-hat.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "agents/yellow-hat.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "commands/6hats.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "commands/fix-develop.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "commands/fix-pr.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "commands/issues.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "commands/tm-marathon-config-example.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "commands/tm.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "commands/understand.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-22-assess-deterministic-wiki.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-22-assess-v1.5-real-use-fixes.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-23-standalone-skill-pipeline.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-27-assess-truth-pressure-signals.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-27-huddle-broadcast-per-recipient.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-27-huddle-cli-team-mode-regression.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-28-assess-dismiss-false-positives.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-29-assess-keyhole-readiness.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-29-assess-write-side-truth-pressure.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-29-issues-marathon-shared-skills.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/plans/2026-05-31-assess-dogfooded.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/superpowers/specs/2026-05-29-issues-marathon-shared-skills-design.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "docs/testing-a-branch-locally.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "skills/assess/SKILL.md": {
                  "complexity_summarised": 169.0,
                  "doc_value": -169.0,
                  "subject_code_count": 15
                },
                "skills/deslop/SKILL.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "skills/deslop/references/full-checklist.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "skills/ghsync/SKILL.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "skills/huddle/SKILL.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "skills/marathon/SKILL.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                },
                "skills/pr-review-merge/SKILL.md": {
                  "complexity_summarised": 0.0,
                  "doc_value": -0.0,
                  "subject_code_count": 0
                }
              },
              "freshness_by_doc": {
                ".github/claude-review-instructions.md": -1.0,
                "CLAUDE.md": -1.0,
                "README.md": -1.0,
                "agents/black-hat.md": -1.0,
                "agents/blue-hat.md": -1.0,
                "agents/green-hat.md": -1.0,
                "agents/red-hat.md": -1.0,
                "agents/scribe.md": -1.0,
                "agents/white-hat.md": -1.0,
                "agents/yellow-hat.md": -1.0,
                "commands/6hats.md": -1.0,
                "commands/fix-develop.md": -1.0,
                "commands/fix-pr.md": -1.0,
                "commands/issues.md": -1.0,
                "commands/tm-marathon-config-example.md": -1.0,
                "commands/tm.md": -0.135,
                "commands/understand.md": -1.0,
                "docs/superpowers/plans/2026-05-22-assess-deterministic-wiki.md": -1.0,
                "docs/superpowers/plans/2026-05-22-assess-v1.5-real-use-fixes.md": -1.0,
                "docs/superpowers/plans/2026-05-23-standalone-skill-pipeline.md": -1.0,
                "docs/superpowers/plans/2026-05-27-assess-truth-pressure-signals.md": -1.0,
                "docs/superpowers/plans/2026-05-27-huddle-broadcast-per-recipient.md": -1.0,
                "docs/superpowers/plans/2026-05-27-huddle-cli-team-mode-regression.md": -1.0,
                "docs/superpowers/plans/2026-05-28-assess-dismiss-false-positives.md": -1.0,
                "docs/superpowers/plans/2026-05-29-assess-keyhole-readiness.md": -1.0,
                "docs/superpowers/plans/2026-05-29-assess-write-side-truth-pressure.md": -1.0,
                "docs/superpowers/plans/2026-05-29-issues-marathon-shared-skills.md": -1.0,
                "docs/superpowers/plans/2026-05-31-assess-dogfooded.md": -1.0,
                "docs/superpowers/specs/2026-05-29-issues-marathon-shared-skills-design.md": -1.0,
                "docs/testing-a-branch-locally.md": -1.0,
                "skills/assess/SKILL.md": -1.0,
                "skills/deslop/SKILL.md": -1.0,
                "skills/deslop/references/full-checklist.md": -1.0,
                "skills/ghsync/SKILL.md": -1.0,
                "skills/huddle/SKILL.md": -1.0,
                "skills/marathon/SKILL.md": -1.0,
                "skills/pr-review-merge/SKILL.md": -1.0
              },
              "good_contracts": [],
              "high_ccn_threshold": 107.35,
              "stale_doc_on_complexity": [],
              "unexplained_complexity": []
            },
            "instruction_file_size": {
              ".github/claude-review-instructions.md": {
                "bloat_penalty": 0,
                "line_count": 237,
                "word_count": 1532
              },
              "CLAUDE.md": {
                "bloat_penalty": 0,
                "line_count": 184,
                "word_count": 1773
              }
            },
            "instruction_files": {
              ".github/claude-review-instructions.md": {
                "freshness_days": 1,
                "grade": "A",
                "line_count": 237,
                "present": true,
                "score": 82,
                "subscores": {
                  "bloat_penalty": 0,
                  "line_count": 237,
                  "path_references": 29,
                  "positive_directives": 9,
                  "tradeoff_phrases": 5,
                  "verifiable_outcomes": 0,
                  "word_count": 1532
                }
              },
              "CLAUDE.md": {
                "freshness_days": 1,
                "grade": "A-",
                "line_count": 184,
                "present": true,
                "score": 70,
                "subscores": {
                  "bloat_penalty": 0,
                  "line_count": 184,
                  "path_references": 66,
                  "positive_directives": 26,
                  "tradeoff_phrases": 2,
                  "verifiable_outcomes": 0,
                  "word_count": 1773
                }
              }
            },
            "instructions_grade": "A",
            "measured_commit": {
              "available": true,
              "behind": "<<normalized>>",
              "committed_date": "<<normalized>>",
              "dirty": "<<normalized>>",
              "head_sha": "<<normalized>>",
              "head_short": "<<normalized>>",
              "subject": "<<normalized>>",
              "upstream": "<<normalized>>"
            },
            "observability": {
              "boundary": "Scores what the repo makes agent-reachable; cannot observe the agent's live environment.",
              "discoverable": {
                "present": true,
                "signals": [
                  "observability content (data-freshness, datadog, grafana, observability, prometheus, runbook): docs/superpowers/plans/2026-05-27-assess-truth-pressure-signals.md",
                  "observability content (datadog, grafana, observability, prometheus, runbook): skills/assess/SKILL.md",
                  "observability content (observability, runbook): docs/superpowers/plans/2026-05-28-assess-dismiss-false-positives.md"
                ]
              },
              "instrumented": {
                "present": false,
                "signals": []
              },
              "reachable": {
                "present": true,
                "signals": [
                  "runbook with runnable queries: skills/assess/SKILL.md"
                ]
              },
              "rung": 0
            },
            "plugin_version": "<<normalized>>",
            "prior_plugin_version": "<<normalized>>",
            "prior_stats_exists": "<<normalized>>",
            "run_date": "<<normalized>>",
            "runtime": {
              "available": true,
              "observability_rung": 0,
              "runtime_evidence_available": true,
              "static_reachability": {
                "available": false,
                "candidate_count": 0,
                "candidates": [],
                "caveat": "Static reachability proves nothing in THIS repo references the symbol; it cannot prove no external consumer (a mobile app, another service) calls it. Cross-boundary liveness needs telemetry or a named human.",
                "tools": [
                  {
                    "language": "python",
                    "reason": "vulture not on PATH",
                    "status": "tool_absent",
                    "tool": "vulture"
                  }
                ]
              }
            },
            "sensitive_instruction_content": {},
            "skill_files": [
              "skills/pr-review-merge/SKILL.md",
              "skills/ghsync/SKILL.md",
              "skills/marathon/SKILL.md",
              "skills/huddle/SKILL.md",
              "skills/assess/SKILL.md",
              "skills/deslop/SKILL.md"
            ],
            "skills_count": 6,
            "skills_present": true,
            "stale_hubs": [
              {
                "code_churn_in_window": 177,
                "confidence": "low",
                "last_commit_days": 3,
                "pagerank": 0.0478,
                "path": "skills/deslop/references/full-checklist.md",
                "priority": 8.461,
                "ratio": 177.0,
                "subject_method": "repo-baseline"
              },
              {
                "code_churn_in_window": 177,
                "confidence": "low",
                "last_commit_days": 1,
                "pagerank": 0.0258,
                "path": ".github/claude-review-instructions.md",
                "priority": 4.567,
                "ratio": 177.0,
                "subject_method": "repo-baseline"
              },
              {
                "code_churn_in_window": 177,
                "confidence": "low",
                "last_commit_days": 2,
                "pagerank": 0.0478,
                "path": "docs/testing-a-branch-locally.md",
                "priority": 4.23,
                "ratio": 88.5,
                "subject_method": "repo-baseline"
              },
              {
                "code_churn_in_window": 177,
                "confidence": "low",
                "last_commit_days": 256,
                "pagerank": 0.0258,
                "path": "agents/scribe.md",
                "priority": 2.283,
                "ratio": 88.5,
                "subject_method": "repo-baseline"
              },
              {
                "code_churn_in_window": 177,
                "confidence": "low",
                "last_commit_days": 59,
                "pagerank": 0.0258,
                "path": "agents/black-hat.md",
                "priority": 1.142,
                "ratio": 44.25,
                "subject_method": "repo-baseline"
              },
              {
                "code_churn_in_window": 177,
                "confidence": "low",
                "last_commit_days": 59,
                "pagerank": 0.0258,
                "path": "agents/green-hat.md",
                "priority": 1.142,
                "ratio": 44.25,
                "subject_method": "repo-baseline"
              },
              {
                "code_churn_in_window": 177,
                "confidence": "low",
                "last_commit_days": 59,
                "pagerank": 0.0258,
                "path": "agents/red-hat.md",
                "priority": 1.142,
                "ratio": 44.25,
                "subject_method": "repo-baseline"
              },
              {
                "code_churn_in_window": 177,
                "confidence": "low",
                "last_commit_days": 59,
                "pagerank": 0.0258,
                "path": "agents/blue-hat.md",
                "priority": 0.913,
                "ratio": 35.4,
                "subject_method": "repo-baseline"
              },
              {
                "code_churn_in_window": 177,
                "confidence": "low",
                "last_commit_days": 1,
                "pagerank": 0.0258,
                "path": "CLAUDE.md",
                "priority": 0.571,
                "ratio": 22.12,
                "subject_method": "repo-baseline"
              },
              {
                "code_churn_in_window": 177,
                "confidence": "high",
                "last_commit_days": 0,
                "pagerank": 0.0258,
                "path": "README.md",
                "priority": 0.131,
                "
      • golden-doc-repo
        • src
          • app.py 29 B
            def run(x):
                return x + 1
            
        • FIXTURE.md 2.1 KB
          # golden-doc-repo — doc-graph-svg golden fixture
          
          Tiny documentation tree consumed by `tests/test_golden_svg_render.py` to lock
          the doc-graph SVG's staleness colour mapping (`days-stale -> hue`), the
          entry-node marker, and the SVG accessibility metadata.
          
          The test copies these files into a temp dir, synthesizes a git history
          (committing `old.md` far in the past so it is stale, everything else "now"),
          churns `src/app.py` so the staleness saturation axis is live, then runs the
          **real** renderer (`scripts/doc-graph-svg.py` via `uv run --script`, no
          matplotlib stubbing) and parses the produced SVG.
          
          This file is named `FIXTURE.md` rather than `README.md` so it is NOT itself a
          graphed doc - `README.md` is the fixture's entry point and must describe the
          repo, not the test.
          
          ## Structure
          
          - `README.md` - entry point; links to `guide.md` and `old.md`.
          - `guide.md` - fresh doc, reachable from the entry.
          - `old.md` - stale doc (committed far in the past), reachable from the entry.
          - `src/app.py` - code the docs describe; churned so staleness saturation is live.
          
          ## Expected node encoding (golden values)
          
          Deterministic from the OrRd colormap: staleness hue = `days / max(days)`, so the
          oldest doc caps at the darkest red and same-day docs sit at the pale end.
          Subject churn is equal across docs (repo-wide), so saturation is full (no grey
          blend) and the fills are the pure base hue.
          
          | Doc         | staleness | fill      | RGB           | marker                          |
          |-------------|-----------|-----------|---------------|---------------------------------|
          | `README.md` | fresh (0d)| `#fff7ec` | (255,247,236) | blue entry ring `#0072B2`, sw 3.5 |
          | `guide.md`  | fresh (0d)| `#fff7ec` | (255,247,236) | default grey stroke             |
          | `old.md`    | stale     | `#7f0000` | (127,  0,  0) | default grey stroke             |
          
          Each node's `<title>` carries the doc path plus its staleness ("Nd stale"), the
          accessible label a screen reader announces.
          
          If the staleness colour mapping changes, these fills move and the golden test
          fails - update this table and the test together, deliberately.
          
        • guide.md 113 B
          # User Guide
          
          Fresh documentation. Back to the [home page](README.md).
          
          Describes the behaviour of `src/app.py`.
          
        • old.md 120 B
          # Old Notes
          
          Stale documentation that has frozen while the code kept moving.
          Reachable from the [home page](README.md).
          
        • README.md 157 B
          # Golden Doc Repo
          
          Entry point for the golden doc-graph render test.
          
          - [User Guide](guide.md)
          - [Old Notes](old.md)
          
          The application lives in `src/app.py`.
          
      • golden-svg-repo
        • complex_stable.py 932 B
          """Deliberately high cyclomatic-complexity file.
          
          One function, 20 `if` decision points -> file-aggregate CCN 21 (lizard).
          Used by the golden-SVG test to assert ccn -> red hue.
          """
          
          
          def classify(n):
              total = 0
              if n == 0:
                  total += 1
              if n == 1:
                  total += 2
              if n == 2:
                  total += 3
              if n == 3:
                  total += 4
              if n == 4:
                  total += 5
              if n == 5:
                  total += 6
              if n == 6:
                  total += 7
              if n == 7:
                  total += 8
              if n == 8:
                  total += 9
              if n == 9:
                  total += 10
              if n == 10:
                  total += 11
              if n == 11:
                  total += 12
              if n == 12:
                  total += 13
              if n == 13:
                  total += 14
              if n == 14:
                  total += 15
              if n == 15:
                  total += 16
              if n == 16:
                  total += 17
              if n == 17:
                  total += 18
              if n == 18:
                  total += 19
              if n == 19:
                  total += 20
              return total
          
        • hot.py 932 B
          """Deliberately high cyclomatic-complexity file.
          
          One function, 20 `if` decision points -> file-aggregate CCN 21 (lizard).
          Used by the golden-SVG test to assert ccn -> red hue.
          """
          
          
          def classify(n):
              total = 0
              if n == 0:
                  total += 1
              if n == 1:
                  total += 2
              if n == 2:
                  total += 3
              if n == 3:
                  total += 4
              if n == 4:
                  total += 5
              if n == 5:
                  total += 6
              if n == 6:
                  total += 7
              if n == 7:
                  total += 8
              if n == 8:
                  total += 9
              if n == 9:
                  total += 10
              if n == 10:
                  total += 11
              if n == 11:
                  total += 12
              if n == 12:
                  total += 13
              if n == 13:
                  total += 14
              if n == 14:
                  total += 15
              if n == 15:
                  total += 16
              if n == 16:
                  total += 17
              if n == 17:
                  total += 18
              if n == 18:
                  total += 19
              if n == 19:
                  total += 20
              return total
          
        • README.md 2.3 KB
          # golden-svg-repo — complexity-treemap golden fixture
          
          Tiny, deterministic source tree with **known** cyclomatic complexity, consumed
          by `tests/test_golden_svg_render.py` to lock the code-heatmap colour mapping
          (`ccn -> hue`, `churn -> saturation`) and the SVG accessibility metadata.
          
          The test copies these files into a temp dir, synthesizes a git history
          (committing `hot.py` and `simple_active.py` several extra times to create
          churn), then runs the **real** renderer (`scripts/complexity-treemap.py` via
          `uv run --script`, no matplotlib stubbing) and parses the produced SVG.
          
          ## Files and their measured values (lizard)
          
          | File                | file-aggregate CCN | git churn | role in the test                        |
          |---------------------|-------------------:|----------:|-----------------------------------------|
          | `hot.py`            | 21                 | high (5)  | high CCN + high churn -> vivid dark red |
          | `complex_stable.py` | 21                 | low (1)   | high CCN + low churn -> desaturated red  |
          | `simple_active.py`  | 2                  | high (5)  | low CCN + high churn -> pale, saturated |
          | `simple_stable.py`  | 2                  | low (1)   | low CCN + low churn -> pale grey        |
          
          `hot.py` and `complex_stable.py` are byte-identical in their scored function
          (one function, 20 `if` decision points -> CCN 21), so their **base hue is
          identical**; only their churn differs. That isolates the `churn -> saturation`
          axis (compare the two) from the `ccn -> hue` axis (compare `hot.py` against the
          equally-churned `simple_active.py`).
          
          ## Expected fill colours (golden values)
          
          Deterministic from the OrRd colormap + the cap/blend maths (cap = max of data;
          2-file complexity pair caps CCN at 21, churn at 5):
          
          | File                | fill      | RGB             | chroma (max-min) |
          |---------------------|-----------|-----------------|-----------------:|
          | `hot.py`            | `#7f0000` | (127,   0,   0) | 127 (vivid)      |
          | `complex_stable.py` | `#c0a7ab` | (192, 167, 171) | 25 (grey-blended)|
          | `simple_active.py`  | `#fddbad` | (253, 219, 173) | 80               |
          | `simple_stable.py`  | `#d9d3ce` | (217, 211, 206) | 11               |
          
          If the colour-mapping logic changes, these fills move and the golden test
          fails - update this table and the test together, deliberately.
          
        • simple_active.py 252 B
          """Deliberately low cyclomatic-complexity file.
          
          Two branch-free functions -> CCN 1 each, file-aggregate CCN 2 (lizard).
          Used by the golden-SVG test to assert low ccn -> pale hue.
          """
          
          
          def add(a, b):
              return a + b
          
          
          def mul(a, b):
              return a * b
          
        • simple_stable.py 252 B
          """Deliberately low cyclomatic-complexity file.
          
          Two branch-free functions -> CCN 1 each, file-aggregate CCN 2 (lizard).
          Used by the golden-SVG test to assert low ccn -> pale hue.
          """
          
          
          def add(a, b):
              return a + b
          
          
          def mul(a, b):
              return a * b
          
      • hollow_test_repo
        • src
          • processor.py 753 B
            """Tiny line processor shared verbatim by the hollow and honest fixture repos.
            
            The two repos differ ONLY in their test file: the hollow repo asserts on the
            private `_last_processed_line` cursor (implementation detail); the honest repo
            asserts on the public `process` return value (the contract). Same source, same
            coverage - the only variable is what the test pins.
            """
            from __future__ import annotations
            
            
            class Processor:
                def __init__(self) -> None:
                    self._last_processed_line = 0
                    self.results: list[str] = []
            
                def process(self, lines: list[str]) -> list[str]:
                    for i, line in enumerate(lines, start=1):
                        self._last_processed_line = i
                        self.results.append(line.upper())
                    return self.results
            
        • tests
          • test_processor.py 557 B
            """Hollow test: pins the implementation, not the contract.
            
            It asserts only on the private `_last_processed_line` cursor. A correct refactor
            that renames or removes the cursor breaks this test; a behavioural regression in
            the public output (wrong case, dropped lines) sails straight past it. This is the
            meridian resume-guard fingerprint that detect_assertion_on_internal flags.
            """
            from src.processor import Processor
            
            
            def test_process_advances_cursor():
                p = Processor()
                p.process(["a", "b", "c", "d", "e"])
                assert p._last_processed_line == 5
            
      • honest_test_repo
        • src
          • processor.py 753 B
            """Tiny line processor shared verbatim by the hollow and honest fixture repos.
            
            The two repos differ ONLY in their test file: the hollow repo asserts on the
            private `_last_processed_line` cursor (implementation detail); the honest repo
            asserts on the public `process` return value (the contract). Same source, same
            coverage - the only variable is what the test pins.
            """
            from __future__ import annotations
            
            
            class Processor:
                def __init__(self) -> None:
                    self._last_processed_line = 0
                    self.results: list[str] = []
            
                def process(self, lines: list[str]) -> list[str]:
                    for i, line in enumerate(lines, start=1):
                        self._last_processed_line = i
                        self.results.append(line.upper())
                    return self.results
            
        • tests
          • test_processor.py 546 B
            """Honest test: pins the contract, not the implementation.
            
            It asserts on the public `process` return value - the observable behaviour the
            caller depends on. A regression in the output is caught; a refactor that keeps
            the output stable is free to change internals. detect_assertion_on_internal must
            NOT flag this: there is no private-field assertion here.
            """
            from src.processor import Processor
            
            
            def test_process_uppercases_lines():
                p = Processor()
                out = p.process(["a", "b", "c", "d", "e"])
                assert out == ["A", "B", "C", "D", "E"]
            
      • lean_with_skills
        • .claude
          • skills
            • go-conventions
              • SKILL.md 366 B
                ---
                name: go-conventions
                description: Go conventions for this repo. TRIGGER when editing any *.go file or Connect-Go handlers.
                ---
                
                # Go Conventions
                
                Use the Connect-Go Content-Type negotiation defaults. Prefer protobuf JSON
                timestamps over custom formats because the wire contract stays portable.
                
                Default to table-driven tests. Run `go test ./...` before pushing.
                
            • java-conventions
              • SKILL.md 374 B
                ---
                name: java-conventions
                description: Java conventions for this repo. TRIGGER when editing any *.java file, JUnit tests, or Maven modules.
                ---
                
                # Java Conventions
                
                Use Log4j2 for logging. Prefer constructor injection over field injection
                because it makes dependencies explicit and testable.
                
                Default to JUnit5 with AssertJ for assertions. Run `mvn verify` before pushing.
                
        • CLAUDE.md 1000 B
          # CLAUDE.md
          
          Lean in-repo contract. Topic-specific guidance is factored into on-demand
          skills so the agent only loads what is relevant - progressive disclosure
          keeps this file short and the context window focused.
          
          ## Scope
          
          Repo-specific rules only. Global conventions live in the user's config and
          aren't repeated here.
          
          ## Conventions by topic
          
          - Java conventions (logging, dependency injection, testing) load on demand via
            the `java-conventions` skill.
          - Go conventions (Connect-Go Content-Type, protobuf JSON) load on demand via
            the `go-conventions` skill.
          
          Each skill lives under `.claude/skills/<name>/SKILL.md` and is loaded when the
          router matches its trigger. Prefer adding a new skill over inlining a wall of
          text here, because a lean pointer file beats a monolith the agent must hold in
          full context.
          
          ## Verifiable outcomes
          
          - Working if: `pytest` passes from the repo root.
          - Run the linter in tools/lint.sh before pushing.
          
          ## Where things live
          
          - Source: src/app/
          - Tests: tests/
          
      • maven_project
        • dependency-analyze-output.txt 794 B
          [INFO] Scanning for projects...
          [INFO]
          [INFO] -------------< com.example.assess:synthetic-maven-fixture >-------------
          [INFO] Building synthetic-maven-fixture 1.0.0
          [INFO] --------------------------------[ jar ]---------------------------------
          [INFO]
          [INFO] --- maven-dependency-plugin:3.6.1:analyze (default-cli) @ synthetic-maven-fixture ---
          [WARNING] Used undeclared dependencies found:
          [WARNING]    org.slf4j:slf4j-api:jar:2.0.12:compile
          [WARNING] Unused declared dependencies found:
          [WARNING]    org.apache.commons:commons-lang3:jar:3.14.0:compile
          [WARNING]    com.google.guava:guava:jar:33.0.0-jre:compile
          [INFO] ------------------------------------------------------------------------
          [INFO] BUILD SUCCESS
          [INFO] ------------------------------------------------------------------------
          
        • pom.xml 2.1 KB · in bundle
        • Sample.java 240 B · in bundle
      • structure_drift
        • ARCHITECTURE_modules.md 271 B
          # Architecture
          
          Boundary-declaring doc consumed as test data by the structure_drift suite. One
          section names a live directory; one names a directory the filesystem has lost.
          
          ## API layer
          
          The API module owns `src/api/`.
          
          ## Legacy
          
          The legacy module owns `src/legacy/`.
          
        • codeowners_empty 242 B · in bundle
        • codeowners_mixed 201 B · in bundle
      • .gitkeep 0 B · in bundle
      • bad_instructions.md 210 B
        # Project Guidelines
        
        Write clean code. Follow best practices. Be careful with security.
        
        Don't write bad code. Don't break things. Don't make mistakes.
        
        Use good libraries. Test your code. Be a good engineer.
        
      • coverage.xml 855 B · in bundle
      • current_stats.json 595 B
        {
          "files_scored": 105,
          "loc": {"p50": 52, "p95": 410, "max": 1300},
          "ccn": {"p50": 3, "p95": 13, "max": 48},
          "top_hotspots": [
            {"path": "src/api/handler.go", "loc": 700, "ccn": 32, "commits": 15},
            {"path": "src/new/feature.go", "loc": 500, "ccn": 22, "commits": 6},
            {"path": "src/util/helpers.go", "loc": 400, "ccn": 18, "commits": 5}
          ],
          "top_complex": [
            {"path": "src/api/handler.go", "ccn": 32},
            {"path": "src/new/feature.go", "ccn": 22}
          ],
          "top_large": [
            {"path": "src/api/handler.go", "loc": 700},
            {"path": "src/new/feature.go", "loc": 500}
          ]
        }
        
      • good_instructions.md 664 B
        # Project Guidelines
        
        ## Approach
        
        - Use `bcrypt` for password hashing because timing-safe comparison matters.
        - Prefer Postgres over SQLite for production: we need concurrent writers.
        - Default to constructor injection in `src/auth/` over field injection.
        
        ## When editing `src/payments/processor.py`
        
        - Match the existing transaction pattern in `src/payments/refund.py`.
        - Add the corresponding test in `tests/payments/test_processor.py`.
        - The reconciler runs every 5 minutes; idempotency is required.
        
        ## Working if
        
        - Diffs touch only files mentioned in the task.
        - New code follows the patterns in `src/auth/login.py`.
        - Tests run green before opening a PR.
        
      • lcov.info 113 B · in bundle
      • monolithic_instructions.md 52.4 KB
        # Engineering Handbook
        
        This document inlines every convention the team follows. It is the
        single source of truth and must be read in full before contributing.
        
        ## Authentication
        
        Use the standard authentication approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/authentication/core.py.
        Run the authentication checks in tests/authentication/ before pushing.
        
        ### Authentication - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/authentication_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/authentication_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/authentication_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/authentication_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/authentication_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/authentication_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/authentication_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/authentication_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/authentication_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/authentication_10.yaml for the canonical example and match its structure exactly.
        
        ### Authentication - Examples
        
        ```
        example_authentication_1() -> applies the rule above
        example_authentication_2() -> applies the rule above
        example_authentication_3() -> applies the rule above
        example_authentication_4() -> applies the rule above
        example_authentication_5() -> applies the rule above
        example_authentication_6() -> applies the rule above
        ```
        
        ## Database Access
        
        Use the standard database access approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/database_access/core.py.
        Run the database access checks in tests/database_access/ before pushing.
        
        ### Database Access - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/database_access_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/database_access_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/database_access_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/database_access_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/database_access_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/database_access_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/database_access_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/database_access_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/database_access_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/database_access_10.yaml for the canonical example and match its structure exactly.
        
        ### Database Access - Examples
        
        ```
        example_database_access_1() -> applies the rule above
        example_database_access_2() -> applies the rule above
        example_database_access_3() -> applies the rule above
        example_database_access_4() -> applies the rule above
        example_database_access_5() -> applies the rule above
        example_database_access_6() -> applies the rule above
        ```
        
        ## Caching Strategy
        
        Use the standard caching strategy approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/caching_strategy/core.py.
        Run the caching strategy checks in tests/caching_strategy/ before pushing.
        
        ### Caching Strategy - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/caching_strategy_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/caching_strategy_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/caching_strategy_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/caching_strategy_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/caching_strategy_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/caching_strategy_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/caching_strategy_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/caching_strategy_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/caching_strategy_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/caching_strategy_10.yaml for the canonical example and match its structure exactly.
        
        ### Caching Strategy - Examples
        
        ```
        example_caching_strategy_1() -> applies the rule above
        example_caching_strategy_2() -> applies the rule above
        example_caching_strategy_3() -> applies the rule above
        example_caching_strategy_4() -> applies the rule above
        example_caching_strategy_5() -> applies the rule above
        example_caching_strategy_6() -> applies the rule above
        ```
        
        ## Error Handling
        
        Use the standard error handling approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/error_handling/core.py.
        Run the error handling checks in tests/error_handling/ before pushing.
        
        ### Error Handling - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/error_handling_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/error_handling_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/error_handling_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/error_handling_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/error_handling_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/error_handling_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/error_handling_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/error_handling_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/error_handling_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/error_handling_10.yaml for the canonical example and match its structure exactly.
        
        ### Error Handling - Examples
        
        ```
        example_error_handling_1() -> applies the rule above
        example_error_handling_2() -> applies the rule above
        example_error_handling_3() -> applies the rule above
        example_error_handling_4() -> applies the rule above
        example_error_handling_5() -> applies the rule above
        example_error_handling_6() -> applies the rule above
        ```
        
        ## Logging
        
        Use the standard logging approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/logging/core.py.
        Run the logging checks in tests/logging/ before pushing.
        
        ### Logging - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/logging_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/logging_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/logging_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/logging_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/logging_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/logging_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/logging_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/logging_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/logging_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/logging_10.yaml for the canonical example and match its structure exactly.
        
        ### Logging - Examples
        
        ```
        example_logging_1() -> applies the rule above
        example_logging_2() -> applies the rule above
        example_logging_3() -> applies the rule above
        example_logging_4() -> applies the rule above
        example_logging_5() -> applies the rule above
        example_logging_6() -> applies the rule above
        ```
        
        ## Configuration
        
        Use the standard configuration approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/configuration/core.py.
        Run the configuration checks in tests/configuration/ before pushing.
        
        ### Configuration - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/configuration_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/configuration_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/configuration_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/configuration_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/configuration_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/configuration_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/configuration_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/configuration_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/configuration_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/configuration_10.yaml for the canonical example and match its structure exactly.
        
        ### Configuration - Examples
        
        ```
        example_configuration_1() -> applies the rule above
        example_configuration_2() -> applies the rule above
        example_configuration_3() -> applies the rule above
        example_configuration_4() -> applies the rule above
        example_configuration_5() -> applies the rule above
        example_configuration_6() -> applies the rule above
        ```
        
        ## Deployment
        
        Use the standard deployment approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/deployment/core.py.
        Run the deployment checks in tests/deployment/ before pushing.
        
        ### Deployment - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/deployment_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/deployment_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/deployment_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/deployment_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/deployment_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/deployment_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/deployment_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/deployment_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/deployment_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/deployment_10.yaml for the canonical example and match its structure exactly.
        
        ### Deployment - Examples
        
        ```
        example_deployment_1() -> applies the rule above
        example_deployment_2() -> applies the rule above
        example_deployment_3() -> applies the rule above
        example_deployment_4() -> applies the rule above
        example_deployment_5() -> applies the rule above
        example_deployment_6() -> applies the rule above
        ```
        
        ## Testing
        
        Use the standard testing approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/testing/core.py.
        Run the testing checks in tests/testing/ before pushing.
        
        ### Testing - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/testing_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/testing_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/testing_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/testing_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/testing_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/testing_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/testing_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/testing_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/testing_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/testing_10.yaml for the canonical example and match its structure exactly.
        
        ### Testing - Examples
        
        ```
        example_testing_1() -> applies the rule above
        example_testing_2() -> applies the rule above
        example_testing_3() -> applies the rule above
        example_testing_4() -> applies the rule above
        example_testing_5() -> applies the rule above
        example_testing_6() -> applies the rule above
        ```
        
        ## Code Style
        
        Use the standard code style approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/code_style/core.py.
        Run the code style checks in tests/code_style/ before pushing.
        
        ### Code Style - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/code_style_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/code_style_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/code_style_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/code_style_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/code_style_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/code_style_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/code_style_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/code_style_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/code_style_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/code_style_10.yaml for the canonical example and match its structure exactly.
        
        ### Code Style - Examples
        
        ```
        example_code_style_1() -> applies the rule above
        example_code_style_2() -> applies the rule above
        example_code_style_3() -> applies the rule above
        example_code_style_4() -> applies the rule above
        example_code_style_5() -> applies the rule above
        example_code_style_6() -> applies the rule above
        ```
        
        ## Dependency Management
        
        Use the standard dependency management approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/dependency_management/core.py.
        Run the dependency management checks in tests/dependency_management/ before pushing.
        
        ### Dependency Management - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/dependency_management_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/dependency_management_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/dependency_management_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/dependency_management_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/dependency_management_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/dependency_management_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/dependency_management_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/dependency_management_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/dependency_management_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/dependency_management_10.yaml for the canonical example and match its structure exactly.
        
        ### Dependency Management - Examples
        
        ```
        example_dependency_management_1() -> applies the rule above
        example_dependency_management_2() -> applies the rule above
        example_dependency_management_3() -> applies the rule above
        example_dependency_management_4() -> applies the rule above
        example_dependency_management_5() -> applies the rule above
        example_dependency_management_6() -> applies the rule above
        ```
        
        ## API Design
        
        Use the standard api design approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/api_design/core.py.
        Run the api design checks in tests/api_design/ before pushing.
        
        ### API Design - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/api_design_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/api_design_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/api_design_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/api_design_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/api_design_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/api_design_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/api_design_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/api_design_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/api_design_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/api_design_10.yaml for the canonical example and match its structure exactly.
        
        ### API Design - Examples
        
        ```
        example_api_design_1() -> applies the rule above
        example_api_design_2() -> applies the rule above
        example_api_design_3() -> applies the rule above
        example_api_design_4() -> applies the rule above
        example_api_design_5() -> applies the rule above
        example_api_design_6() -> applies the rule above
        ```
        
        ## Concurrency
        
        Use the standard concurrency approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/concurrency/core.py.
        Run the concurrency checks in tests/concurrency/ before pushing.
        
        ### Concurrency - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/concurrency_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/concurrency_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/concurrency_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/concurrency_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/concurrency_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/concurrency_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/concurrency_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/concurrency_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/concurrency_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/concurrency_10.yaml for the canonical example and match its structure exactly.
        
        ### Concurrency - Examples
        
        ```
        example_concurrency_1() -> applies the rule above
        example_concurrency_2() -> applies the rule above
        example_concurrency_3() -> applies the rule above
        example_concurrency_4() -> applies the rule above
        example_concurrency_5() -> applies the rule above
        example_concurrency_6() -> applies the rule above
        ```
        
        ## Security
        
        Use the standard security approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/security/core.py.
        Run the security checks in tests/security/ before pushing.
        
        ### Security - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/security_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/security_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/security_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/security_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/security_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/security_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/security_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/security_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/security_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/security_10.yaml for the canonical example and match its structure exactly.
        
        ### Security - Examples
        
        ```
        example_security_1() -> applies the rule above
        example_security_2() -> applies the rule above
        example_security_3() -> applies the rule above
        example_security_4() -> applies the rule above
        example_security_5() -> applies the rule above
        example_security_6() -> applies the rule above
        ```
        
        ## Performance
        
        Use the standard performance approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/performance/core.py.
        Run the performance checks in tests/performance/ before pushing.
        
        ### Performance - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/performance_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/performance_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/performance_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/performance_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/performance_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/performance_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/performance_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/performance_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/performance_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/performance_10.yaml for the canonical example and match its structure exactly.
        
        ### Performance - Examples
        
        ```
        example_performance_1() -> applies the rule above
        example_performance_2() -> applies the rule above
        example_performance_3() -> applies the rule above
        example_performance_4() -> applies the rule above
        example_performance_5() -> applies the rule above
        example_performance_6() -> applies the rule above
        ```
        
        ## Migrations
        
        Use the standard migrations approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/migrations/core.py.
        Run the migrations checks in tests/migrations/ before pushing.
        
        ### Migrations - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/migrations_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/migrations_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/migrations_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/migrations_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/migrations_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/migrations_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/migrations_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/migrations_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/migrations_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/migrations_10.yaml for the canonical example and match its structure exactly.
        
        ### Migrations - Examples
        
        ```
        example_migrations_1() -> applies the rule above
        example_migrations_2() -> applies the rule above
        example_migrations_3() -> applies the rule above
        example_migrations_4() -> applies the rule above
        example_migrations_5() -> applies the rule above
        example_migrations_6() -> applies the rule above
        ```
        
        ## Monitoring
        
        Use the standard monitoring approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/monitoring/core.py.
        Run the monitoring checks in tests/monitoring/ before pushing.
        
        ### Monitoring - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/monitoring_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/monitoring_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/monitoring_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/monitoring_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/monitoring_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/monitoring_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/monitoring_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/monitoring_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/monitoring_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/monitoring_10.yaml for the canonical example and match its structure exactly.
        
        ### Monitoring - Examples
        
        ```
        example_monitoring_1() -> applies the rule above
        example_monitoring_2() -> applies the rule above
        example_monitoring_3() -> applies the rule above
        example_monitoring_4() -> applies the rule above
        example_monitoring_5() -> applies the rule above
        example_monitoring_6() -> applies the rule above
        ```
        
        ## Build System
        
        Use the standard build system approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/build_system/core.py.
        Run the build system checks in tests/build_system/ before pushing.
        
        ### Build System - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/build_system_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/build_system_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/build_system_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/build_system_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/build_system_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/build_system_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/build_system_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/build_system_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/build_system_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/build_system_10.yaml for the canonical example and match its structure exactly.
        
        ### Build System - Examples
        
        ```
        example_build_system_1() -> applies the rule above
        example_build_system_2() -> applies the rule above
        example_build_system_3() -> applies the rule above
        example_build_system_4() -> applies the rule above
        example_build_system_5() -> applies the rule above
        example_build_system_6() -> applies the rule above
        ```
        
        ## Release Process
        
        Use the standard release process approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/release_process/core.py.
        Run the release process checks in tests/release_process/ before pushing.
        
        ### Release Process - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/release_process_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/release_process_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/release_process_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/release_process_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/release_process_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/release_process_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/release_process_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/release_process_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/release_process_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/release_process_10.yaml for the canonical example and match its structure exactly.
        
        ### Release Process - Examples
        
        ```
        example_release_process_1() -> applies the rule above
        example_release_process_2() -> applies the rule above
        example_release_process_3() -> applies the rule above
        example_release_process_4() -> applies the rule above
        example_release_process_5() -> applies the rule above
        example_release_process_6() -> applies the rule above
        ```
        
        ## Documentation
        
        Use the standard documentation approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/documentation/core.py.
        Run the documentation checks in tests/documentation/ before pushing.
        
        ### Documentation - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/documentation_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/documentation_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/documentation_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/documentation_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/documentation_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/documentation_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/documentation_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/documentation_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/documentation_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/documentation_10.yaml for the canonical example and match its structure exactly.
        
        ### Documentation - Examples
        
        ```
        example_documentation_1() -> applies the rule above
        example_documentation_2() -> applies the rule above
        example_documentation_3() -> applies the rule above
        example_documentation_4() -> applies the rule above
        example_documentation_5() -> applies the rule above
        example_documentation_6() -> applies the rule above
        ```
        
        ## Git Workflow
        
        Use the standard git workflow approach because consistency reduces review friction.
        Prefer explicit configuration over implicit defaults rather than relying on magic.
        Default to the patterns established in src/git_workflow/core.py.
        Run the git workflow checks in tests/git_workflow/ before pushing.
        
        ### Git Workflow - Rationale
        
        Rule 1: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/git_workflow_1.yaml for the canonical example and match its structure exactly.
        Rule 2: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/git_workflow_2.yaml for the canonical example and match its structure exactly.
        Rule 3: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/git_workflow_3.yaml for the canonical example and match its structure exactly.
        Rule 4: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/git_workflow_4.yaml for the canonical example and match its structure exactly.
        Rule 5: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/git_workflow_5.yaml for the canonical example and match its structure exactly.
        Rule 6: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/git_workflow_6.yaml for the canonical example and match its structure exactly.
        Rule 7: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/git_workflow_7.yaml for the canonical example and match its structure exactly.
        Rule 8: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/git_workflow_8.yaml for the canonical example and match its structure exactly.
        Rule 9: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/git_workflow_9.yaml for the canonical example and match its structure exactly.
        Rule 10: choose the documented option instead of an ad-hoc one because the team has agreed on a single path. See config/git_workflow_10.yaml for the canonical example and match its structure exactly.
        
        ### Git Workflow - Examples
        
        ```
        example_git_workflow_1() -> applies the rule above
        example_git_workflow_2() -> applies the rule above
        example_git_workflow_3() -> applies the rule above
        example_git_workflow_4() -> applies the rule above
        example_git_workflow_5() -> applies the rule above
        example_git_workflow_6() -> applies the rule above
        ```
        
        
      • mutmut-junitxml.xml 1.1 KB · in bundle
      • prior_stats.json 601 B
        {
          "files_scored": 100,
          "loc": {"p50": 50, "p95": 400, "max": 1200},
          "ccn": {"p50": 3, "p95": 12, "max": 45},
          "top_hotspots": [
            {"path": "src/legacy/parser.go", "loc": 800, "ccn": 35, "commits": 12},
            {"path": "src/api/handler.go", "loc": 600, "ccn": 28, "commits": 8},
            {"path": "src/util/helpers.go", "loc": 400, "ccn": 18, "commits": 5}
          ],
          "top_complex": [
            {"path": "src/legacy/parser.go", "ccn": 35},
            {"path": "src/api/handler.go", "ccn": 28}
          ],
          "top_large": [
            {"path": "src/legacy/parser.go", "loc": 800},
            {"path": "src/api/handler.go", "loc": 600}
          ]
        }
        
    • conftest.py 3.7 KB
      """Shared pytest fixtures.
      
      Hermetic git. The fixtures below build throwaway git repos and assert on
      commit *metadata* (authorship, dates, coupling), never on signatures. A git
      subprocess inherits the ambient global/system config, and some environments
      enable commit signing there (``commit.gpgsign=true`` with an SSH/GPG signing
      program, e.g. Claude Code's web sandbox). Signing then fails inside the
      disposable test repos and ``git commit`` exits 128 — breaking the whole
      git-backed suite in any signing environment while CI (which doesn't sign)
      stays green. We neutralise ambient git config for the whole test process at
      import time (before any fixture builds a repo) by pointing GIT_CONFIG_GLOBAL
      / GIT_CONFIG_SYSTEM at the null device. Each repo still sets its own local
      identity, so commits resolve an author/committer with no global config.
      """
      from __future__ import annotations
      
      import datetime as _dt
      import os
      import subprocess
      from pathlib import Path
      
      import pytest
      
      # Applied at import time so it is in effect before any module/session-scoped
      # fixture creates a repo. See the module docstring for the why.
      os.environ["GIT_CONFIG_GLOBAL"] = os.devnull
      os.environ["GIT_CONFIG_SYSTEM"] = os.devnull
      os.environ["GIT_CONFIG_NOSYSTEM"] = "1"
      
      
      @pytest.fixture
      def fixtures_dir() -> Path:
          """Path to tests/fixtures/."""
          return Path(__file__).parent / "fixtures"
      
      
      @pytest.fixture
      def tmp_assess_dir(tmp_path: Path) -> Path:
          """A clean .assess/ directory in a temp location."""
          assess_dir = tmp_path / ".assess"
          assess_dir.mkdir()
          (assess_dir / "hotspots").mkdir()
          return assess_dir
      
      
      def _git(repo: Path, *args: str, env: dict | None = None) -> None:
          full_env = {**os.environ, **(env or {})}
          subprocess.run(["git", "-C", str(repo), *args],
                         check=True, capture_output=True, text=True, env=full_env)
      
      
      @pytest.fixture
      def git_repo(tmp_path: Path):
          """Create an initialised git repo and return (repo_path, commit_fn).
      
          commit_fn(message, days_ago=None, committer_days_ago=None) stages everything
          and commits; pass an integer `days_ago` to backdate both author and committer
          time, which lets a test simulate a stale doc beside churning code. Pass
          `committer_days_ago` to backdate the committer time independently of the
          author time - a rebase/cherry-pick keeps the original author time but stamps a
          fresh committer time, so this lets a test prove staleness reads from author
          time (`%at`), not committer time (`%ct`). Git's date env vars reject relative
          strings ("500 days ago"), so we convert to a strict ISO timestamp.
          """
          repo = tmp_path / "repo"
          repo.mkdir()
          _git(repo, "init", "-q")
          _git(repo, "config", "user.email", "test@example.com")
          _git(repo, "config", "user.name", "Test")
      
          def _stamp(days_ago: int) -> str:
              when = _dt.datetime.now() - _dt.timedelta(days=days_ago)
              return when.strftime("%Y-%m-%dT%H:%M:%S")
      
          def commit(
              message: str,
              days_ago: int | None = None,
              committer_days_ago: int | None = None,
          ) -> None:
              _git(repo, "add", "-A")
              env = {}
              if days_ago is not None:
                  author_stamp = _stamp(days_ago)
                  # Committer time defaults to author time; override it to simulate a
                  # rebase/cherry-pick, where the commit is re-applied "now" but keeps
                  # its original author date.
                  committer_at = committer_days_ago if committer_days_ago is not None else days_ago
                  env = {
                      "GIT_AUTHOR_DATE": author_stamp,
                      "GIT_COMMITTER_DATE": _stamp(committer_at),
                  }
              _git(repo, "commit", "-q", "-m", message, env=env)
      
          return repo, commit
      
    • golden.py 4.5 KB
      """Golden-baseline normalization for /assess dogfood parity tests.
      
      Phase 0 of the `assess-dogfooded` work captures a full `/assess` run against
      this repo as a regression baseline. The decomposition work (Part 3) must prove
      the decomposed pipeline reproduces the *same* deterministic output the monolith
      produces today - a byte-for-byte parity test against the captured golden.
      
      But a raw `run-context.json` / `assess-report.md` carries fields that change on
      every commit and every version bump - the plugin version, the run date, the
      measured commit, and the cross-run diff (which depends on whatever prior stats
      sidecar happened to be on disk). Comparing those verbatim would make the parity
      test fail for reasons unrelated to the decomposition.
      
      So the golden fixtures are stored *normalized*: every volatile field is replaced
      with the sentinel below. A parity test regenerates `run-context.json`, runs it
      through `normalize_run_context`, and compares against the stored golden - which
      was itself produced by this same function. Same transform on both sides → the
      only differences that can fail the test are real divergences in the
      deterministic computation, which is exactly what Part 3 must not introduce.
      
      Keep this module dependency-free (stdlib only) so it imports cleanly in any test
      environment, mirroring the deterministic-core contract.
      """
      from __future__ import annotations
      
      import copy
      import json
      import re
      from pathlib import Path
      from typing import Any
      
      #: Replaces every volatile scalar. A string sentinel (not ``None``) so a
      #: normalized field stays type-stable and visibly intentional in a diff.
      SENTINEL = "<<normalized>>"
      
      #: Top-level keys whose entire value is environment- or run-dependent and is
      #: therefore replaced wholesale. ``diff`` / ``diff_detail`` depend on which
      #: prior stats sidecar was on disk; the version/date keys move every release.
      _VOLATILE_TOP_LEVEL = (
          "plugin_version",
          "prior_plugin_version",
          "run_date",
          # run_id is a fresh timestamp+uuid every run (schema_version is stable, so it
          # stays comparable and is NOT masked).
          "run_id",
          "diff",
          "diff_detail",
          "diff_reliable",
          "diff_version_note",
          "prior_stats_exists",
      )
      
      #: Sub-keys of ``measured_commit`` that identify the specific commit/work-tree
      #: state. ``available`` is preserved (structural), the rest are normalized.
      _VOLATILE_MEASURED_COMMIT = (
          "head_sha",
          "head_short",
          "committed_date",
          "subject",
          "dirty",
          "upstream",
          "behind",
      )
      
      
      def normalize_run_context(ctx: dict[str, Any]) -> dict[str, Any]:
          """Return a copy of a ``run-context.json`` dict with volatile fields masked.
      
          Pure: the input is deep-copied, never mutated. Missing keys are tolerated
          (the schema evolves), so an absent volatile key is simply skipped rather
          than synthesized - the golden then carries no entry for it either.
          """
          out = copy.deepcopy(ctx)
      
          for key in _VOLATILE_TOP_LEVEL:
              if key in out:
                  out[key] = SENTINEL
      
          mc = out.get("measured_commit")
          if isinstance(mc, dict):
              for key in _VOLATILE_MEASURED_COMMIT:
                  if key in mc:
                      mc[key] = SENTINEL
      
          return out
      
      
      #: The "Generated ..." provenance line at the top of a report. Captures the
      #: date and plugin version, both volatile.
      _REPORT_GENERATED_RE = re.compile(
          r"^_Generated .*?\._$", re.MULTILINE
      )
      #: The "Measured at commit" bullet pins absolute figures to a commit + date.
      _REPORT_MEASURED_COMMIT_RE = re.compile(
          r"^- \*\*Measured at commit:\*\* .*$", re.MULTILINE
      )
      
      
      def normalize_report(text: str) -> str:
          """Return an ``assess-report.md`` string with volatile lines masked.
      
          Only the two provenance lines (the ``_Generated ..._`` stamp and the
          ``Measured at commit`` bullet) carry the date/version/commit; the rest of
          the report is deterministic given the deterministic core's output, so it is
          compared verbatim.
          """
          text = _REPORT_GENERATED_RE.sub(f"_Generated {SENTINEL}._", text)
          text = _REPORT_MEASURED_COMMIT_RE.sub(
              f"- **Measured at commit:** {SENTINEL}", text
          )
          return text
      
      
      def load_golden_run_context() -> dict[str, Any]:
          """Load the normalized golden ``run-context`` baseline."""
          path = Path(__file__).parent / "fixtures" / "golden" / "run-context-baseline.json"
          return json.loads(path.read_text())
      
      
      def load_golden_report() -> str:
          """Load the normalized golden ``assess-report`` baseline."""
          path = Path(__file__).parent / "fixtures" / "golden" / "assess-report-baseline.md"
          return path.read_text()
      
    • README.md 5.3 KB
      # skills/assess/tests
      
      Test suites for the `/assess` deterministic engine. Tests live here, co-located with
      the scripts they pin. When a source module changes, its test file is expected to change
      in the same commit - this is intentional co-change, not accidental coupling.
      
      ## Test/source co-change seam
      
      The suites below are expected to move with the engine. A reviewer seeing
      `test_doc_graph.py` and `lib/doc_graph.py` in the same diff is looking at the normal
      edit cycle, not a layering violation. Keep tests co-located.
      
      The two highest-frequency co-change pairs in the git history are:
      
      - **`test_assess_core.py` / `assess_core.py`** - the orchestrator and its end-to-end
        harness. Every time the core adds a signal or changes the `run-context.json` schema,
        both files move.
      - **`test_doc_graph.py` / `lib/doc_graph.py`** - the navigability graph is the
        foundation for Layer 0 and feeds both the staleness and the understanding analysis,
        so its contract tests are touched on most doc-analysis changes.
      
      ---
      
      ## Suite / source mapping
      
      ### Orchestrator suites
      
      | Suite | Pins |
      |---|---|
      | `test_assess_core.py` | `scripts/assess_core.py` - end-to-end orchestrator; drives `build_run_context` without running lizard/scc |
      | `test_assess_finalize.py` | `scripts/assess_finalize.py` - LLM write-back; placeholder substitution in `log.md` and `hotspots/*.md` |
      | `test_assess_gate.py` | `scripts/assess_gate.py` - CI regression gate; complexity and containment threshold checks and exit codes |
      | `test_assess_report.py` | `scripts/assess_report.py` - deterministic report renderer; template substitution, section renderers, conditional fallbacks |
      | `test_emit_workflow.py` | `scripts/assess_emit_workflow.py` - CLI wrapper for the frozen-harness workflow emitter; default derivation, arg parsing, path filters and the path-filter default |
      | `test_decomposition_parity.py` | `scripts/assess_core.py` + `scripts/assess_report.py` - parity harness; guards that the deterministic pipeline produces byte-for-byte identical output after the Part 3 SKILL.md decomposition |
      | `test_complexity_treemap.py` | `scripts/complexity-treemap.py` - build-artifact filter, plugin version stamp, and stats-sidecar enrichment (heavy deps are stubbed) |
      
      ### lib/ suites
      
      | Suite | Pins |
      |---|---|
      | `test_doc_graph.py` | `lib/doc_graph.py` - doc link-graph, link parsing, orphan detection, connectivity, MOC validation, doc->code edges |
      | `test_keyhole_signals.py` | `lib/keyhole_signals.py` - integration barrier; derivation of the five run-context blocks and the six named derived findings from mocked upstream signal outputs |
      | `test_change_coupling.py` | `lib/change_coupling.py` - B1 change-coupling pairs, B2 containment ratio, B4 authorship; synthetic git histories built in tmp dirs |
      | `test_coupling_analysis.py` | `lib/coupling_analysis.py` - B3 static-vs-historical disagreement; hidden-coupling, bleeding-module, and refactor-boundary classification with mocked inputs |
      | `test_doc_complexity_join.py` | `lib/doc_complexity_join.py` - Signal C: doc_value formula, slop-doc guard, threshold behaviour; mocked complexity-stats and staleness inputs |
      | `test_doc_staleness.py` | `lib/doc_staleness.py` - doc->code association (base-doc, parallel docs/, code links, repo-wide fallback) and churn-relative staleness ratios |
      | `test_structure_graph.py` | `lib/structure_graph.py` - A1 footprint additivity, A2 SCCs and Q range, A3 front-door vs burrow, A4 cut-lines, graceful degradation |
      | `test_understanding_analysis.py` | `lib/understanding_analysis.py` - B4 human anchor + intent source, velocity clock (D2), orphaned-understanding classification; both pure-logic (mocked) and git-integration variants |
      | `test_liveness_scan.py` | `lib/liveness_scan.py` - dead-code tool output parsers, observability rungs, graceful degradation when tools are absent |
      | `test_test_pressure.py` | `lib/test_pressure/` - mutation tier output parsing, cheap heuristics (test/source ratio, assertion density, gap signal) |
      | `test_ci_workflow.py` | `lib/ci_workflow.py` - template substitution (version, branch, tool steps, path filters), path-filtered workflow detection, literal-dollar escaping, YAML well-formedness |
      | `test_stats_diff.py` | `lib/stats_diff.py` - hotspot transition classification (graduated, regressed, new, persistent) and sidecar loading |
      | `test_wiki_writer.py` | `lib/wiki_writer.py` - wiki file rendering (index, log, hotspot pages) and HotspotEntry / LogEntry dataclass behaviour |
      | `test_git_commit_info.py` | `lib/git_churn.py` (`git_commit_info`) - commit snapshot with SHA/timestamp for staleness warnings |
      | `test_instruction_bloat.py` | `lib/agent_instructions_grader.py` - bloat penalty, skills-delegation credit, conservative thresholds |
      
      ### Infrastructure suites
      
      | Suite | Pins |
      |---|---|
      | `test_smoke.py` | `lib/__init__.py` - confirms the lib package is importable and `__version__` is set |
      | `test_golden_baseline.py` | `tests/golden.py` + dogfood fixtures - guards the regression baseline scaffolding (fixture completeness, normalization idempotency, loader correctness) used by `test_decomposition_parity.py` |
      
      ---
      
      ## Running the suite
      
      ```bash
      # From skills/assess/ - avoids ~7 phantom git-commit failures from global git config
      GIT_CONFIG_GLOBAL=/dev/null uv run --with pytest pytest tests/ -v
      ```
      
      The phantom failures are a local-only artifact of global git commit-template or hook
      configuration. They do not appear in CI.
      
    • test_accretion_ratchet.py 30.3 KB
      """Comprehensive contract suite for the accretion-ratchet scanner.
      
      Fixtures build synthetic git histories in tmp dirs; expected values are
      hand-computed in each test's docstring so the contract is auditable. The
      scanner's job is to flag files whose accumulated line count only ever ratchets
      upward - net growth *with almost no deletion pressure* across multiple commits -
      while leaving healthy churn, renames, binaries, and single-touch artifacts
      alone. These tests pin that behaviour and, crucially, the determinism contract:
      the author-time ordering must make the output byte-identical run to run.
      
      Author/committer identity and dates are pinned in every fixture commit so the
      ``%at`` ordering and the resulting net-delta accumulation are reproducible on
      CI. The ambient git config is already neutralised process-wide by the package
      ``conftest.py`` (GIT_CONFIG_GLOBAL/SYSTEM -> /dev/null), so commits never trip
      ambient signing.
      """
      from __future__ import annotations
      
      import json
      import os
      import subprocess
      from pathlib import Path
      
      import lib.accretion_ratchet as ar
      from lib.accretion_ratchet import (
          DELETION_FRACTION_THRESHOLD,
          MIN_COMMITS_FOR_ACCRETION,
          AccretionFile,
          _accumulate_history,
          _build_accretion_file,
          _FileHistory,
          _is_monotonic_nondecreasing,
          _repo_top,
          scan_accretion_ratchet,
      )
      
      # A fixed clock so every commit's author/committer time is deterministic. Each
      # commit advances the clock by one day, which both pins the ``%at`` sort order
      # and gives the time-span readout a known value.
      _BASE_EPOCH = 1_700_000_000  # 2023-11-14T22:13:20Z
      _DAY = 86_400
      
      
      def _git(repo: Path, *args: str, env: dict | None = None) -> None:
          full_env = {**os.environ, **(env or {})}
          subprocess.run(["git", "-C", str(repo), *args],
                         check=True, capture_output=True, text=True, env=full_env)
      
      
      def _init_repo(tmp_path: Path) -> Path:
          repo = tmp_path / "repo"
          repo.mkdir()
          _git(repo, "init", "-q")
          _git(repo, "config", "user.email", "dev@example.com")
          _git(repo, "config", "user.name", "Dev Human")
          return repo
      
      
      class _Clock:
          """Monotone per-commit clock: each call returns the next day's epoch.
      
          Pinning author *and* committer time to the same advancing value makes the
          scanner's ``(author_time, sha)`` sort fully determined by commit order, so
          the accumulation sequence - and the flagged output - is reproducible.
          """
      
          def __init__(self) -> None:
              self._n = 0
      
          def env(self) -> dict[str, str]:
              stamp = f"{_BASE_EPOCH + self._n * _DAY} +0000"
              self._n += 1
              return {
                  "GIT_AUTHOR_DATE": stamp,
                  "GIT_COMMITTER_DATE": stamp,
              }
      
      
      def _commit(repo: Path, rel: str, text: str, clock: _Clock,
                  message: str = "change") -> None:
          """Write ``text`` to ``rel``, stage, and commit at the clock's next tick."""
          p = repo / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", message, env=clock.env())
      
      
      def _lines(n: int, *, start: int = 0, width: int = 12) -> str:
          """A file body of ``n`` distinct lines (distinct so numstat counts churn).
      
          Each line is unique and ``width`` chars wide, so replacing the body deletes
          the old lines and adds the new ones - a real numstat delta, not a no-op.
          """
          return "".join(f"L{i + start:0{width}d}\n" for i in range(n))
      
      
      def _flagged_paths(repo: Path, **kw) -> list[str]:
          return [f.path for f in scan_accretion_ratchet(repo, **kw).files]
      
      
      # --- 1. Deletion-fraction discrimination -------------------------------------
      
      def test_deletion_fraction_discriminates_refactor_from_ratchet(tmp_path: Path) -> None:
          """A healthy refactor is spared; a pure ratchet is flagged.
      
          Two files, three commits each (clearing the multi-commit gate):
      
          * ``refactor.py`` - grows then is reworked: 2000 lines added, 1500 deleted
            across its history => deletion fraction 1500 / 3500 = 0.4286, far above the
            0.15 default. Maintained by rewriting, so it is NOT flagged - regardless of
            whether its net drifts up.
          * ``ratchet.py`` - appended to only: 510 added, 10 deleted => 10 / 520 =
            0.0192, well below 0.15, and its running net never falls back. Flagged.
      
          Only the ratchet should surface.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
      
          # ratchet.py: append-only growth. Verified numstat per commit below.
          #   c1: write 200 lines               -> +200 / -0   (net 200)
          #   c2: keep 200, append 200          -> +200 / -0   (net 400)
          #   c3: rewrite 10 lines in place,
          #       append 110 more               -> +120 / -10  (net 510)
          # Totals: 520 added, 10 deleted => fraction 10 / 530 = 0.0189 < 0.15.
          # Running net 200 -> 400 -> 510 is monotonic. Flagged.
          _commit(repo, "ratchet.py", _lines(200), clock)
          _commit(repo, "ratchet.py", _lines(200) + _lines(200, start=200), clock)
          body = (_lines(190) + _lines(10, start=9000)
                  + _lines(200, start=200) + _lines(110, start=400))
          _commit(repo, "ratchet.py", body, clock)
      
          # refactor.py: heavy churn. Grow big, then rewrite large swaths so deletions
          # are a large share of total churn.
          _commit(repo, "refactor.py", _lines(1000), clock)
          _commit(repo, "refactor.py", _lines(500, start=10000) + _lines(500, start=2000), clock)
          _commit(repo, "refactor.py", _lines(1000, start=20000), clock)
      
          scan = scan_accretion_ratchet(repo)
          flagged = {f.path for f in scan.files}
      
          assert "ratchet.py" in flagged
          assert "refactor.py" not in flagged
          # The ratchet's deletion fraction is well under the default threshold.
          ratchet = next(f for f in scan.files if f.path == "ratchet.py")
          assert ratchet.deletion_fraction < DELETION_FRACTION_THRESHOLD
          assert ratchet.net_additions > 0
      
      
      # --- 2. Rename handling ------------------------------------------------------
      
      def test_rename_is_not_flagged_as_accretion(tmp_path: Path) -> None:
          """A create -> rename -> add sequence is not a ratchet.
      
          With ``--no-renames`` (the scanner's mode) a rename reads as a full delete of
          the old path plus a full add of the new path. The old path nets to zero (and
          is filtered: net <= 0). The new path appears in only one commit (the rename
          itself) and would need ``MIN_COMMITS_FOR_ACCRETION`` (3) touches to qualify;
          the single later edit gives it two, still under the gate. So neither path is
          flagged - the rename produces no false positive.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
      
          _commit(repo, "old_name.py", _lines(300), clock, "create")
          # Rename: git mv, content unchanged. Under --no-renames this is del old / add new.
          _git(repo, "mv", "old_name.py", "new_name.py")
          _git(repo, "commit", "-q", "-m", "rename", env=clock.env())
          # One later edit under the new name.
          _commit(repo, "new_name.py", _lines(300) + _lines(50, start=300), clock, "extend")
      
          flagged = _flagged_paths(repo)
          assert "old_name.py" not in flagged
          assert "new_name.py" not in flagged
      
      
      # --- 3. Degenerate history ---------------------------------------------------
      
      def test_squashed_history_is_available_but_unreliable(tmp_path: Path) -> None:
          """One commit touching many files => available True, reliable False.
      
          A squashed import gives every file exactly one commit, so the per-file
          commit-count distribution is flat (p95 == 1) over enough active files
          (>= MIN_ACTIVE_FILES_FOR_DEGENERACY = 5). ``churn_is_degenerate`` returns
          True, so the scan reports ``reliable=False`` while still being ``available``.
          No file clears the 3-commit gate, so nothing is flagged either.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
      
          files = {f"mod/f{i}.py": _lines(100 + i) for i in range(8)}
          for rel, text in files.items():
              p = repo / rel
              p.parent.mkdir(parents=True, exist_ok=True)
              p.write_text(text, encoding="utf-8")
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", "squashed import", env=clock.env())
      
          scan = scan_accretion_ratchet(repo)
          assert scan.available is True
          assert scan.reliable is False
          assert scan.files == []
      
      
      # --- 4. Binary file guard ----------------------------------------------------
      
      def test_binary_files_are_skipped(tmp_path: Path) -> None:
          """A binary file (numstat ``-``) carries no line signal and is not flagged.
      
          Git emits ``-\t-\t<path>`` for binary content; the parser skips rows whose
          add/remove columns are not digits. Even committed repeatedly (clearing the
          multi-commit gate), the binary never accumulates additions, so it can never
          appear in the flagged set. A text file committed alongside it under pure
          accretion still surfaces - proving the binary is skipped, not the whole
          commit.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
      
          def write_binary(rel: str, n: int) -> None:
              # NUL bytes force git to treat the blob as binary (numstat '-').
              (repo / rel).write_bytes(bytes([0, 1, 2, 255] * n))
      
          for i in range(1, 4):
              write_binary("asset.bin", 100 * i)
              # A text file that only grows, committed in the same commits.
              (repo / "grow.py").write_text(_lines(200 * i), encoding="utf-8")
              _git(repo, "add", "-A")
              _git(repo, "commit", "-q", "-m", f"c{i}", env=clock.env())
      
          scan = scan_accretion_ratchet(repo)
          flagged = {f.path for f in scan.files}
          assert "asset.bin" not in flagged
          # The accompanying append-only text file is flagged, so the commits were
          # scanned - the binary was filtered specifically.
          assert "grow.py" in flagged
      
      
      # --- 5. Ordering reproducibility ---------------------------------------------
      
      def test_scan_output_is_byte_identical_across_runs(tmp_path: Path) -> None:
          """Two scans of one repo serialize to byte-identical JSON.
      
          The scanner sorts history on ``(author_time, sha)`` rather than trusting
          git's emission order, so the net-delta accumulation - and the flagged set,
          its order, and every field - must be reproducible. This pins that contract:
          serialize the full summary twice and assert exact equality.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
      
          # A handful of files with varied histories so the flagged set is non-trivial.
          for i in range(1, 5):
              _commit(repo, "a.py", _lines(100 * i), clock)
          for i in range(1, 4):
              _commit(repo, "b.py", _lines(80 * i, start=900), clock)
          for i in range(1, 4):
              # A churny file that should be excluded - keeps the sort non-trivial.
              _commit(repo, "c.py", _lines(50, start=i * 1000), clock)
      
          first = json.dumps(scan_accretion_ratchet(repo).summary(), sort_keys=True)
          second = json.dumps(scan_accretion_ratchet(repo).summary(), sort_keys=True)
          assert first == second
          # And the flagged set is actually populated, so equality isn't vacuous.
          assert scan_accretion_ratchet(repo).files
      
      
      # --- 6. Multi-commit gate ----------------------------------------------------
      
      def test_single_commit_growth_is_not_flagged(tmp_path: Path) -> None:
          """A file that grows large in one commit is below the multi-commit gate.
      
          Accretion is a property of *repeated* growth: a file appearing in fewer than
          ``MIN_COMMITS_FOR_ACCRETION`` (3) commits is filtered, even if that single
          commit adds a thousand lines and deletes nothing (a perfect-looking ratchet
          in miniature). This guards against a one-shot generated file reading as
          accretion. Other files in the repo provide enough history that the scan is
          reliable, isolating the gate as the reason this file is spared.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
      
          # Background history so the repo isn't degenerate (>=5 active, multi-commit).
          for i in range(1, 4):
              _commit(repo, "background.py", _lines(40 * i, start=7000), clock)
          for name in ("p.py", "q.py", "r.py", "s.py"):
              for i in range(1, 4):
                  _commit(repo, name, _lines(20 * i, start=8000), clock)
      
          # The file under test: one commit, large append-only body, zero deletions.
          _commit(repo, "oneshot.py", _lines(1000), clock, "generated in one shot")
      
          flagged = _flagged_paths(repo)
          assert "oneshot.py" not in flagged
      
      
      def test_two_commit_growth_is_not_flagged(tmp_path: Path) -> None:
          """Exactly MIN_COMMITS_FOR_ACCRETION - 1 touches is still below the gate.
      
          The boundary: a monotonically growing, zero-deletion file touched in only
          two commits must not be flagged, while the same pattern at three commits is.
          This pins the gate at its exact threshold.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
      
          # Two-commit grower: must NOT be flagged.
          _commit(repo, "two.py", _lines(100), clock)
          _commit(repo, "two.py", _lines(200), clock)
      
          # Three-commit grower with the same shape: MUST be flagged, proving the gate
          # is the only thing keeping two.py out.
          _commit(repo, "three.py", _lines(100, start=300), clock)
          _commit(repo, "three.py", _lines(200, start=300), clock)
          _commit(repo, "three.py", _lines(300, start=300), clock)
      
          flagged = _flagged_paths(repo)
          assert "two.py" not in flagged
          assert "three.py" in flagged
          assert MIN_COMMITS_FOR_ACCRETION == 3
      
      
      # --- Monotonicity: a late cut clears the flag --------------------------------
      
      def test_late_refactor_clears_the_ratchet(tmp_path: Path) -> None:
          """A file that grows, then is cut back below an earlier high, is not flagged.
      
          Monotonicity is judged off the running net-delta sequence, not the
          endpoints. Three growth commits push net up; a fourth deletes enough to drop
          net below an earlier value. That single step down breaks the ratchet even
          though the file still has a positive net - the deletion pressure the signal
          rewards. (Deletions here also stay a small share of churn, so it is the
          monotonicity test, not the deletion-fraction filter, doing the work.)
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
      
          # Grow: net climbs 300 -> 600 -> 900.
          _commit(repo, "f.py", _lines(300), clock)
          _commit(repo, "f.py", _lines(600), clock)
          _commit(repo, "f.py", _lines(900), clock)
          # Cut: drop to 250 lines. Net falls to 250, below the first commit's 300.
          # This single backward step makes the sequence non-monotonic.
          _commit(repo, "f.py", _lines(250), clock)
      
          # Provide background so the scan is reliable and f.py is the only variable.
          for name in ("x.py", "y.py", "z.py", "w.py"):
              for i in range(1, 4):
                  _commit(repo, name, _lines(15 * i, start=9000), clock)
      
          flagged = _flagged_paths(repo)
          assert "f.py" not in flagged
      
      
      # --- 7. Self-test on the assess repo ----------------------------------------
      
      def test_self_scan_on_assess_repo_is_well_formed(tmp_path: Path) -> None:
          """Scanning this repo's own history produces only well-formed flags.
      
          A dogfood guard run against real, human-maintained history. The repo *does*
          contain files whose net line count has only ever grown (a test suite that
          keeps gaining cases, a reference doc that keeps gaining sections) - that is
          not a false positive, it is the signal working on real data. What must hold
          is that every flagged record honestly satisfies the definition it claims:
          positive net additions, at least ``MIN_COMMITS_FOR_ACCRETION`` commits, and a
          deletion fraction strictly under the threshold the scan reports. A "false
          positive" here would be a record that is flagged while violating its own
          contract - that is what this rules out. The scanner may degrade to
          unavailable in a packaging context with no git history; that is acceptable.
      
          The flagged set must also be deterministically sorted (net descending, path
          ascending) - the same ordering contract the byte-identical test pins, checked
          here against real history rather than a fixture.
          """
          repo_root = Path(__file__).resolve().parents[1]  # skills/assess/
          scan = scan_accretion_ratchet(repo_root)
      
          if not scan.available:
              # No git history reachable (e.g. exported tree) - nothing to assert.
              return
      
          for f in scan.files:
              # Every flagged record must satisfy the contract it claims.
              assert f.net_additions > 0, f
              assert f.commit_count >= MIN_COMMITS_FOR_ACCRETION, f
              assert f.deletion_fraction < scan.deletion_fraction_threshold, f
              assert 0.0 <= f.deletion_fraction < 1.0, f
              assert f.time_span_months >= 0.0, f
      
          # Sorted by net additions descending, then path ascending - the public
          # ordering contract, holding on real history.
          keys = [(-f.net_additions, f.path) for f in scan.files]
          assert keys == sorted(keys)
      
      
      # --- AccretionFile field contract --------------------------------------------
      
      def test_accretion_file_fields_and_to_dict(tmp_path: Path) -> None:
          """A flagged file's fields and ``to_dict`` rounding match the contract.
      
          Five append-only commits over four days (one per clock tick) to a single
          growing file. net_additions is the final additions - deletions; commit_count
          is the touch count; deletion_fraction rounds to 4 dp in ``to_dict`` and
          time_span_months rounds to 1 dp. The span is 4 days (first to fifth commit) /
          30.44 ~= 0.13 months -> rounds to 0.1.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
      
          # Five commits, pure append, no deletions.
          for i in range(1, 6):
              _commit(repo, "grow.py", _lines(100 * i), clock)
          # Background so the scan is reliable.
          for name in ("a.py", "b.py", "c.py", "d.py"):
              for i in range(1, 4):
                  _commit(repo, name, _lines(10 * i, start=5000), clock)
      
          scan = scan_accretion_ratchet(repo)
          grow = next((f for f in scan.files if f.path == "grow.py"), None)
          assert grow is not None
          assert isinstance(grow, AccretionFile)
          assert grow.commit_count == 5
          assert grow.net_additions == 500  # 100..500 added, nothing removed
          assert grow.deletion_fraction == 0.0
      
          d = grow.to_dict()
          assert d["path"] == "grow.py"
          assert d["net_additions"] == 500
          assert d["commit_count"] == 5
          assert d["deletion_fraction"] == 0.0
          # round(x, 1) on the to_dict span: keys present and numeric.
          assert isinstance(d["time_span_months"], float)
      
      
      # --- Threshold sensitivity (as-merged semantics) -----------------------------
      
      def test_caller_threshold_is_honored_end_to_end(tmp_path: Path) -> None:
          """The caller's deletion_threshold is the cut actually applied by the scan.
      
          Builds a file at deletion fraction ~0.20: 400 lines added, 100 deleted
          across the history (100 / 500 = 0.20), monotonic, multi-commit. At the 0.15
          default it is dropped (0.20 >= 0.15). At ``deletion_threshold=0.30`` it is
          admitted (0.20 < 0.30) - and the reported threshold on the scan matches the
          one applied. This is the end-to-end form of the task-1-fix regression: the
          caller's value is honored, not a hard-coded module constant.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
      
          # c1: write 200 lines                 -> +200 / -0   (net 200)
          # c2: keep 200, append 200             -> +200 / -0   (net 400)
          # c3: keep first 300, replace last 100 -> +100 / -100 (net 400, still up)
          # Totals: 500 added, 100 deleted => fraction 100 / 600 = 0.1667, just over the
          # 0.15 default. Running net 200 -> 400 -> 400 is monotonic non-decreasing.
          _commit(repo, "g.py", _lines(200), clock)
          _commit(repo, "g.py", _lines(400), clock)
          body = _lines(300) + _lines(100, start=99000)
          _commit(repo, "g.py", body, clock)
      
          # Background history so the scan is reliable.
          for name in ("h.py", "i.py", "j.py", "k.py"):
              for i in range(1, 4):
                  _commit(repo, name, _lines(10 * i, start=6000), clock)
      
          # Measure g.py's real deletion fraction (threshold 1.0 admits everything)
          # so the assertions can bracket the threshold around it.
          full = scan_accretion_ratchet(repo, deletion_threshold=1.0)
          g_full = next(f for f in full.files if f.path == "g.py")
          frac = g_full.deletion_fraction
          assert frac > DELETION_FRACTION_THRESHOLD  # excluded at the default
      
          # Below the file's fraction -> dropped; the reported threshold is the cut used.
          below = scan_accretion_ratchet(repo, deletion_threshold=frac - 0.01)
          assert below.deletion_fraction_threshold == frac - 0.01
          assert all(f.path != "g.py" for f in below.files)
      
          # Above the file's fraction -> admitted.
          above = scan_accretion_ratchet(repo, deletion_threshold=frac + 0.01)
          assert any(f.path == "g.py" for f in above.files)
      
      
      # --- Availability degrade -----------------------------------------------------
      
      def test_non_git_directory_is_unavailable(tmp_path: Path) -> None:
          """A plain directory (not a git repo) degrades to available=False, never raises."""
          plain = tmp_path / "nogit"
          plain.mkdir()
          (plain / "a.py").write_text(_lines(50), encoding="utf-8")
      
          scan = scan_accretion_ratchet(plain)
          assert scan.available is False
          assert scan.files == []
          assert scan.reason
      
      
      def test_empty_repo_has_no_commit_history(tmp_path: Path) -> None:
          """An initialised repo with zero commits reports 'no commit history'.
      
          ``_accumulate_history`` returns an empty map (git log fails on a repo with no
          HEAD), which the scanner reports as unavailable rather than a clean scan.
          """
          repo = _init_repo(tmp_path)  # init + config, but no commit yet
          scan = scan_accretion_ratchet(repo)
          assert scan.available is False
          assert scan.reason == "no commit history"
      
      
      def test_scan_degrades_when_accumulate_raises(tmp_path: Path, monkeypatch) -> None:
          """An unexpected error inside the pipeline degrades to available=False.
      
          The pipeline is wrapped so the deterministic core never crashes its caller.
          Forcing ``_accumulate_history`` to raise must surface as a degraded scan with
          the exception type in the reason, not a propagated traceback.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
          _commit(repo, "a.py", _lines(10), clock)
      
          def boom(_repo_top: str) -> dict:
              raise RuntimeError("synthetic failure")
      
          monkeypatch.setattr(ar, "_accumulate_history", boom)
          scan = scan_accretion_ratchet(repo)
          assert scan.available is False
          assert "RuntimeError" in scan.reason
          assert "synthetic failure" in scan.reason
      
      
      # --- internal helpers --------------------------------------------------------
      
      def test_repo_top_resolves_and_rejects_non_repo(tmp_path: Path) -> None:
          """``_repo_top`` returns the toplevel inside a repo, None outside one."""
          repo = _init_repo(tmp_path)
          clock = _Clock()
          _commit(repo, "a.py", _lines(3), clock)
          # Resolves to the repo top from a subdirectory.
          sub = repo / "pkg"
          sub.mkdir()
          top = _repo_top(sub)
          assert top is not None
          assert Path(top).resolve() == repo.resolve()
      
          plain = tmp_path / "plain"
          plain.mkdir()
          assert _repo_top(plain) is None
      
      
      def test_is_monotonic_nondecreasing_edge_cases() -> None:
          """Empty / single-point sequences are vacuously monotonic; a dip is not."""
          assert _is_monotonic_nondecreasing([]) is True
          assert _is_monotonic_nondecreasing([5]) is True
          assert _is_monotonic_nondecreasing([1, 1, 2, 3]) is True
          assert _is_monotonic_nondecreasing([1, 2, 1]) is False
      
      
      def test_build_accretion_file_filters() -> None:
          """The promote-to-AccretionFile filters each reject for the documented reason."""
          # Below the multi-commit gate.
          assert _build_accretion_file(
              "f", _FileHistory(additions=100, deletions=0, commit_count=2,
                                net_sequence=[50, 100]), 0.15) is None
          # Zero total churn (no add, no del) - guarded before the division.
          assert _build_accretion_file(
              "f", _FileHistory(additions=0, deletions=0, commit_count=3,
                                net_sequence=[0, 0, 0]), 0.15) is None
          # Deletion fraction at/over threshold.
          assert _build_accretion_file(
              "f", _FileHistory(additions=80, deletions=20, commit_count=3,
                                net_sequence=[20, 40, 60]), 0.15) is None
          # Net <= 0 with a permissive threshold: a file with equal add/delete churn
          # (fraction 0.5) clears a 0.6 threshold but nets to zero, so the net<=0 guard
          # is what rejects it - distinct from the deletion-fraction filter above.
          assert _build_accretion_file(
              "f", _FileHistory(additions=10, deletions=10, commit_count=3,
                                net_sequence=[0, 0, 0]), 0.6) is None
          # Non-monotonic running net (a dip) clears the flag.
          assert _build_accretion_file(
              "f", _FileHistory(additions=300, deletions=10, commit_count=3,
                                net_sequence=[100, 50, 290]), 0.15) is None
          # A clean accretor passes and carries the right fields.
          ok = _build_accretion_file(
              "f", _FileHistory(additions=300, deletions=10, commit_count=3,
                                first_time=_BASE_EPOCH, last_time=_BASE_EPOCH + _DAY,
                                net_sequence=[100, 200, 290]), 0.15)
          assert ok is not None
          assert ok.net_additions == 290
          assert ok.commit_count == 3
      
      
      def test_accumulate_history_skips_binary_and_malformed(tmp_path: Path) -> None:
          """``_accumulate_history`` skips binary rows and never indexes them.
      
          A repo with a binary file and a text file: the returned history map contains
          the text path with real line counts and omits the binary path entirely
          (its numstat rows are ``-``, filtered at parse time).
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
          (repo / "data.bin").write_bytes(bytes([0, 1, 2, 255] * 50))
          (repo / "code.py").write_text(_lines(40), encoding="utf-8")
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", "c1", env=clock.env())
      
          top = _repo_top(repo)
          assert top is not None
          hist = _accumulate_history(top)
          assert "code.py" in hist
          assert hist["code.py"].additions == 40
          assert "data.bin" not in hist
      
      
      def test_accumulate_history_on_non_repo_returns_empty(tmp_path: Path) -> None:
          """git log failing (not a repo path) yields an empty history map, no raise."""
          plain = tmp_path / "plain"
          plain.mkdir()
          assert _accumulate_history(str(plain)) == {}
      
      
      def test_accumulate_history_skips_malformed_records(monkeypatch) -> None:
          """Malformed commit records are skipped, not allowed to corrupt the map.
      
          Feeds ``_accumulate_history`` a hand-built git-log payload with three records:
          a header with no SHA (one field -> skipped at the len check), a header whose
          timestamp is non-numeric (ValueError -> skipped), and one well-formed commit.
          Only the good commit's file survives, proving the two parse guards skip rather
          than crash. ``\x1e`` opens each record; rows are ``added\tremoved\tpath``.
          """
          RS = "\x1e"
          payload = (
              f"{RS}1700000000\n"               # header missing the SHA -> len(header)!=2
              "5\t0\torphan.py\n"
              f"{RS}notanumber abc123\n"         # non-numeric author time -> ValueError
              "9\t0\tbadtime.py\n"
              f"{RS}1700100000 deadbeef\n"       # well-formed
              "40\t2\tgood.py\n"
          )
      
          class _Result:
              stdout = payload
      
          monkeypatch.setattr(ar.subprocess, "run", lambda *a, **k: _Result())
          hist = _accumulate_history("/irrelevant")
          assert set(hist) == {"good.py"}
          assert hist["good.py"].additions == 40
          assert hist["good.py"].deletions == 2
      
      
      # --- CLI ---------------------------------------------------------------------
      
      def test_cli_reports_offenders_and_writes_json(tmp_path: Path, capsys, monkeypatch) -> None:
          """``main()`` scans, prints the offender readout, and writes the JSON sidecar.
      
          Drives the CLI entry point on a real repo with one append-only file. Exit
          code 0 on a successful scan; the JSON file matches the printed summary; the
          offender line for the flagged file appears in stdout.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
          for i in range(1, 5):
              _commit(repo, "grow.py", _lines(100 * i), clock)
      
          out_json = tmp_path / "out.json"
          monkeypatch.setattr(
              "sys.argv",
              ["accretion_ratchet.py", str(repo), "--json", str(out_json)],
          )
          rc = ar.main()
          assert rc == 0
      
          printed = capsys.readouterr().out
          assert "accreting files:" in printed
          assert "grow.py" in printed
      
          data = json.loads(out_json.read_text())
          assert data["available"] is True
          assert data["total_accreting"] >= 1
          assert any(f["path"] == "grow.py" for f in data["top_offenders"])
          assert "elapsed_seconds" in data
      
      
      def test_cli_returns_nonzero_when_unavailable(tmp_path: Path, capsys, monkeypatch) -> None:
          """``main()`` exits non-zero and prints the reason on an unavailable scan."""
          plain = tmp_path / "plain"
          plain.mkdir()
          monkeypatch.setattr("sys.argv", ["accretion_ratchet.py", str(plain)])
          rc = ar.main()
          assert rc == 1
          assert "unavailable" in capsys.readouterr().out
      
      
      # --- Documentation files are not accretion -----------------------------------
      
      def test_accretion_skips_documentation_append_only_markdown(tmp_path: Path) -> None:
          """A 3,000-line append-only markdown plan earns no accretion entry.
      
          ``notes/PLAN.md`` grows by 1,000 lines in each of three commits with no
          deletions (fraction 0.0, monotonic), the exact accretion fingerprint. A plan
          that grows by appending carries no change risk, so the scanner skips
          documentation extensions. ``src/big.py`` grows the same way and is still
          flagged, so the filter is by file type, not by growth shape.
          """
          repo = _init_repo(tmp_path)
          clock = _Clock()
          for i in range(1, 4):
              _commit(repo, "notes/PLAN.md", _lines(1000 * i), clock)
              _commit(repo, "src/big.py", _lines(100 * i, start=50000), clock)
      
          assert (repo / "notes" / "PLAN.md").read_text().count("\n") == 3000
          flagged = _flagged_paths(repo)
          assert "notes/PLAN.md" not in flagged
          assert "src/big.py" in flagged
      
      
      def test_accretion_skips_documentation_every_doc_suffix() -> None:
          """Every documentation suffix is rejected, case-insensitively; code is not."""
          hist = _FileHistory(additions=900, deletions=0, commit_count=3,
                              first_time=0, last_time=86_400,
                              net_sequence=[300, 600, 900])
          for path in ("a.md", "b.MD", "c.markdown", "d.mdx", "e.rst", "g.adoc"):
              assert _build_accretion_file(path, hist, DELETION_FRACTION_THRESHOLD) is None
          # .txt is not documentation here: build logic and manifests accrete for real.
          for path in ("a.py", "b.dart", "c.js", "Makefile", "CMakeLists.txt",
                       "requirements.txt"):
              assert _build_accretion_file(path, hist, DELETION_FRACTION_THRESHOLD) is not None
      
    • test_accretion_ratchet_threshold.py 2 KB
      """Regression: the caller's --deletion-threshold is the cut actually applied.
      
      `_build_accretion_file` once hard-coded the module constant
      (DELETION_FRACTION_THRESHOLD = 0.15) for its deletion-fraction filter, while
      `scan_accretion_ratchet` re-filtered on, and reported, the caller's
      `deletion_threshold`. The effective cut was min(0.15, deletion_threshold), so any
      caller-supplied threshold above 0.15 was silently ignored - the API contradicted
      its reported `deletion_fraction_threshold`. This pins the contract: a file at
      deletion fraction 0.20 is admitted under `--deletion-threshold 0.30` and dropped
      under the 0.15 default.
      
      The comprehensive suite is owned by a separate task; this is the focused
      regression only, in a minimally-named module to avoid colliding with it.
      """
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
      
      from lib.accretion_ratchet import (  # noqa: E402
          _FileHistory,
          _build_accretion_file,
      )
      
      
      def _history_at_fraction_020() -> _FileHistory:
          """A monotonically-growing file whose deletion fraction is exactly 0.20.
      
          Three appending commits (multi-commit gate cleared), running net-delta never
          falls back (monotonic), 80 additions to 20 deletions => 20/100 = 0.20.
          """
          return _FileHistory(
              additions=80,
              deletions=20,
              commit_count=3,
              first_time=1_000,
              last_time=1_000 + 86_400,
              net_sequence=[20, 40, 60],
          )
      
      
      def test_threshold_above_fraction_admits_file() -> None:
          """--deletion-threshold 0.30 admits a 0.20-fraction file (old code dropped it)."""
          result = _build_accretion_file("grower.py", _history_at_fraction_020(), 0.30)
          assert result is not None
          assert result.path == "grower.py"
          assert result.deletion_fraction == 0.20
      
      
      def test_default_threshold_drops_same_file() -> None:
          """At the 0.15 default the same file is correctly excluded (0.20 >= 0.15)."""
          result = _build_accretion_file("grower.py", _history_at_fraction_020(), 0.15)
          assert result is None
      
    • test_action_contract.py 7.1 KB
      """Contract tests for the repo-root composite action (action.yml).
      
      The action is the published form of the assess gate - the thing consumers pin
      by version and Dependabot upgrades. These tests hold it to the same invariants
      the emitted-workflow template carried before the logic moved here: pinned
      supply chain, warn-only infra degrade, and the gate step as the only path to a
      red check.
      """
      from __future__ import annotations
      
      import re
      from pathlib import Path
      
      import pytest
      
      try:  # PyYAML is not a declared dependency of this suite.
          import yaml
      except ImportError:  # pragma: no cover - environment-dependent
          yaml = None
      
      _ACTION_PATH = Path(__file__).resolve().parents[3] / "action.yml"
      
      # `working-directory: <value>` as a plain scalar, read from the text rather than
      # the parse tree so the guard below still runs where PyYAML is absent - which is
      # every CI run of this suite today, and the reason a module-level importorskip
      # would make that guard decorative.
      _WORKING_DIR_RE = re.compile(r"^\s*working-directory:[ \t]*(\S.*?)\s*$", re.MULTILINE)
      
      
      def _action() -> dict:
          if yaml is None:
              pytest.skip("PyYAML is not installed; the structural assertions need a parse tree")
          return yaml.safe_load(_ACTION_PATH.read_text(encoding="utf-8"))
      
      
      def _steps() -> list[dict]:
          return _action()["runs"]["steps"]
      
      
      def test_action_is_composite_at_repo_root():
          action = _action()
          assert action["runs"]["using"] == "composite"
          # Root placement is what makes `uses: bjcoombs/ai-native-toolkit@v<semver>`
          # resolve (and keeps the action Marketplace-eligible).
          assert _ACTION_PATH.parent.name == "ai-native-toolkit" or (
              _ACTION_PATH.parent / ".claude-plugin"
          ).exists()
      
      
      def test_action_installs_pinned_ripgrep():
          """The marker scan needs rg; ubuntu-latest does not ship it. Unpinned
          installs could move the regression baseline with no change in the tree."""
          text = _ACTION_PATH.read_text(encoding="utf-8")
          rg_lines = [ln for ln in text.splitlines() if "ripgrep/releases/download" in ln]
          assert rg_lines, "expected a pinned ripgrep download"
          assert re.search(r"/download/\d+\.\d+\.\d+/", rg_lines[0])
      
      
      def test_action_installs_pinned_uv():
          text = _ACTION_PATH.read_text(encoding="utf-8")
          uv_lines = [ln for ln in text.splitlines() if "astral.sh/uv/" in ln]
          assert uv_lines, "expected a pinned uv installer"
          assert re.search(r"astral\.sh/uv/\d+\.\d+\.\d+/install\.sh", uv_lines[0]), (
              f"unpinned uv installer: {uv_lines[0].strip()}"
          )
      
      
      def test_action_contains_no_floating_latest():
          assert "@latest" not in _ACTION_PATH.read_text(encoding="utf-8")
      
      
      def test_action_runs_the_four_core_scripts():
          text = _ACTION_PATH.read_text(encoding="utf-8")
          for script in (
              "complexity-treemap.py",
              "assess_core.py",
              "assess_report.py",
              "assess_gate.py",
          ):
              assert script in text, f"missing core script: {script}"
      
      
      def test_assessment_steps_are_guarded_on_uv():
          """Render + gate only run when uv is available; unavailable uv must skip
          with a notice (warn-only contract), never fail the consumer's check."""
          guarded = [
              s for s in _steps() if "steps.ensure-uv.outputs.ok == 'true'" in (s.get("if") or "")
          ]
          # Render, the skip-notice reporter, and the gate all require uv.
          assert len(guarded) == 3
          ensure = next(s for s in _steps() if s.get("id") == "ensure-uv")
          assert "::notice::" in ensure["run"]
          assert "ok=false" in ensure["run"]
      
      
      def test_render_step_is_continue_on_error():
          """A mid-render crash is infrastructure, not a finding: the render step must
          continue-on-error so it degrades to a skip, never a red check."""
          render = next(s for s in _steps() if s.get("id") == "render")
          assert render["continue-on-error"] is True
      
      
      def test_gate_runs_only_after_successful_render():
          """The gate must run meaningfully only when the snapshot rendered whole: its
          guard requires both uv and a successful render outcome."""
          gate = next(s for s in _steps() if "assess_gate.py" in s.get("run", ""))
          cond = gate.get("if") or ""
          assert "steps.ensure-uv.outputs.ok == 'true'" in cond
          assert "steps.render.outcome == 'success'" in cond
      
      
      def test_failed_render_emits_skip_notice():
          """A failed render surfaces a skip notice (never a failure) so the skip is
          visible in the checks UI as infrastructure, not a finding."""
          skip = next(
              s for s in _steps()
              if "steps.render.outcome == 'failure'" in (s.get("if") or "")
          )
          assert "::notice::" in skip["run"]
          assert "not a finding" in skip["run"]
      
      
      def test_infra_steps_cannot_red_the_check():
          """The ripgrep step must always exit 0 (degrade = reduced coverage); only
          the gate step's exit code may fail the consumer's PR."""
          rg_step = next(s for s in _steps() if "ripgrep" in s["name"].lower())
          assert rg_step["run"].rstrip().endswith("exit 0")
          gate_step = next(s for s in _steps() if "assess_gate.py" in s.get("run", ""))
          assert "exit 0" not in gate_step["run"]
          assert "continue-on-error" not in gate_step
      
      
      def test_config_input_default():
          assert _action()["inputs"]["config"]["default"] == ".assess/config.toml"
      
      
      def test_own_workflow_self_tests_the_action():
          """This repo's own gate must run `uses: ./` so every PR exercises the
          action from the branch under review, not a stale released tag."""
          wf = (
              _ACTION_PATH.parent / ".github" / "workflows" / "assess-gate.yml"
          ).read_text(encoding="utf-8")
          assert "uses: ./" in wf
      
      
      def test_action_yml_is_git_tracked():
          """The repo .gitignore is an allowlist (`/*` + unignores); a root file not
          explicitly unignored is silently skipped by `git add -A`. That happened to
          action.yml on first publish - the self-test workflow failed with
          'Can't find action.yml'. Pin trackedness so the action can't ship absent."""
          import subprocess
      
          out = subprocess.run(
              ["git", "-C", str(_ACTION_PATH.parent), "ls-files", "--", "action.yml"],
              capture_output=True, text=True,
          )
          assert out.stdout.strip() == "action.yml"
      
      
      def test_action_description_fits_marketplace_limit():
          """GitHub Marketplace rejects an action whose description is 125+ chars -
          discovered live on the v1.42.0 release page. Pin publishability."""
          desc = _action()["description"]
          assert len(desc) < 125, f"{len(desc)} chars: {desc}"
      
      
      def test_working_directories_exist():
          """Every `working-directory` in action.yml must resolve on disk.
      
          A composite step's `working-directory` is only checked at run time, and a
          miss there surfaces as a render failure the warn-only contract swallows as
          a notice. Resolving `${{ github.action_path }}` to the checkout root here
          turns a moved directory into a red test in the PR that moves it.
          """
          action_root = _ACTION_PATH.parent
          values = _WORKING_DIR_RE.findall(_ACTION_PATH.read_text(encoding="utf-8"))
          assert values, "expected at least one working-directory in action.yml"
          for wd in values:
              resolved = Path(wd.replace("${{ github.action_path }}", str(action_root)))
              assert resolved.is_dir(), f"working-directory does not exist: {wd}"
      
    • test_agent_instructions_grader.py 10.2 KB
      """Tests for heuristic agent-instructions grader.
      
      Grades any of: CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules,
      .github/copilot-instructions.md. The grader operates on text + freshness,
      so it's filename-agnostic - the file selection lives in assess_core.
      """
      from __future__ import annotations
      
      from pathlib import Path
      
      import pytest
      
      from lib.agent_instructions_grader import (
          compute_size_metrics,
          count_positive_directives,
          count_tradeoff_phrases,
          count_path_references,
          count_verifiable_outcomes,
          detect_alias,
          detect_skills_delegation,
          grade_instructions,
          scan_sensitive_content,
      )
      
      
      @pytest.fixture
      def good_text(fixtures_dir: Path) -> str:
          return (fixtures_dir / "good_instructions.md").read_text()
      
      
      @pytest.fixture
      def bad_text(fixtures_dir: Path) -> str:
          return (fixtures_dir / "bad_instructions.md").read_text()
      
      
      def test_positive_directives_good_outscores_bad(good_text: str, bad_text: str) -> None:
          # Good fixture uses: Use, Prefer, Default to, Match, Add (positive)
          # Bad fixture uses mostly: Write, Follow, Be, Don't (negatives + generic verbs)
          assert count_positive_directives(good_text) >= 5
          assert count_positive_directives(bad_text) <= 2
      
      
      def test_tradeoff_phrases_only_in_good(good_text: str, bad_text: str) -> None:
          # "because", "over X" are tradeoff signals
          assert count_tradeoff_phrases(good_text) >= 2
          assert count_tradeoff_phrases(bad_text) == 0
      
      
      def test_path_references_only_in_good(good_text: str, bad_text: str) -> None:
          # Good fixture has src/auth/, src/payments/processor.py, etc.
          assert count_path_references(good_text) >= 4
          assert count_path_references(bad_text) == 0
      
      
      def test_verifiable_outcomes_only_in_good(good_text: str, bad_text: str) -> None:
          # "Working if" is the signal phrase
          assert count_verifiable_outcomes(good_text) >= 1
          assert count_verifiable_outcomes(bad_text) == 0
      
      
      # --- JVM / build-tool verifiable outcomes (issue #116) --------------------
      # The detector was calibrated for JS/Python phrasing and credited zero
      # verifiable outcomes to Maven/Gradle CLAUDE.md files that contain runnable
      # verification (mvn/gradle/gradlew test, -Dtest=, rg recipes). Recognise
      # those idioms while keeping JS/Python phrase recognition intact.
      
      MAVEN_INSTRUCTIONS = """# Project Guidelines
      
      ## Verifying a change
      
      - Run the focused test: `mvn test -Dtest=PaymentServiceTest#refundsAreIdempotent`.
      - Run the full verification gate before opening a PR: `mvn verify`.
      - Confirm no stray TODOs slipped in: `rg "TODO" src/main/java`.
      """
      
      GRADLE_INSTRUCTIONS = """# Project Guidelines
      
      ## Verifying a change
      
      - Run the focused test: `./gradlew test --tests com.example.PaymentServiceTest`.
      - Build and check everything: `./gradlew build check`.
      - Confirm logging uses the wrapper: `rg "System.out" src`.
      """
      
      
      def test_maven_instructions_score_nonzero_verifiable_outcomes() -> None:
          # Success criterion for issue #116: a Maven CLAUDE.md with runnable
          # mvn/rg verification must score a NON-ZERO verifiable_outcomes.
          assert count_verifiable_outcomes(MAVEN_INSTRUCTIONS) >= 1
          grade = grade_instructions(MAVEN_INSTRUCTIONS, freshness_days=10)
          assert grade.subscores["verifiable_outcomes"] >= 1
      
      
      def test_gradle_instructions_score_nonzero_verifiable_outcomes() -> None:
          assert count_verifiable_outcomes(GRADLE_INSTRUCTIONS) >= 1
          grade = grade_instructions(GRADLE_INSTRUCTIONS, freshness_days=10)
          assert grade.subscores["verifiable_outcomes"] >= 1
      
      
      def test_jvm_idioms_do_not_regress_js_python_recognition(good_text: str, bad_text: str) -> None:
          # Existing phrase-based recognition stays intact: the good JS/Python
          # fixture still credits a verifiable outcome, the bad one still none.
          assert count_verifiable_outcomes(good_text) >= 1
          assert count_verifiable_outcomes(bad_text) == 0
      
      
      def test_jvm_patterns_do_not_false_match_prose() -> None:
          # High precision: prose that merely mentions Maven/Gradle without a
          # runnable command must not be credited as a verifiable outcome.
          prose = (
              "# Guidelines\n\n"
              "This is a Maven project that uses Gradle elsewhere. "
              "We care about testing and verifying our work generally.\n"
          )
          assert count_verifiable_outcomes(prose) == 0
      
      
      def test_rg_prose_mention_is_not_credited() -> None:
          # A bare reference to ripgrep without a runnable recipe (no flag, no
          # quoted query) must not count as a verifiable outcome.
          prose = (
              "# Guidelines\n\n"
              "You can use rg to find things, and rg or grep are both useful. "
              "The rg tool is fast.\n"
          )
          assert count_verifiable_outcomes(prose) == 0
      
      
      def test_rg_recipe_with_flag_is_credited() -> None:
          # A real ripgrep recipe (flag-driven, unquoted query) is verifiable.
          text = "# Guidelines\n\nCheck for leftovers: `rg -n TODO src/main/java`.\n"
          assert count_verifiable_outcomes(text) >= 1
      
      
      def test_grade_returns_letter_grade(good_text: str, bad_text: str) -> None:
          good = grade_instructions(good_text, freshness_days=10)
          bad = grade_instructions(bad_text, freshness_days=10)
      
          assert good.grade in {"A", "A-", "B+", "B"}
          assert bad.grade in {"D", "F"}
          assert good.score > bad.score
      
      
      def test_grade_penalizes_staleness(good_text: str) -> None:
          fresh = grade_instructions(good_text, freshness_days=10)
          stale = grade_instructions(good_text, freshness_days=400)
          assert stale.score < fresh.score
      
      
      def test_grade_empty_string_is_F() -> None:
          empty = grade_instructions("", freshness_days=0)
          assert empty.grade == "F"
          assert empty.score == 0
      
      
      def test_subscores_in_result(good_text: str) -> None:
          result = grade_instructions(good_text, freshness_days=10)
          assert result.subscores["positive_directives"] >= 5
          assert result.subscores["path_references"] >= 4
          assert "tradeoff_phrases" in result.subscores
          assert "verifiable_outcomes" in result.subscores
      
      
      def test_size_metrics_accurate() -> None:
          text = "line1\nline2\nline3"
          m = compute_size_metrics(text)
          assert m["line_count"] == 3
          assert m["word_count"] == 3
          assert m["exceeds_line_threshold"] is False
          assert m["exceeds_word_threshold"] is False
      
      
      def test_skills_delegation_detection() -> None:
          text = "Load Java conventions via the `java-conventions` skill."
          d = detect_skills_delegation(text)
          assert d["delegates_to_skills"] is True
          assert d["delegation_pointers"] >= 1
          assert len(d["delegation_samples"]) >= 1
      
      
      def test_skills_delegation_detection_dir_pointer() -> None:
          text = "Topic guidance lives under .claude/skills/ and loads on demand."
          d = detect_skills_delegation(text)
          assert d["delegates_to_skills"] is True
      
      
      def test_no_skills_delegation_in_generic_text() -> None:
          text = "Write clean code. Follow best practices."
          d = detect_skills_delegation(text)
          assert d["delegates_to_skills"] is False
          assert d["delegation_pointers"] == 0
      
      
      def test_size_subscores_in_grade(good_text: str) -> None:
          result = grade_instructions(good_text, freshness_days=10)
          assert "line_count" in result.subscores
          assert "word_count" in result.subscores
          assert "bloat_penalty" in result.subscores
          assert result.subscores["bloat_penalty"] == 0  # good_instructions is small
      
      
      # --- Sensitive-content scan (issue #56) -----------------------------------
      
      def _categories(findings: list[dict]) -> set[str]:
          return {f["category"] for f in findings}
      
      
      def test_scan_flags_public_ip() -> None:
          findings = scan_sensitive_content("Demo server: 203.0.113.42 runs the stack.")
          assert "ip_address" in _categories(findings)
          # Evidence is redacted - the full IP must not survive into the finding.
          assert all("203.0.113.42" not in f["evidence"] for f in findings)
      
      
      def test_scan_ignores_loopback_and_version_strings() -> None:
          assert scan_sensitive_content("bind to 127.0.0.1 for local dev") == []
          # 999 is not a valid octet -> a version-like string, not an IP.
          assert scan_sensitive_content("upgrade to release 1.2.999.4") == []
      
      
      def test_scan_flags_ssh_root_login() -> None:
          findings = scan_sensitive_content("Connect with `ssh root@demo.example.com`.")
          cats = _categories(findings)
          assert "ssh_or_host" in cats
          assert all("demo.example.com" not in f["evidence"] for f in findings)
      
      
      def test_scan_flags_private_key_and_cloud_key() -> None:
          pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIabc\n-----END RSA PRIVATE KEY-----"
          assert "private_key" in _categories(scan_sensitive_content(pem))
          assert "cloud_key" in _categories(scan_sensitive_content("AWS: AKIAIOSFODNN7EXAMPLE"))
      
      
      def test_scan_flags_real_credential_but_not_placeholder() -> None:
          real = scan_sensitive_content('password = "hunter2correcthorse"')
          assert "credential" in _categories(real)
          assert all("hunter2" not in f["evidence"] for f in real)
          # Placeholders / env refs are not flagged.
          assert scan_sensitive_content("API_KEY=your_key_here") == []
          assert scan_sensitive_content("token = ${GH_TOKEN}") == []
          assert scan_sensitive_content("password: <your-password>") == []
      
      
      def test_scan_flags_home_directory_path_but_not_placeholder() -> None:
          findings = scan_sensitive_content("Config lives at /Users/ben/.config/app.yaml")
          assert "home_path" in _categories(findings)
          assert all("ben" not in f["evidence"] for f in findings)
          # Generic placeholder home dirs are not a leak.
          assert scan_sensitive_content("clone into /home/user/project") == []
      
      
      def test_scan_clean_instruction_file_has_no_findings(good_text: str) -> None:
          assert scan_sensitive_content(good_text) == []
      
      
      # --- Alias detection (issue #57) ------------------------------------------
      
      def test_detect_alias_thin_stub_points_at_claude_md() -> None:
          stub = "# AGENTS.md\n\nSee [CLAUDE.md](./CLAUDE.md) for all project instructions."
          result = detect_alias(stub)
          assert result["is_alias"] is True
          assert result["alias_target"] == "CLAUDE.md"
      
      
      def test_detect_alias_rejects_full_standalone_doc(good_text: str) -> None:
          # A real instruction file is not a thin alias even if it mentions CLAUDE.md.
          assert detect_alias(good_text)["is_alias"] is False
      
      
      def test_detect_alias_rejects_stub_with_no_canonical_reference() -> None:
          assert detect_alias("# Notes\n\nThis project is great.")["is_alias"] is False
      
    • test_agent_ops.py 4.5 KB
      """Tests for the agent-operations guardrail scan (Layer 8 evidence)."""
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      from lib.agent_ops import scan_agent_ops
      
      
      def _write_settings(repo: Path, rel: str, data: object) -> Path:
          path = repo / rel
          path.parent.mkdir(parents=True, exist_ok=True)
          if isinstance(data, str):
              path.write_text(data, encoding="utf-8")
          else:
              path.write_text(json.dumps(data), encoding="utf-8")
          return path
      
      
      # ── empty / degraded cases ──────────────────────────────────────────────────
      
      def test_no_claude_dir(git_repo) -> None:
          repo, commit = git_repo
          (repo / "README.md").write_text("hi", encoding="utf-8")
          commit("init")
          block = scan_agent_ops(repo)
          assert block["available"] is True
          assert block["settings"] == []
          assert block["hooks_dir"]["present"] is False
          assert block["summary"] == {
              "permissions_encoded": False,
              "hooks_present": False,
              "routines_present": False,
          }
      
      
      def test_non_git_dir_counts_nothing_as_tracked(tmp_path: Path) -> None:
          _write_settings(tmp_path, ".claude/settings.json",
                          {"permissions": {"allow": ["Bash(ls:*)"]}})
          block = scan_agent_ops(tmp_path)
          assert block["available"] is True
          assert block["settings"][0]["tracked"] is False
          assert block["settings"][0]["allow_count"] == 1
          # Untracked evidence is reported but never credited.
          assert block["summary"]["permissions_encoded"] is False
      
      
      def test_malformed_settings_json_degrades(git_repo) -> None:
          repo, commit = git_repo
          _write_settings(repo, ".claude/settings.json", "{not json")
          commit("add settings")
          block = scan_agent_ops(repo)
          entry = block["settings"][0]
          assert entry["parse_ok"] is False
          assert entry["allow_count"] == 0
          assert block["summary"]["permissions_encoded"] is False
      
      
      # ── tracked-only credit ─────────────────────────────────────────────────────
      
      def test_tracked_settings_credit_summary(git_repo) -> None:
          repo, commit = git_repo
          _write_settings(repo, ".claude/settings.json", {
              "permissions": {"allow": ["Bash(ls:*)", "Read"], "deny": ["WebFetch"]},
              "hooks": {"PreToolUse": [], "PostToolUse": []},
              "sandbox": {"enabled": True},
          })
          commit("add settings")
          block = scan_agent_ops(repo)
          entry = block["settings"][0]
          assert entry["tracked"] is True
          assert entry["parse_ok"] is True
          assert entry["allow_count"] == 2
          assert entry["deny_count"] == 1
          assert entry["ask_count"] == 0
          assert entry["hook_events"] == 2
          assert entry["sandbox_configured"] is True
          assert block["summary"]["permissions_encoded"] is True
          assert block["summary"]["hooks_present"] is True
      
      
      def test_untracked_settings_reported_not_credited(git_repo) -> None:
          repo, commit = git_repo
          (repo / "README.md").write_text("hi", encoding="utf-8")
          commit("init")
          # Written after the commit, never staged - the settings.local.json case.
          _write_settings(repo, ".claude/settings.local.json",
                          {"permissions": {"allow": ["Bash(ls:*)"]}})
          block = scan_agent_ops(repo)
          assert [s["path"] for s in block["settings"]] == [".claude/settings.local.json"]
          assert block["settings"][0]["tracked"] is False
          assert block["summary"]["permissions_encoded"] is False
      
      
      def test_hooks_dir_scripts(git_repo) -> None:
          repo, commit = git_repo
          hook = repo / ".claude" / "hooks" / "check.sh"
          hook.parent.mkdir(parents=True)
          hook.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
          commit("add hook")
          block = scan_agent_ops(repo)
          assert block["hooks_dir"]["present"] is True
          assert block["hooks_dir"]["file_count"] == 1
          assert block["hooks_dir"]["tracked_count"] == 1
          assert block["summary"]["hooks_present"] is True
      
      
      def test_routine_dirs(git_repo) -> None:
          repo, commit = git_repo
          wf = repo / ".claude" / "workflows" / "nightly-triage.md"
          wf.parent.mkdir(parents=True)
          wf.write_text("# nightly triage\n", encoding="utf-8")
          commit("add workflow")
          block = scan_agent_ops(repo)
          workflows = next(
              d for d in block["routine_dirs"] if d["path"] == ".claude/workflows"
          )
          assert workflows["tracked_count"] == 1
          assert block["summary"]["routines_present"] is True
      
    • test_anomaly_detector.py 6.1 KB
      """Tests for anomaly detection on /assess run output."""
      from __future__ import annotations
      
      from lib.anomaly_detector import detect_anomalies
      
      
      def _ctx(**overrides) -> dict:
          """Build a healthy context, then apply overrides for the test."""
          base = {
              "prior_stats_exists": True,
              "stats_summary": {
                  "files_scored": 100,
                  "loc": {"p50": 30, "p95": 200, "max": 500},
                  "ccn": {"p50": 3, "p95": 8, "max": 25},
                  "top_hotspots": [
                      {"path": "src/a.go", "loc": 400, "ccn": 25, "commits": 5},
                      {"path": "src/b.go", "loc": 300, "ccn": 18, "commits": 3},
                  ],
              },
              "instruction_files": {
                  "CLAUDE.md": {
                      "present": True, "grade": "B+", "score": 62, "line_count": 80, "subscores": {},
                  },
              },
              "instructions_grade": "B+",
              "diff": {"new": 1, "graduated": 0, "regressed": 0, "persistent": 1},
          }
          for k, v in overrides.items():
              if isinstance(base.get(k), dict) and isinstance(v, dict) and v:
                  base[k].update(v)
              else:
                  base[k] = v
          return base
      
      
      def test_no_anomalies_in_healthy_run() -> None:
          assert detect_anomalies(_ctx()) == []
      
      
      def test_zero_files_scored() -> None:
          anomalies = detect_anomalies(_ctx(stats_summary={"files_scored": 0}))
          assert "ZERO_FILES_SCORED" in {a.code for a in anomalies}
      
      
      def test_zero_complexity_with_files() -> None:
          anomalies = detect_anomalies(_ctx(stats_summary={
              "files_scored": 50, "ccn": {"p50": 0, "p95": 0, "max": 0},
          }))
          assert "ZERO_COMPLEXITY" in {a.code for a in anomalies}
      
      
      def test_empty_hotspots_large_repo() -> None:
          anomalies = detect_anomalies(_ctx(stats_summary={
              "files_scored": 250, "top_hotspots": [],
          }))
          assert "EMPTY_HOTSPOTS" in {a.code for a in anomalies}
      
      
      def test_instruction_file_grade_mismatch_long_file_low_grade() -> None:
          """An instruction file that's >200 lines but grades F is suspicious."""
          anomalies = detect_anomalies(_ctx(
              instruction_files={"CLAUDE.md": {
                  "present": True, "grade": "F", "score": 10, "line_count": 350, "subscores": {},
              }},
              instructions_grade="F",
          ))
          assert "INSTRUCTION_FILE_GRADE_MISMATCH" in {a.code for a in anomalies}
      
      
      def test_instruction_file_grade_mismatch_for_agents_md() -> None:
          """The check applies to any instruction filename, not just CLAUDE.md."""
          anomalies = detect_anomalies(_ctx(
              instruction_files={"AGENTS.md": {
                  "present": True, "grade": "F", "score": 10, "line_count": 300, "subscores": {},
              }},
              instructions_grade="F",
          ))
          assert "INSTRUCTION_FILE_GRADE_MISMATCH" in {a.code for a in anomalies}
      
      
      def test_all_hotspots_new_means_rotation_failed() -> None:
          anomalies = detect_anomalies(_ctx(
              diff={"new": 8, "graduated": 0, "regressed": 0, "persistent": 0},
              stats_summary={"files_scored": 100, "top_hotspots": [
                  {"path": f"src/{i}.go", "loc": 300, "ccn": 15, "commits": 2} for i in range(8)
              ]},
          ))
          assert "ALL_NEW_HOTSPOTS" in {a.code for a in anomalies}
      
      
      def test_all_new_hotspots_first_run_not_flagged() -> None:
          """On a true first run (no prior stats), ALL_NEW_HOTSPOTS must not be raised."""
          anomalies = detect_anomalies(_ctx(
              prior_stats_exists=False,
              diff={"new": 8, "graduated": 0, "regressed": 0, "persistent": 0},
              stats_summary={"files_scored": 100, "top_hotspots": [
                  {"path": f"src/{i}.go", "loc": 300, "ccn": 15, "commits": 2} for i in range(8)
              ]},
          ))
          assert "ALL_NEW_HOTSPOTS" not in {a.code for a in anomalies}
      
      
      def test_anomaly_detail_excludes_source_paths() -> None:
          """Every anomaly type must produce detail strings with no source file paths.
      
          File basenames for well-known instruction files (CLAUDE.md, AGENTS.md, etc.)
          are intentionally included - those are public knowledge and help triage.
          The exclusion is for source paths (src/foo.go, lib/bar.py, etc.).
          """
          # Trigger all 5 anomaly types
          triggers = [
              # ZERO_FILES_SCORED
              _ctx(stats_summary={"files_scored": 0}),
              # ZERO_COMPLEXITY
              _ctx(stats_summary={"files_scored": 50, "ccn": {"p50": 0, "p95": 0, "max": 0}}),
              # EMPTY_HOTSPOTS
              _ctx(stats_summary={"files_scored": 250, "top_hotspots": []}),
              # INSTRUCTION_FILE_GRADE_MISMATCH
              _ctx(
                  instruction_files={"CLAUDE.md": {
                      "present": True, "grade": "F", "score": 10, "line_count": 350, "subscores": {},
                  }},
                  instructions_grade="F",
              ),
              # ALL_NEW_HOTSPOTS
              _ctx(
                  diff={"new": 8, "graduated": 0, "regressed": 0, "persistent": 0},
                  stats_summary={"files_scored": 100, "top_hotspots": [
                      {"path": f"src/{i}.go", "loc": 300, "ccn": 15, "commits": 2} for i in range(8)
                  ]},
              ),
          ]
          for ctx in triggers:
              anomalies = detect_anomalies(ctx)
              for a in anomalies:
                  # No source-path characters
                  assert "/" not in a.detail, f"{a.code}: detail contains slash: {a.detail!r}"
                  assert ".go" not in a.detail, f"{a.code}: detail contains .go: {a.detail!r}"
                  assert ".py" not in a.detail, f"{a.code}: detail contains .py: {a.detail!r}"
                  assert ".ts" not in a.detail, f"{a.code}: detail contains .ts: {a.detail!r}"
      
      
      def test_anomaly_has_code_description_detail() -> None:
          anomalies = detect_anomalies(_ctx(stats_summary={"files_scored": 0}))
          assert len(anomalies) >= 1
          a = anomalies[0]
          assert a.code and a.description and a.detail
      
      
      def test_no_anomalies_when_no_instruction_files() -> None:
          """A repo with no instruction files at all is a valid (if poor) state, not an anomaly.
      
          The grade is None (distinct from F). INSTRUCTION_FILE_GRADE_MISMATCH should not fire
          because there's no file to mismatch with.
          """
          ctx = _ctx(instruction_files={}, instructions_grade=None)
          anomalies = detect_anomalies(ctx)
          codes = {a.code for a in anomalies}
          assert "INSTRUCTION_FILE_GRADE_MISMATCH" not in codes
      
    • test_archetype.py 11.1 KB
      """Tests for repository archetype detection (lib/archetype.py).
      
      Covers the four acceptance criteria of issue #224:
      1. A markdown KB repo is auto-detected; an override marker forces/suppresses.
      2. A detected KB marks write-side layers N/A and renormalises the denominator.
      3. The KB-maintenance (Karpathy LLM-wiki) workflow is detected and the gist
         is always available as the best-practice pointer.
      4. A conventional software repo is unaffected (all 0-8 layers, denominator 8).
      """
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
      
      from lib.archetype import (  # noqa: E402
          KARPATHY_GIST_URL,
          KB_APPLICABLE_LAYERS,
          SOFTWARE_DENOMINATOR,
          WRITE_SIDE_LAYERS,
          analyze_archetype,
          classify_archetype,
          detect_kb_maintenance,
          read_archetype_override,
      )
      
      
      def _kb_maint_empty() -> dict:
          return {"documented": False, "signals_found": [], "gist_cited": False, "gist": KARPATHY_GIST_URL}
      
      
      # --- classify_archetype: heuristic --------------------------------------
      
      
      def test_markdown_kb_detected_by_ratio_and_no_runtime():
          block = classify_archetype(
              code_file_count=1,
              doc_file_count=80,
              other_file_count=4,
              has_runtime_surface=False,
              override=None,
              kb_maintenance=_kb_maint_empty(),
          )
          assert block["archetype"] == "knowledge-base"
          assert block["detected_via"] == "heuristic"
          assert block["na_layers"] == WRITE_SIDE_LAYERS
          assert block["applicable_layers"] == KB_APPLICABLE_LAYERS
          assert block["denominator"] == len(KB_APPLICABLE_LAYERS) == 3
          assert "ratio" in block["reason"]
      
      
      def test_software_repo_unaffected_all_layers():
          block = classify_archetype(
              code_file_count=120,
              doc_file_count=20,
              other_file_count=10,
              has_runtime_surface=True,
              override=None,
              kb_maintenance=_kb_maint_empty(),
          )
          assert block["archetype"] == "software"
          assert block["na_layers"] == []
          assert block["applicable_layers"] == list(range(0, 9))
          assert block["denominator"] == SOFTWARE_DENOMINATOR == 8
      
      
      def test_doc_heavy_app_with_runtime_surface_is_software():
          # Lots of markdown but a real build manifest -> not a KB.
          block = classify_archetype(
              code_file_count=2,
              doc_file_count=200,
              other_file_count=5,
              has_runtime_surface=True,
              override=None,
              kb_maintenance=_kb_maint_empty(),
          )
          assert block["archetype"] == "software"
          assert block["signals"]["has_runtime_surface"] is True
          assert "runtime surface" in block["reason"]
      
      
      def test_empty_or_tiny_doc_set_not_kb():
          block = classify_archetype(
              code_file_count=0,
              doc_file_count=1,
              other_file_count=0,
              has_runtime_surface=False,
              override=None,
              kb_maintenance=_kb_maint_empty(),
          )
          # Only one doc and no real base -> not enough to call it a knowledge base.
          assert block["archetype"] == "software"
      
      
      # --- classify_archetype: override ---------------------------------------
      
      
      def test_override_forces_knowledge_base():
          block = classify_archetype(
              code_file_count=500,  # code-heavy; heuristic would say software
              doc_file_count=3,
              other_file_count=0,
              has_runtime_surface=True,
              override="knowledge-base",
              kb_maintenance=_kb_maint_empty(),
          )
          assert block["archetype"] == "knowledge-base"
          assert block["detected_via"] == "override"
          assert block["denominator"] == 3
      
      
      def test_override_suppresses_detection():
          block = classify_archetype(
              code_file_count=0,  # heuristic would say knowledge-base
              doc_file_count=99,
              other_file_count=0,
              has_runtime_surface=False,
              override="software",
              kb_maintenance=_kb_maint_empty(),
          )
          assert block["archetype"] == "software"
          assert block["detected_via"] == "override"
          assert block["denominator"] == 8
      
      
      # --- override_contradicts_signals (issue: silent override) --------------
      
      
      def test_kb_override_contradicts_high_code_ratio():
          # A knowledge-base marker on a code-heavy repo with a runtime surface: the
          # override wins the score, but the contradiction is surfaced with details.
          block = classify_archetype(
              code_file_count=500,
              doc_file_count=3,
              other_file_count=0,
              has_runtime_surface=True,
              override="knowledge-base",
              kb_maintenance=_kb_maint_empty(),
          )
          assert block["override_contradicts_signals"] is True
          assert block["contradiction_details"]
          assert "software" in block["contradiction_details"]
          # Override still wins: the denominator uses the forced (KB) archetype.
          assert block["archetype"] == "knowledge-base"
          assert block["denominator"] == 3
      
      
      def test_software_override_contradicts_pure_doc_repo():
          block = classify_archetype(
              code_file_count=0,
              doc_file_count=99,
              other_file_count=0,
              has_runtime_surface=False,
              override="software",
              kb_maintenance=_kb_maint_empty(),
          )
          assert block["override_contradicts_signals"] is True
          assert "knowledge-base" in block["contradiction_details"]
          # Override still wins the classification.
          assert block["archetype"] == "software"
          assert block["denominator"] == 8
      
      
      def test_override_matching_heuristic_no_contradiction():
          # A KB marker on a repo the heuristic would also call a KB: no contradiction.
          block = classify_archetype(
              code_file_count=1,
              doc_file_count=80,
              other_file_count=0,
              has_runtime_surface=False,
              override="knowledge-base",
              kb_maintenance=_kb_maint_empty(),
          )
          assert block["override_contradicts_signals"] is False
          assert block["contradiction_details"] is None
          assert block["archetype"] == "knowledge-base"
      
      
      def test_heuristic_path_never_contradicts():
          # No override -> no contradiction flag can fire (nothing forced anything).
          block = classify_archetype(
              code_file_count=1,
              doc_file_count=80,
              other_file_count=0,
              has_runtime_surface=False,
              override=None,
              kb_maintenance=_kb_maint_empty(),
          )
          assert block["detected_via"] == "heuristic"
          assert block["override_contradicts_signals"] is False
          assert block["contradiction_details"] is None
      
      
      def test_analyze_records_override_source_on_contradiction(tmp_path: Path):
          # A software marker on a pure-doc repo: analyze_archetype records the marker
          # source file so a finding can point at it.
          repo = tmp_path / "kb"
          (repo / "notes").mkdir(parents=True)
          for i in range(12):
              (repo / "notes" / f"note-{i}.md").write_text(f"# Note {i}\n", encoding="utf-8")
          (repo / "CLAUDE.md").write_text(
              "# KB\n\n<!-- assess-archetype: software -->\n", encoding="utf-8"
          )
          _git_init(repo)
      
          block = analyze_archetype(repo)
          assert block["archetype"] == "software"
          assert block["override_contradicts_signals"] is True
          assert block["override_source"] == "CLAUDE.md"
      
      
      # --- read_archetype_override --------------------------------------------
      
      
      def test_read_override_marker_force_kb(tmp_path: Path):
          (tmp_path / "CLAUDE.md").write_text(
              "# Repo\n\n<!-- assess-archetype: knowledge-base -->\n", encoding="utf-8"
          )
          assert read_archetype_override(tmp_path) == "knowledge-base"
      
      
      def test_read_override_marker_suppress(tmp_path: Path):
          (tmp_path / "AGENTS.md").write_text(
              "assess-archetype: software\n", encoding="utf-8"
          )
          assert read_archetype_override(tmp_path) == "software"
      
      
      def test_read_override_alias_kb(tmp_path: Path):
          (tmp_path / "CLAUDE.md").write_text("<!-- assess-archetype: kb -->", encoding="utf-8")
          assert read_archetype_override(tmp_path) == "knowledge-base"
      
      
      def test_read_override_absent_returns_none(tmp_path: Path):
          (tmp_path / "CLAUDE.md").write_text("# nothing special here\n", encoding="utf-8")
          assert read_archetype_override(tmp_path) is None
      
      
      def test_read_override_unrecognised_value_ignored(tmp_path: Path):
          (tmp_path / "CLAUDE.md").write_text("<!-- assess-archetype: banana -->", encoding="utf-8")
          assert read_archetype_override(tmp_path) is None
      
      
      # --- detect_kb_maintenance ----------------------------------------------
      
      
      def test_kb_maintenance_documented_by_two_facets():
          text = (
              "Raw sources are immutable and append-only. "
              "A periodic consolidation pass lints the wiki and prunes stale notes."
          )
          sig = detect_kb_maintenance(text)
          assert sig["documented"] is True
          assert "immutable-sources" in sig["signals_found"]
          assert "periodic-consolidation" in sig["signals_found"]
          assert sig["gist"] == KARPATHY_GIST_URL
      
      
      def test_kb_maintenance_documented_by_gist_citation():
          text = "We follow the LLM-wiki pattern: " + KARPATHY_GIST_URL
          sig = detect_kb_maintenance(text)
          assert sig["gist_cited"] is True
          assert sig["documented"] is True
      
      
      def test_kb_maintenance_single_keyword_not_documented():
          text = "This project has a database schema."
          sig = detect_kb_maintenance(text)
          assert sig["documented"] is False
          # gist pointer is always present regardless
          assert sig["gist"] == KARPATHY_GIST_URL
      
      
      def test_kb_maintenance_gist_always_present_when_absent():
          sig = detect_kb_maintenance("nothing relevant")
          assert sig["documented"] is False
          assert sig["signals_found"] == []
          assert sig["gist"] == KARPATHY_GIST_URL
      
      
      # --- analyze_archetype (integration over a temp repo) -------------------
      
      
      def _git_init(repo: Path) -> None:
          import subprocess
      
          subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True)
          subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True)
          subprocess.run(
              ["git", "-C", str(repo), "-c", "user.email=t@e.com", "-c", "user.name=t",
               "commit", "-q", "-m", "init"],
              check=True,
          )
      
      
      def test_analyze_markdown_repo_is_knowledge_base(tmp_path: Path):
          repo = tmp_path / "kb"
          (repo / "notes").mkdir(parents=True)
          for i in range(12):
              (repo / "notes" / f"note-{i}.md").write_text(f"# Note {i}\n", encoding="utf-8")
          (repo / "CLAUDE.md").write_text(
              "# KB schema\nRaw sources are immutable. Periodic consolidation lints the wiki.\n",
              encoding="utf-8",
          )
          _git_init(repo)
      
          block = analyze_archetype(repo)
          assert block["available"] is True
          assert block["archetype"] == "knowledge-base"
          assert block["na_layers"] == WRITE_SIDE_LAYERS
          assert block["denominator"] == 3
          assert block["kb_maintenance"]["documented"] is True
      
      
      def test_analyze_software_repo_is_software(tmp_path: Path):
          repo = tmp_path / "app"
          (repo / "src").mkdir(parents=True)
          for i in range(15):
              (repo / "src" / f"mod_{i}.py").write_text(f"def f{i}():\n    return {i}\n", encoding="utf-8")
          (repo / "README.md").write_text("# App\n", encoding="utf-8")
          (repo / "pyproject.toml").write_text("[project]\nname='app'\n", encoding="utf-8")
          _git_init(repo)
      
          block = analyze_archetype(repo)
          assert block["archetype"] == "software"
          assert block["na_layers"] == []
          assert block["denominator"] == 8
          assert block["signals"]["has_runtime_surface"] is True
      
    • test_assess_config.py 6 KB
      """Tests for the working-notes keys in `.assess/config.toml` (issue #367).
      
      `working_notes_dirs` forces a directory to be classified as a working-notes
      tree whatever its size or fingerprint; `working_notes_ignore` keeps a directory
      counted in the headline even when the fingerprint matches. Each test runs the
      key end to end: config file -> `load_working_notes_config` -> `build_doc_graph`.
      """
      from __future__ import annotations
      
      from pathlib import Path
      
      from lib.assess_config import load_working_notes_config
      from lib.doc_graph import build_doc_graph
      
      
      def _write(root: Path, rel: str, text: str) -> None:
          p = root / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      def _config(root: Path, body: str) -> None:
          _write(root, ".assess/config.toml", body)
      
      
      def _plan_notes(root: Path, directory: str = "notes") -> None:
          """50 plan notes under one backlog index: the fingerprint matches."""
          for i in range(1, 51):
              _write(root, f"{directory}/plan_{i:02d}.md", f"# plan {i:02d}\n")
          _write(root, f"{directory}/backlog.md", "".join(
              f"- [plan {i:02d}](plan_{i:02d}.md)\n" for i in range(1, 51)
          ))
      
      
      _WORDS = "harbour ledger compass anchor beacon current driftwood estuary".split()
      
      
      def _journal(root: Path) -> None:
          """Eight cross-linked pages with varied names: no fingerprint matches."""
          n = len(_WORDS)
          for i, w in enumerate(_WORDS):
              links = "".join(f"- [{_WORDS[(i + k) % n]}]({_WORDS[(i + k) % n]}.md)\n" for k in (1, 3))
              _write(root, f"journal/{w}.md", f"# {w}\n\n{links}")
      
      
      def _graph(root: Path) -> dict:
          cfg = load_working_notes_config(root)
          return build_doc_graph(
              root, working_notes_dirs=cfg.dirs, working_notes_ignore=cfg.ignore,
          ).as_dict()
      
      
      def test_working_notes_dirs_forces_classification(tmp_path: Path) -> None:
          _journal(tmp_path)
          _write(tmp_path, "README.md", "# Home\n[harbour](journal/harbour.md)\n")
          assert _graph(tmp_path)["excluded_working_notes_trees"] == []
      
          _config(tmp_path, 'working_notes_dirs = ["./journal/"]\n')
          d = _graph(tmp_path)
          assert d["excluded_working_notes_trees"] == [{"path": "journal", "file_count": 8}]
          assert d["working_notes_doc_count"] == 8
          assert d["doc_count"] == 1
      
      
      def test_working_notes_dirs_nested_path_and_absent_dir(tmp_path: Path) -> None:
          for i in range(3):
              _write(tmp_path, f"docs/plans/p{i}.md", f"# p{i}\n")
          _write(tmp_path, "docs/guide.md", "# Guide\n")
          _config(tmp_path, 'working_notes_dirs = ["docs/plans", "does-not-exist"]\n')
          d = _graph(tmp_path)
          assert d["excluded_working_notes_trees"] == [{"path": "docs/plans", "file_count": 3}]
          assert d["doc_count"] == 1
      
      
      def test_working_notes_dirs_absorbs_a_detected_tree_below_it(tmp_path: Path) -> None:
          _plan_notes(tmp_path, "work/notes")
          _write(tmp_path, "work/summary.md", "# Summary\n")
          _config(tmp_path, 'working_notes_dirs = ["work"]\n')
          d = _graph(tmp_path)
          assert d["excluded_working_notes_trees"] == [{"path": "work", "file_count": 52}]
      
      
      def test_working_notes_ignore_suppresses_classification(tmp_path: Path) -> None:
          _plan_notes(tmp_path)
          _write(tmp_path, "README.md", "# Home\n[backlog](notes/backlog.md)\n")
          assert _graph(tmp_path)["excluded_working_notes_trees"] == [
              {"path": "notes", "file_count": 51}
          ]
      
          _config(tmp_path, 'working_notes_ignore = ["notes"]\n')
          d = _graph(tmp_path)
          assert d["excluded_working_notes_trees"] == []
          assert d["working_notes_doc_count"] == 0
          assert d["doc_count"] == 52
      
      
      def test_working_notes_ignore_covers_subdirectories_and_wins_over_dirs(tmp_path: Path) -> None:
          _plan_notes(tmp_path, "notes/2026")
          _journal(tmp_path)
          _config(tmp_path, 'working_notes_dirs = ["journal"]\nworking_notes_ignore = ["notes", "journal"]\n')
          d = _graph(tmp_path)
          assert d["excluded_working_notes_trees"] == []
          assert d["doc_count"] == 51 + len(_WORDS)
      
      
      def test_working_notes_config_degrades_silently(tmp_path: Path) -> None:
          assert load_working_notes_config(tmp_path) == ([], [])
          _config(tmp_path, 'working_notes_dirs = "journal"\nworking_notes_ignore = ["", "/", 7, "a/b/"]\n')
          assert load_working_notes_config(tmp_path) == ([], ["a/b"])
          _config(tmp_path, "working_notes_dirs = [\n")  # malformed TOML
          assert load_working_notes_config(tmp_path) == ([], [])
      
      
      def test_working_notes_ignore_never_qualifies_a_parent(tmp_path: Path) -> None:
          # 25 plans + backlog beside 10 varied curated pages in notes/misc: notes/
          # fails the name-density leg. Ignoring notes/misc must not tip it over.
          for i in range(1, 26):
              _write(tmp_path, f"notes/plan_{i:02d}.md", f"# plan {i:02d}\n")
          _write(tmp_path, "notes/backlog.md", "".join(
              f"- [plan {i:02d}](plan_{i:02d}.md)\n" for i in range(1, 26)
          ))
          for w in "alpha bravo charlie delta echo foxtrot golf hotel india juliet".split():
              _write(tmp_path, f"notes/misc/{w}.md", f"# {w}\n")
          assert _graph(tmp_path)["excluded_working_notes_trees"] == []
      
          _config(tmp_path, 'working_notes_ignore = ["notes/misc"]\n')
          d = _graph(tmp_path)
          assert d["excluded_working_notes_trees"] == []
          assert d["doc_count"] == 36
      
      
      def test_working_notes_ignore_leaves_no_rump_tree(tmp_path: Path) -> None:
          # A chapter series under its contents page qualifies as one tree, docs/.
          # Ignoring the series must return the contents page too, not report a
          # one-file docs tree.
          for i in range(1, 21):
              _write(tmp_path, f"docs/chapters/chapter-{i:02d}.md", f"# chapter {i}\n")
          _write(tmp_path, "docs/contents.md", "".join(
              f"- [chapter {i}](chapters/chapter-{i:02d}.md)\n" for i in range(1, 21)
          ))
          _write(tmp_path, "README.md", "# Home\n[contents](docs/contents.md)\n")
          assert _graph(tmp_path)["excluded_working_notes_trees"] == [
              {"path": "docs", "file_count": 21}
          ]
      
          _config(tmp_path, 'working_notes_ignore = ["docs/chapters"]\n')
          d = _graph(tmp_path)
          assert d["excluded_working_notes_trees"] == []
          assert d["doc_count"] == 22
      
    • test_assess_core.py 99.4 KB
      """End-to-end test for the assess_core orchestrator.
      
      We don't run lizard/scc here - we drive assess_core via its public functions
      to exercise the deterministic plumbing.
      """
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      import pytest
      
      import assess_core
      from assess_core import build_run_context
      
      
      def _minimal_repo(tmp_path: Path) -> Path:
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 0, "loc": {}, "ccn": {},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
          return repo
      
      
      _EMPTY_STATS = json.dumps({
          "files_scored": 0, "loc": {}, "ccn": {},
          "top_hotspots": [], "top_complex": [], "top_large": [],
      })
      
      
      def _seed_assess(repo: Path) -> None:
          (repo / ".assess").mkdir(exist_ok=True)
          (repo / ".assess" / "complexity-stats.json").write_text(_EMPTY_STATS)
      
      
      def test_untracked_instruction_file_flagged_not_graded(git_repo, fixtures_dir: Path) -> None:
          """Issue #34 Gap 1: an on-disk-but-untracked instruction file isn't credited
          to the grade, and is surfaced as a finding."""
          repo, commit = git_repo
          good = (fixtures_dir / "good_instructions.md").read_text()
          _seed_assess(repo)
          (repo / ".github").mkdir()
          (repo / ".github" / "copilot-instructions.md").write_text(good, encoding="utf-8")
          commit("committed instructions")
          (repo / "CLAUDE.md").write_text(good, encoding="utf-8")  # untracked (after commit)
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          assert ".github/copilot-instructions.md" in ctx["instruction_files"]
          assert "CLAUDE.md" not in ctx["instruction_files"]          # untracked -> not graded
          assert "CLAUDE.md" in ctx["untracked_instruction_files"]    # but flagged
      
      
      def test_archetype_block_emitted_for_software_repo(git_repo) -> None:
          """Issue #224: run-context carries an archetype block; a code repo is software."""
          repo, commit = git_repo
          _seed_assess(repo)
          src = repo / "src"
          src.mkdir()
          for i in range(12):
              (src / f"mod_{i}.py").write_text(f"def f{i}():\n    return {i}\n", encoding="utf-8")
          (repo / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8")
          commit("software repo")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          arch = ctx["archetype"]
          assert arch["available"] is True
          assert arch["archetype"] == "software"
          assert arch["na_layers"] == []
          assert arch["denominator"] == 8
      
      
      def test_archetype_block_detects_knowledge_base(git_repo) -> None:
          """Issue #224: a markdown-only repo is detected as a knowledge base with
          write-side layers N/A and a renormalised denominator."""
          repo, commit = git_repo
          _seed_assess(repo)
          notes = repo / "notes"
          notes.mkdir()
          for i in range(15):
              (notes / f"note-{i}.md").write_text(f"# Note {i}\n\nbody\n", encoding="utf-8")
          (repo / "CLAUDE.md").write_text(
              "# KB\nRaw sources are immutable. A periodic consolidation pass lints the wiki.\n",
              encoding="utf-8",
          )
          commit("knowledge base")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          arch = ctx["archetype"]
          assert arch["archetype"] == "knowledge-base"
          assert arch["na_layers"] == [2, 3, 4, 5, 6, 7]
          assert arch["denominator"] == 3
          assert arch["kb_maintenance"]["documented"] is True
      
      
      def test_archetype_override_marker_suppresses(git_repo) -> None:
          """Issue #224: an `assess-archetype: software` marker forces software even
          on a markdown-only repo."""
          repo, commit = git_repo
          _seed_assess(repo)
          notes = repo / "notes"
          notes.mkdir()
          for i in range(15):
              (notes / f"note-{i}.md").write_text(f"# Note {i}\n", encoding="utf-8")
          (repo / "CLAUDE.md").write_text(
              "# Docs repo\n\n<!-- assess-archetype: software -->\n", encoding="utf-8"
          )
          commit("override to software")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          arch = ctx["archetype"]
          assert arch["archetype"] == "software"
          assert arch["detected_via"] == "override"
      
      
      def test_dangling_symlink_instruction_is_broken_ref(git_repo) -> None:
          """Issue #34 Gap 2: a committed instruction file that is a dangling symlink
          is an advertised-but-broken reference."""
          import os
          repo, commit = git_repo
          _seed_assess(repo)
          (repo / "README.md").write_text("# Repo", encoding="utf-8")
          os.symlink("missing-target.md", repo / ".cursorrules")  # dangling symlink
          commit("init with dangling .cursorrules")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          refs = ctx["broken_instruction_refs"]
          assert any(r.get("path") == ".cursorrules" and "symlink" in r["reason"] for r in refs)
      
      
      def test_broken_link_to_instruction_file_is_broken_ref(git_repo) -> None:
          """Issue #34 Gap 2: an entry doc linking a missing instruction file."""
          repo, commit = git_repo
          _seed_assess(repo)
          (repo / "README.md").write_text("see the [rules](AGENTS.md)", encoding="utf-8")
          commit("init; README links a missing AGENTS.md")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          refs = ctx["broken_instruction_refs"]
          assert any(Path(r.get("target", "")).name == "AGENTS.md" for r in refs)
      
      
      def test_sensitive_content_surfaced_for_committed_file(git_repo, fixtures_dir: Path) -> None:
          """Issue #56: a committed instruction file carrying an IP / home path is
          surfaced (redacted) so the remediation can warn before any further commit."""
          repo, commit = git_repo
          good = (fixtures_dir / "good_instructions.md").read_text()
          _seed_assess(repo)
          (repo / "CLAUDE.md").write_text(
              good + "\n\n## Demo\nServer 203.0.113.7, ssh root@demo.example.com\n"
              "Config at /Users/ben/.config/app.yaml\n",
              encoding="utf-8",
          )
          commit("instructions with infra detail")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          flagged = ctx["sensitive_instruction_content"]
          assert "CLAUDE.md" in flagged
          cats = {f["category"] for f in flagged["CLAUDE.md"]}
          assert {"ip_address", "ssh_or_host", "home_path"} <= cats
          # Evidence must be redacted - no raw secret survives into run-context.
          blob = json.dumps(flagged)
          assert "203.0.113.7" not in blob and "/Users/ben" not in blob
      
      
      def test_sensitive_content_surfaced_for_untracked_file(git_repo, fixtures_dir: Path) -> None:
          """Issue #56: the file the remediation might tell you to commit (an
          untracked CLAUDE.md) is scanned even though it isn't graded."""
          repo, commit = git_repo
          good = (fixtures_dir / "good_instructions.md").read_text()
          _seed_assess(repo)
          (repo / "README.md").write_text("# Repo", encoding="utf-8")
          commit("init")
          (repo / "CLAUDE.md").write_text(  # untracked
              good + "\nAWS key AKIAIOSFODNN7EXAMPLE\n", encoding="utf-8"
          )
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          assert "CLAUDE.md" in ctx["untracked_instruction_files"]   # not graded
          assert "CLAUDE.md" in ctx["sensitive_instruction_content"]  # but scanned
          assert any(f["category"] == "cloud_key"
                     for f in ctx["sensitive_instruction_content"]["CLAUDE.md"])
      
      
      def test_agents_md_symlink_alias_inherits_claude_grade(git_repo, fixtures_dir: Path) -> None:
          """Issue #57: AGENTS.md as a symlink to CLAUDE.md is the single-source-of-
          truth shape - it inherits CLAUDE.md's grade, not a standalone score."""
          import os
          repo, commit = git_repo
          good = (fixtures_dir / "good_instructions.md").read_text()
          _seed_assess(repo)
          (repo / "CLAUDE.md").write_text(good, encoding="utf-8")
          os.symlink("CLAUDE.md", repo / "AGENTS.md")
          commit("CLAUDE.md + AGENTS.md alias")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          files = ctx["instruction_files"]
          assert files["AGENTS.md"]["is_alias"] is True
          assert files["AGENTS.md"]["alias_target"] == "CLAUDE.md"
          assert files["AGENTS.md"]["grade"] == files["CLAUDE.md"]["grade"]
      
      
      def test_agents_md_thin_stub_alias_inherits_grade(git_repo, fixtures_dir: Path) -> None:
          """Issue #57: a thin AGENTS.md stub pointing at CLAUDE.md inherits its grade
          instead of scoring low as a bespoke doc the remediation would rewrite."""
          repo, commit = git_repo
          good = (fixtures_dir / "good_instructions.md").read_text()
          _seed_assess(repo)
          (repo / "CLAUDE.md").write_text(good, encoding="utf-8")
          (repo / "AGENTS.md").write_text(
              "# AGENTS.md\n\nSee [CLAUDE.md](./CLAUDE.md) for all instructions.\n",
              encoding="utf-8",
          )
          commit("CLAUDE.md + thin AGENTS.md stub")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          files = ctx["instruction_files"]
          assert files["AGENTS.md"]["is_alias"] is True
          assert files["AGENTS.md"]["alias_target"] == "CLAUDE.md"
          assert files["AGENTS.md"]["grade"] == files["CLAUDE.md"]["grade"]
      
      
      def test_ancestor_instruction_files_key_present(tmp_path: Path) -> None:
          """Issue #57: the ancestor-cascade signal is always surfaced as a list."""
          repo = _minimal_repo(tmp_path)
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          assert isinstance(ctx["ancestor_instruction_files"], list)
      
      
      def test_build_run_context_first_run(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          """No prior .assess/, no instruction files - 'new' diff, empty instructions."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
      
          current_stats = {
              "files_scored": 50,
              "loc": {"p50": 30, "p95": 200, "max": 500},
              "ccn": {"p50": 2, "p95": 8, "max": 20},
              "top_hotspots": [
                  {"path": "src/a.go", "loc": 500, "ccn": 20, "commits": 5},
              ],
              "top_complex": [{"path": "src/a.go", "ccn": 20}],
              "top_large": [{"path": "src/a.go", "loc": 500}],
          }
          (assess_dir / "complexity-stats.json").write_text(json.dumps(current_stats))
      
          # Clear the ambient CI signal so the default (no-flag) run is genuinely
          # interactive regardless of where the suite runs (locally or under CI).
          monkeypatch.delenv("CI", raising=False)
          monkeypatch.delenv("ASSESS_NON_INTERACTIVE", raising=False)
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
      
          assert ctx["run_date"] == "2026-05-22"
          assert ctx["stats_summary"]["files_scored"] == 50
          assert ctx["instruction_files"] == {}  # nothing found
          assert ctx["instructions_grade"] is None  # no instructions = None (distinct from F)
          assert ctx["diff"]["new"] == 1
          assert ctx["diff"]["graduated"] == 0
          assert (assess_dir / "log.md").exists()
          assert (assess_dir / "index.md").exists()
          # No decline markers in a bare repo -> empty block, no re-offer.
          assert ctx["decline_markers"] == []
          assert ctx["reoffer_mutation"] is False
          assert ctx["decline_disclosures"] == []
          # Default run (no --non-interactive, no CI env): interactive, offers left
          # empty for the orchestrator to present live - NOT pre-recorded as skipped.
          assert ctx["interactive"] is True
          assert ctx["offers"] == []
      
          # Explicit headless signal: every offer is pre-recorded as skipped and the
          # orchestrator makes zero prompts. This is the wiring the CLI --non-interactive
          # flag drives, verified independent of the ambient environment.
          ctx_headless = build_run_context(
              repo_root=repo, run_date="2026-05-22", non_interactive=True
          )
          assert ctx_headless["interactive"] is False
          assert {o["type"] for o in ctx_headless["offers"]}  # non-empty
          assert all(o["status"] == "skipped" for o in ctx_headless["offers"])
      
      
      def test_build_run_context_surfaces_decline_markers(tmp_path: Path) -> None:
          """A JSON decline marker flows into run-context with provenance + disclosure."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 1, "loc": {}, "ccn": {},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
          # Marker declined under an ancient major -> re-offer eligible.
          (assess_dir / ".no-mutmut").write_text(json.dumps({
              "declined_by": "ben", "declined_at": "2025-01-01",
              "plugin_version": "0.9.0",
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
      
          markers = ctx["decline_markers"]
          assert len(markers) == 1
          assert markers[0]["tool"] == "mutmut"
          assert markers[0]["declined_by"] == "ben"
          assert ctx["reoffer_mutation"] is True
          assert any("Mutation testing permanently declined by ben on 2025-01-01" in d
                     for d in ctx["decline_disclosures"])
      
      
      def test_unfinalized_hotspot_page_uses_neutral_pointer_not_placeholder(tmp_path: Path) -> None:
          """A hotspot page the deterministic core writes - before any LLM finalize -
          must carry the neutral out-of-Top-3 pointer, never a TODO-style placeholder.
      
          assess_finalize only rewrites the pages it's handed actions for (at minimum
          the Top 3), so a flagged-but-not-Top-3 page can ship un-finalized. Its default
          "Suggested actions" body must read as intentional, not as unfinished work
          (issue #165).
          """
          from lib.wiki_writer import UNFINALIZED_ACTIONS_POINTER, slug_for_path
      
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 50,
              "loc": {"p50": 30, "p95": 200, "max": 500},
              "ccn": {"p50": 2, "p95": 8, "max": 20},
              "top_hotspots": [{"path": "src/a.go", "loc": 500, "ccn": 20, "commits": 5}],
              "top_complex": [{"path": "src/a.go", "ccn": 20}],
              "top_large": [{"path": "src/a.go", "loc": 500}],
          }))
      
          build_run_context(repo_root=repo, run_date="2026-05-22")
      
          page = (assess_dir / "hotspots" / f"{slug_for_path('src/a.go')}.md").read_text(encoding="utf-8")
          # The "## Suggested actions" heading the finalizer keys off must survive.
          assert "## Suggested actions" in page
          # The neutral pointer is present...
          assert UNFINALIZED_ACTIONS_POINTER in page
          # ...and no TODO/placeholder marker leaks into the committed page.
          for marker in ("Pending LLM-generated suggestions", "TODO", "placeholder", "FIXME"):
              assert marker not in page
      
      
      def test_hotspot_page_carries_growth_profile_when_file_accretes(git_repo) -> None:
          """A top-hotspot file that the accretion scan flags (monotonic growth, low
          deletion across several commits) gets the growth-profile line wired into its
          generated hotspot page - tasks 4+5 end-to-end."""
          from lib.wiki_writer import slug_for_path
      
          repo, commit = git_repo
          src = repo / "src"
          src.mkdir()
          grower = src / "grower.go"
          # Five commits that only add lines and never delete: pure accretion.
          for i in range(1, 6):
              grower.write_text("\n".join(f"line {n}" for n in range(i * 40)) + "\n")
              commit(f"grow {i}", days_ago=(6 - i) * 20)
      
          assess_dir = repo / ".assess"
          assess_dir.mkdir(exist_ok=True)
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 50,
              "loc": {"p50": 30, "p95": 200, "max": 500},
              "ccn": {"p50": 2, "p95": 8, "max": 20},
              "top_hotspots": [{"path": "src/grower.go", "loc": 200, "ccn": 20, "commits": 5}],
              "top_complex": [{"path": "src/grower.go", "ccn": 20}],
              "top_large": [{"path": "src/grower.go", "loc": 200}],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-06-17")
          # Precondition: the scan actually flagged this file (else the test proves nothing).
          flagged = {f["path"] for f in ctx["accretion_ratchet"].get("files", [])}
          assert "src/grower.go" in flagged
      
          # The block must also reach the keyhole: a populated accretion_ratchet block
          # is wired into the derived finding (assess_core passes it to integrate), so
          # the cross-layer finding lists the path - not just the run-context block.
          finding = next(
              f for f in ctx["derived_findings"] if f["name"] == "accretion_ratchet"
          )
          assert "src/grower.go" in finding["paths"]
      
          page = (assess_dir / "hotspots" / f"{slug_for_path('src/grower.go')}.md").read_text(
              encoding="utf-8"
          )
          assert "Growth profile: monotonic" in page
          assert "0 net reductions over" in page
      
      
      def test_hotspot_page_omits_growth_profile_when_scan_unavailable(tmp_path: Path) -> None:
          """Graceful degradation: outside a git repo the accretion scan is
          unavailable, so the hotspot page is still written - just without a growth
          line. Hotspot generation never depends on the scan succeeding."""
          from lib.wiki_writer import slug_for_path
      
          repo = tmp_path / "repo"  # not a git repo
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 50,
              "loc": {"p50": 30, "p95": 200, "max": 500},
              "ccn": {"p50": 2, "p95": 8, "max": 20},
              "top_hotspots": [{"path": "src/a.go", "loc": 500, "ccn": 20, "commits": 5}],
              "top_complex": [{"path": "src/a.go", "ccn": 20}],
              "top_large": [{"path": "src/a.go", "loc": 500}],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-06-17")
          assert ctx["accretion_ratchet"]["available"] is False
      
          page = (assess_dir / "hotspots" / f"{slug_for_path('src/a.go')}.md").read_text(
              encoding="utf-8"
          )
          # Page exists and is complete, but carries no growth profile.
          assert "## Suggested actions" in page
          assert "Growth profile" not in page
      
      
      def test_unfinalized_actions_pointer_carries_no_placeholder_marker() -> None:
          """The neutral pointer text itself must be free of TODO-style markers - it is
          the default that ships when a page is never finalized."""
          from lib.wiki_writer import UNFINALIZED_ACTIONS_POINTER
      
          lowered = UNFINALIZED_ACTIONS_POINTER.lower()
          for marker in ("pending", "todo", "placeholder", "fixme", "tbd"):
              assert marker not in lowered
      
      
      def test_build_run_context_with_claude_md(tmp_path: Path, fixtures_dir: Path) -> None:
          repo = tmp_path / "repo"
          repo.mkdir()
          (repo / "CLAUDE.md").write_text((fixtures_dir / "good_instructions.md").read_text())
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 10, "loc": {"p50": 10, "p95": 30, "max": 50},
              "ccn": {"p50": 1, "p95": 3, "max": 5},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert "CLAUDE.md" in ctx["instruction_files"]
          assert ctx["instruction_files"]["CLAUDE.md"]["grade"] in {"A", "A-", "B+", "B"}
          assert ctx["instruction_files"]["CLAUDE.md"]["subscores"]["positive_directives"] >= 5
          # Top-level instructions_grade reflects the best of the present files
          assert ctx["instructions_grade"] in {"A", "A-", "B+", "B"}
      
      
      def test_build_run_context_with_agents_md(tmp_path: Path, fixtures_dir: Path) -> None:
          """The grader is filename-agnostic - works for AGENTS.md too."""
          repo = tmp_path / "repo"
          repo.mkdir()
          (repo / "AGENTS.md").write_text((fixtures_dir / "good_instructions.md").read_text())
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 10, "loc": {"p50": 10, "p95": 30, "max": 50},
              "ccn": {"p50": 1, "p95": 3, "max": 5},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert "AGENTS.md" in ctx["instruction_files"]
          assert ctx["instruction_files"]["AGENTS.md"]["grade"] in {"A", "A-", "B+", "B"}
      
      
      def test_build_run_context_with_multiple_instruction_files(tmp_path: Path, fixtures_dir: Path) -> None:
          """A repo can have CLAUDE.md AND AGENTS.md AND GEMINI.md (all pointing at the same content)."""
          repo = tmp_path / "repo"
          repo.mkdir()
          good = (fixtures_dir / "good_instructions.md").read_text()
          bad = (fixtures_dir / "bad_instructions.md").read_text()
          (repo / "CLAUDE.md").write_text(good)
          (repo / "AGENTS.md").write_text(good)
          (repo / "GEMINI.md").write_text(bad)
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 10, "loc": {"p50": 10, "p95": 30, "max": 50},
              "ccn": {"p50": 1, "p95": 3, "max": 5},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          keys = set(ctx["instruction_files"].keys())
          assert {"CLAUDE.md", "AGENTS.md", "GEMINI.md"} <= keys
          # Top-level grade reflects the BEST of the present files
          assert ctx["instructions_grade"] in {"A", "A-", "B+", "B"}
      
      
      def test_build_run_context_second_run_sees_diff(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
      
          # First run state - persist prior stats
          prior_stats = {
              "files_scored": 50, "loc": {"p50": 30, "p95": 200, "max": 500},
              "ccn": {"p50": 2, "p95": 8, "max": 20},
              "top_hotspots": [
                  {"path": "src/legacy.go", "loc": 500, "ccn": 20, "commits": 5},
              ],
              "top_complex": [{"path": "src/legacy.go", "ccn": 20}],
              "top_large": [{"path": "src/legacy.go", "loc": 500}],
          }
          (assess_dir / "complexity-stats.prior.json").write_text(json.dumps(prior_stats))
      
          current_stats = {
              "files_scored": 55, "loc": {"p50": 30, "p95": 220, "max": 550},
              "ccn": {"p50": 2, "p95": 9, "max": 22},
              "top_hotspots": [
                  {"path": "src/new.go", "loc": 400, "ccn": 18, "commits": 4},
              ],
              "top_complex": [{"path": "src/new.go", "ccn": 18}],
              "top_large": [{"path": "src/new.go", "loc": 400}],
          }
          (assess_dir / "complexity-stats.json").write_text(json.dumps(current_stats))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ctx["diff"]["graduated"] == 1
          assert ctx["diff"]["new"] == 1
      
      
      def test_graduated_index_row_carries_current_metrics(tmp_path: Path) -> None:
          """Issue #52 Bug 1: when a file graduates off top_hotspots[:10] but is
          still present in top_complex or top_large, the index row must show its
          *current* CCN and LOC, not 0. Zero in those columns reads as "the file
          was emptied," contradicts assess-report.md, and misleads reviewers."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
      
          # Prior run: src/legacy.go was a top hotspot.
          prior_stats = {
              "files_scored": 50, "loc": {}, "ccn": {},
              "top_hotspots": [
                  {"path": "src/legacy.go", "loc": 1096, "ccn": 172, "commits": 5},
              ],
              "top_complex": [{"path": "src/legacy.go", "ccn": 172}],
              "top_large": [{"path": "src/legacy.go", "loc": 1096}],
          }
          (assess_dir / "complexity-stats.prior.json").write_text(json.dumps(prior_stats))
      
          # Current run: src/legacy.go dropped off top_hotspots (a bigger file
          # took its slot) but still appears in top_complex and top_large at its
          # actual current metrics. This is the case CodeRabbit caught.
          current_stats = {
              "files_scored": 55, "loc": {}, "ccn": {},
              "top_hotspots": [
                  {"path": "src/giant.go", "loc": 2500, "ccn": 200, "commits": 8},
              ],
              "top_complex": [
                  {"path": "src/giant.go", "ccn": 200},
                  {"path": "src/legacy.go", "ccn": 172},
              ],
              "top_large": [
                  {"path": "src/giant.go", "loc": 2500},
                  {"path": "src/legacy.go", "loc": 1096},
              ],
          }
          (assess_dir / "complexity-stats.json").write_text(json.dumps(current_stats))
      
          build_run_context(repo_root=repo, run_date="2026-05-29")
          index = (assess_dir / "index.md").read_text(encoding="utf-8")
      
          # The graduated row must reflect reality: 1,096 LOC, ccn 172. NEVER 0.
          legacy_row = next(line for line in index.splitlines() if "src/legacy.go" in line)
          assert "graduated" in legacy_row
          assert "| 172 | 1096 |" in legacy_row, (
              f"expected current ccn/loc, got: {legacy_row}"
          )
          # The active row stays intact.
          assert "src/giant.go" in index
      
      
      def test_graduated_index_row_uses_dash_when_metrics_unknown(tmp_path: Path) -> None:
          """When a graduated file fell off every top-N list (no current metrics
          available anywhere), the row renders `-` rather than misleading zeros."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
      
          # Prior: src/legacy.go was a hotspot.
          prior_stats = {
              "files_scored": 50, "loc": {}, "ccn": {},
              "top_hotspots": [
                  {"path": "src/legacy.go", "loc": 800, "ccn": 90, "commits": 3},
              ],
              "top_complex": [{"path": "src/legacy.go", "ccn": 90}],
              "top_large": [{"path": "src/legacy.go", "loc": 800}],
          }
          (assess_dir / "complexity-stats.prior.json").write_text(json.dumps(prior_stats))
      
          # Current: src/legacy.go fell off ALL top-N lists (none of them mention it).
          current_stats = {
              "files_scored": 55, "loc": {}, "ccn": {},
              "top_hotspots": [
                  {"path": "src/new.go", "loc": 600, "ccn": 80, "commits": 4},
              ],
              "top_complex": [{"path": "src/new.go", "ccn": 80}],
              "top_large": [{"path": "src/new.go", "loc": 600}],
          }
          (assess_dir / "complexity-stats.json").write_text(json.dumps(current_stats))
      
          build_run_context(repo_root=repo, run_date="2026-05-29")
          index = (assess_dir / "index.md").read_text(encoding="utf-8")
          legacy_row = next(line for line in index.splitlines() if "src/legacy.go" in line)
          # Sentinel "-" not "0" - never lie about the size.
          assert "| - | - |" in legacy_row
      
      
      def test_build_run_context_writes_hotspot_pages(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 10, "loc": {"p50": 10, "p95": 30, "max": 50},
              "ccn": {"p50": 1, "p95": 3, "max": 5},
              "top_hotspots": [
                  {"path": "src/foo.go", "loc": 500, "ccn": 20, "commits": 5},
              ],
              "top_complex": [{"path": "src/foo.go", "ccn": 20}],
              "top_large": [{"path": "src/foo.go", "loc": 500}],
          }))
      
          build_run_context(repo_root=repo, run_date="2026-05-22")
          hotspots = list((assess_dir / "hotspots").iterdir())
          assert len(hotspots) == 1
          assert hotspots[0].name.startswith("src-foo-go-")
          assert hotspots[0].name.endswith(".md")
      
      
      def test_build_run_context_includes_anomalies_field(tmp_path: Path) -> None:
          """Every run-context.json must have an anomalies array (possibly empty)."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 0,
              "loc": {}, "ccn": {},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert "anomalies" in ctx
          codes = {a["code"] for a in ctx["anomalies"]}
          assert "ZERO_FILES_SCORED" in codes
      
      
      def test_run_context_has_deterministic_keyhole_products(tmp_path: Path) -> None:
          """assess-dogfooded Part 1: run-context.json carries the deterministic
          report-skeleton products - the pre-rendered findings markdown, the keyhole
          readiness summary, and the prescribed Top-3 actions - plus the eight derived
          findings (six original + E1/E2 trust axis)."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 1, "loc": {}, "ccn": {},
              "top_hotspots": [{"path": "src/a.py", "loc": 100, "ccn": 12, "commits": 3}],
              "top_complex": [{"path": "src/a.py", "ccn": 12}],
              "top_large": [{"path": "src/a.py", "loc": 100}],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
      
          # Task 2: deterministic findings markdown is present and well-formed.
          assert "findings_markdown" in ctx
          assert ctx["findings_markdown"].startswith(
              "## Cross-Layer Findings (Keyhole Readiness)"
          )
          # Task 3: keyhole readiness summary reported alongside the 0-8 score.
          assert "keyhole_summary" in ctx
          assert set(ctx["keyhole_summary"]) == {
              "concerns", "safe_zones", "total_concerns", "summary_text"
          }
          # Task 4: prescribed actions array exists (possibly empty for a clean repo).
          assert "prescribed_actions" in ctx
          assert isinstance(ctx["prescribed_actions"], list)
          # The low-signal marker is always emitted as a boolean beside attention.
          assert isinstance(ctx["attention_low_signal"], bool)
          # Task 5: derived findings now carry the nine named axes in fixed order.
          names = [f["name"] for f in ctx["derived_findings"]]
          assert names == [
              "hidden_coupling", "lying_map", "unexplained_complexity",
              "untrusted_hotspot", "self_referential_tests",
              "unactioned_intent", "accretion_ratchet",
              "orphaned_understanding", "candidate_dead_weight",
              "override_contradicts_signals", "refactor_boundary",
          ]
      
      
      def test_instructions_grade_is_None_when_no_files(tmp_path: Path) -> None:
          """When no instruction file exists, instructions_grade is None (not 'F').
      
          Distinct from F: F means a file exists but scored badly. None means there's
          no file at all - different remediation ("create the file" vs "fix the file").
          """
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 0,
              "loc": {}, "ccn": {},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ctx["instructions_grade"] is None
          assert ctx["instruction_files"] == {}
      
      
      def test_repo_root_not_in_ctx(tmp_path: Path) -> None:
          """ctx should not contain repo_root - it leaks the author's absolute path.
      
          The LLM consumer has $REPO_ROOT from its shell context; no need to serialize it.
          """
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 0, "loc": {}, "ccn": {},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert "repo_root" not in ctx
      
      
      def test_readside_blocks_present_in_ctx(tmp_path: Path) -> None:
          """run-context.json must carry the Layer 0/1 read-side blocks."""
          repo = _minimal_repo(tmp_path)
          (repo / "README.md").write_text("# Project\nsee [code](app.py)\n")
          (repo / "app.py").write_text("x = 1\n")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-27")
          for key in ("doc_graph", "doc_staleness", "stale_hubs", "dead_code", "observability"):
              assert key in ctx, f"missing read-side block: {key}"
          assert ctx["doc_graph"]["available"] is True
          assert ctx["doc_graph"]["doc_count"] == 1
          assert ctx["doc_staleness"]["available"] is True
          assert isinstance(ctx["stale_hubs"], list)
          assert "rung" in ctx["observability"]
          assert "candidate_count" in ctx["dead_code"]
      
      
      def test_keyhole_blocks_present_and_backward_compatible(git_repo) -> None:
          """Task #5 integration barrier: build_run_context emits the five new keyhole
          blocks + derived findings while leaving every existing block intact."""
          repo, commit = git_repo
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          # A small history so the change-coupling / containment / authorship signals
          # have real git data to chew on.
          (repo / "README.md").write_text("# Project\nsee [code](src/app.py)\n")
          (repo / "src").mkdir()
          (repo / "src" / "app.py").write_text("def f():\n    return 1\n")
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 1, "loc": {"p50": 2, "p95": 2, "max": 2},
              "ccn": {"p50": 1, "p95": 1, "max": 1},
              "top_hotspots": [{"path": "src/app.py", "loc": 2, "ccn": 1, "commits": 2}],
              "top_complex": [{"path": "src/app.py", "ccn": 1}],
              "top_large": [{"path": "src/app.py", "loc": 2}],
          }))
          commit("init")
          (repo / "src" / "app.py").write_text("def f():\n    return 2\n")
          commit("change")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-29")
      
          # (1) All five new blocks present.
          for key in ("structure", "behaviour", "documentation", "understanding", "runtime"):
              assert key in ctx, f"missing keyhole block: {key}"
          # structure carries available/reason so the report can say "grimp absent".
          assert "available" in ctx["structure"]
          assert "containment_by_dir" in ctx["behaviour"]
          assert "freshness_by_doc" in ctx["documentation"]
          assert "authorship_class_by_path" in ctx["understanding"]
          assert "static_reachability" in ctx["runtime"]
      
          # (2) derived_findings populated; every finding has name/paths/action.
          assert "derived_findings" in ctx
          findings = ctx["derived_findings"]
          assert findings, "derived_findings must not be empty"
          expected = {"hidden_coupling", "lying_map", "unexplained_complexity",
                      "untrusted_hotspot", "self_referential_tests",
                      "unactioned_intent", "accretion_ratchet",
                      "orphaned_understanding", "candidate_dead_weight",
                      "override_contradicts_signals", "refactor_boundary"}
          assert {f["name"] for f in findings} == expected
          for f in findings:
              assert set(f) == {"name", "paths", "action"}
              assert isinstance(f["paths"], list)
              assert isinstance(f["action"], str) and f["action"]
          assert "attention" in ctx
          assert isinstance(ctx["attention"], list)
      
          # (3) Existing blocks unchanged (backward-compat): the pre-existing shape
          # is all still there alongside the additions.
          for key in ("run_date", "stats_summary", "instruction_files", "diff",
                      "doc_graph", "doc_staleness", "stale_hubs", "dead_code",
                      "observability", "anomalies", "plugin_version"):
              assert key in ctx, f"existing block dropped: {key}"
      
      
      def test_keyhole_signal_failure_degrades_not_crashes(tmp_path: Path, monkeypatch) -> None:
          """A raising keyhole signal must degrade to available:false, not crash the
          run or disturb the existing blocks (defensive-wiring constraint)."""
          repo = _minimal_repo(tmp_path)
      
          def boom(*_a, **_k):
              raise RuntimeError("simulated structure failure")
      
          monkeypatch.setattr(assess_core, "analyze_structure", boom)
          ctx = build_run_context(repo_root=repo, run_date="2026-05-29")
          assert ctx["structure"]["available"] is False
          assert "failed" in ctx["structure"]["reason"]
          # The rest of the run is intact, including the other keyhole blocks.
          assert "behaviour" in ctx
          assert "derived_findings" in ctx
          assert "observability" in ctx
      
      
      def test_stale_hubs_join_centrality_and_staleness(tmp_path: Path) -> None:
          """stale_hubs ranks central docs by pagerank x staleness ratio."""
          repo = _minimal_repo(tmp_path)
          (repo / "hub.md").write_text("hub")
          for i in range(3):
              (repo / f"leaf{i}.md").write_text("see [hub](hub.md)")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-27")
          # Each stale-hub row carries both the centrality and staleness factors,
          # plus the subject_method + confidence that surface coarse-proxy entries.
          if ctx["stale_hubs"]:
              row = ctx["stale_hubs"][0]
              assert {"path", "pagerank", "ratio", "priority",
                      "subject_method", "confidence"} <= set(row)
              assert row["confidence"] in {"low", "high"}
      
      
      def test_stale_hubs_confidence_low_for_repo_baseline(tmp_path: Path) -> None:
          """Hubs whose subject_method is repo-baseline must surface confidence=low.
      
          Without a derivable subject, the staleness ratio shares a denominator with
          every other baseline entry - the priority composite looks comparable when
          it isn't. The confidence flag lets the report discount accordingly.
          """
          repo = _minimal_repo(tmp_path)
          (repo / "hub.md").write_text("hub with no association")
          for i in range(3):
              (repo / f"leaf{i}.md").write_text("see [hub](hub.md)")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-28")
          # All docs here are floating (no co-location, no parallel docs/, no explicit
          # links), so every staleness entry falls back to repo-baseline.
          assert ctx["doc_staleness"]["available"] is True
          for d in ctx["doc_staleness"]["docs"]:
              if d["subject_method"] == "repo-baseline":
                  assert d["confidence"] == "low"
          for h in ctx["stale_hubs"]:
              if h["subject_method"] == "repo-baseline":
                  assert h["confidence"] == "low"
      
      
      def test_stale_hubs_sort_deweights_low_confidence(tmp_path: Path) -> None:
          """A precise-subject hub at half the raw priority of a baseline hub still
          outranks it. The sort multiplies low-confidence priority by 0.5.
          """
          from assess_core import _build_stale_hubs  # type: ignore[import-not-found]
      
          doc_graph = {
              "available": True,
              "hubs": [
                  {"path": "baseline.md", "pagerank": 1.0},
                  {"path": "precise.md", "pagerank": 0.6},
              ],
          }
          doc_staleness = {
              "available": True,
              "docs": [
                  {"path": "baseline.md", "last_commit_days": 100,
                   "code_churn_in_window": 500, "ratio": 100.0,
                   "subject_method": "repo-baseline", "confidence": "low"},
                  {"path": "precise.md", "last_commit_days": 100,
                   "code_churn_in_window": 20, "ratio": 80.0,
                   "subject_method": "nearest-ancestor", "confidence": "high"},
              ],
          }
          hubs = _build_stale_hubs(doc_graph, doc_staleness)
          # Raw priorities: baseline = 100.0 * 1.0 = 100; precise = 80.0 * 0.6 = 48.
          # After the 0.5x low-confidence multiplier in the sort: baseline -> 50,
          # precise -> 48; baseline still wins. Test the inverse case directly.
          doc_staleness_b = dict(doc_staleness)
          doc_staleness_b["docs"] = [
              {"path": "baseline.md", "last_commit_days": 100,
               "code_churn_in_window": 200, "ratio": 80.0,
               "subject_method": "repo-baseline", "confidence": "low"},
              {"path": "precise.md", "last_commit_days": 100,
               "code_churn_in_window": 20, "ratio": 70.0,
               "subject_method": "nearest-ancestor", "confidence": "high"},
          ]
          hubs = _build_stale_hubs(doc_graph, doc_staleness_b)
          # baseline raw = 80.0 -> sorted at 40; precise raw = 42.0 -> wins.
          assert hubs[0]["path"] == "precise.md"
          # Raw priority still reflects the unweighted composite (for transparency).
          assert hubs[0]["priority"] == 42.0
      
      
      def test_readside_scan_failure_degrades_not_crashes(tmp_path: Path, monkeypatch) -> None:
          """A raising scan must degrade to an unavailable marker, not blow up the run."""
          repo = _minimal_repo(tmp_path)
      
          def boom(*_a, **_k):
              raise RuntimeError("simulated scan failure")
      
          monkeypatch.setattr(assess_core, "build_doc_graph", boom)
          ctx = build_run_context(repo_root=repo, run_date="2026-05-27")
          assert ctx["doc_graph"]["available"] is False
          assert "failed" in ctx["doc_graph"]["reason"]
          # downstream blocks still present
          assert "observability" in ctx
          assert ctx["stale_hubs"] == []  # can't join hubs without a graph
      
      
      def test_failed_liveness_scan_is_not_scored_rung_0(tmp_path: Path, monkeypatch) -> None:
          """A failed liveness scan must read as 'not assessed' (rung null), not as a
          genuine rung 0 (no observability) - conflating them mis-scores Layer 1."""
          repo = _minimal_repo(tmp_path)
      
          def boom(*_a, **_k):
              raise RuntimeError("liveness blew up")
      
          monkeypatch.setattr(assess_core, "scan_liveness", boom)
          ctx = build_run_context(repo_root=repo, run_date="2026-05-27")
          assert ctx["observability"]["available"] is False
          assert ctx["observability"]["rung"] is None  # not 0
          assert "reason" in ctx["observability"]
          assert ctx["dead_code"]["available"] is False
      
      
      def test_plugin_version_in_ctx(tmp_path: Path) -> None:
          """ctx should include plugin_version so the LLM can surface it in the report.
      
          Mitigates the multi-version cache footgun: if /reload-plugins lands on an old
          cached version, the report shows that version and the user can spot the drift.
          """
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 0, "loc": {}, "ccn": {},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert "plugin_version" in ctx
          assert isinstance(ctx["plugin_version"], str)
          assert ctx["plugin_version"].count(".") >= 1
      
      
      def test_scans_github_claude_instructions(tmp_path: Path, fixtures_dir: Path) -> None:
          """The scan finds .github/claude-instructions.md - a real-world non-canonical location.
      
          Surfaced by the v1.4 meridian run: .github/claude-review-instructions.md was a
          legitimate 795-line breadcrumb file that the canonical-paths-only scan missed.
          """
          repo = tmp_path / "repo"
          repo.mkdir()
          github_dir = repo / ".github"
          github_dir.mkdir()
          (github_dir / "claude-instructions.md").write_text(
              (fixtures_dir / "good_instructions.md").read_text()
          )
      
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 0, "loc": {}, "ccn": {},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ".github/claude-instructions.md" in ctx["instruction_files"]
      
      
      def test_scans_github_claude_review_instructions(tmp_path: Path, fixtures_dir: Path) -> None:
          """The scan finds .github/claude-review-instructions.md (used by claude-review bots)."""
          repo = tmp_path / "repo"
          repo.mkdir()
          github_dir = repo / ".github"
          github_dir.mkdir()
          (github_dir / "claude-review-instructions.md").write_text(
              (fixtures_dir / "good_instructions.md").read_text()
          )
      
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 0, "loc": {}, "ccn": {},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ".github/claude-review-instructions.md" in ctx["instruction_files"]
      
      
      def test_scans_docs_subdirectory(tmp_path: Path, fixtures_dir: Path) -> None:
          """The scan finds docs/CLAUDE.md (some projects keep instruction files there)."""
          repo = tmp_path / "repo"
          repo.mkdir()
          docs_dir = repo / "docs"
          docs_dir.mkdir()
          (docs_dir / "CLAUDE.md").write_text(
              (fixtures_dir / "good_instructions.md").read_text()
          )
      
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 0, "loc": {}, "ccn": {},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert "docs/CLAUDE.md" in ctx["instruction_files"]
      
      
      def test_briefing_includes_loc_ccn_commits_and_status(tmp_path: Path) -> None:
          """The auto-generated briefing should reflect the actual stats, not be vague."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 10, "loc": {"p50": 10, "p95": 30, "max": 50},
              "ccn": {"p50": 1, "p95": 3, "max": 5},
              "top_hotspots": [
                  {"path": "src/foo.go", "loc": 500, "ccn": 20, "commits": 15},
              ],
              "top_complex": [{"path": "src/foo.go", "ccn": 20}],
              "top_large": [{"path": "src/foo.go", "loc": 500}],
          }))
          build_run_context(repo_root=repo, run_date="2026-05-22")
      
          page = next((assess_dir / "hotspots").iterdir())
          content = page.read_text(encoding="utf-8")
          assert "500 LOC" in content
          assert "max cyclomatic complexity 20" in content
          assert "15 commits" in content
          # has_tests should be "unknown" now, not "no"
          assert "Has test file | unknown" in content
      
      
      def test_has_sibling_test_detects_colocated_and_adjacent(tmp_path: Path) -> None:
          """Co-located and adjacent-dir test files lift has_tests from unknown to
          yes/no cheaply (issue #47, observation 6)."""
          repo = tmp_path / "repo"
          (repo / "go").mkdir(parents=True)
          (repo / "go" / "foo.go").write_text("package foo")
          (repo / "go" / "foo_test.go").write_text("package foo")  # co-located
          (repo / "ts").mkdir()
          (repo / "ts" / "bar.ts").write_text("export const x = 1")
          (repo / "ts" / "bar.test.ts").write_text("test")          # co-located .test.
          (repo / "py").mkdir()
          (repo / "py" / "baz.py").write_text("x = 1")              # no test
          (repo / "svc").mkdir()
          (repo / "svc" / "api.py").write_text("x = 1")
          (repo / "svc" / "__tests__").mkdir()
          (repo / "svc" / "__tests__" / "test_api.py").write_text("t")  # adjacent dir
      
          assert assess_core._has_sibling_test(repo, "go/foo.go") is True
          assert assess_core._has_sibling_test(repo, "ts/bar.ts") is True
          assert assess_core._has_sibling_test(repo, "py/baz.py") is False
          assert assess_core._has_sibling_test(repo, "svc/api.py") is True
          # The file is itself a test -> counts as covered.
          assert assess_core._has_sibling_test(repo, "go/foo_test.go") is True
          # Not on disk (e.g. a since-deleted path in a stats snapshot) -> unknown.
          assert assess_core._has_sibling_test(repo, "go/gone.go") is None
      
      
      def test_hotspot_page_shows_yes_when_sibling_test_exists(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          (repo / "src").mkdir(parents=True)
          (repo / "src" / "foo.go").write_text("package foo")
          (repo / "src" / "foo_test.go").write_text("package foo")
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 10, "loc": {"p50": 10, "p95": 30, "max": 50},
              "ccn": {"p50": 1, "p95": 3, "max": 5},
              "top_hotspots": [{"path": "src/foo.go", "loc": 500, "ccn": 20, "commits": 5}],
              "top_complex": [], "top_large": [],
          }))
          build_run_context(repo_root=repo, run_date="2026-05-22")
          content = next((assess_dir / "hotspots").iterdir()).read_text(encoding="utf-8")
          assert "Has test file | yes" in content
      
      
      def test_commits_read_from_legacy_churn_field(tmp_path: Path) -> None:
          """A stats snapshot using the legacy `churn` key still shows real commits in
          the hotspot page, not 0 (issue #47, observation 5)."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 10, "loc": {"p50": 10, "p95": 30, "max": 50},
              "ccn": {"p50": 1, "p95": 3, "max": 5},
              "top_hotspots": [{"path": "src/foo.go", "loc": 500, "ccn": 20, "churn": 33}],
              "top_complex": [], "top_large": [],
          }))
          build_run_context(repo_root=repo, run_date="2026-05-22")
          content = next((assess_dir / "hotspots").iterdir()).read_text(encoding="utf-8")
          assert "33 commits" in content
          assert "Commits in churn window | 33" in content
      
      
      def test_diff_is_reliable_pure_matrix() -> None:
          """Direct unit coverage of the pure reliability decision, independent of the
          full pipeline."""
          from assess_core import _diff_is_reliable  # type: ignore[import-not-found]
      
          # Missing prior version stamp.
          assert _diff_is_reliable(None, "1.0.0", 1, 1) == (
              False, "version not stamped in prior snapshot"
          )
          # Unparseable version.
          ok, note = _diff_is_reliable("not-a-version", "1.0.0", 1, 1)
          assert ok is False and "unparseable" in note
          # Schema delta.
          assert _diff_is_reliable("1.0.0", "1.0.1", 1, 2) == (
              False, "schema version changed 1->2"
          )
          # Major delta.
          assert _diff_is_reliable("1.9.9", "2.0.0", 1, 1) == (
              False, "major version changed 1.9.9->2.0.0"
          )
          # Minor/patch only -> reliable.
          assert _diff_is_reliable("1.2.3", "1.5.0", 1, 1) == (True, None)
          assert _diff_is_reliable("1.2.3", "1.2.3", 1, 1) == (True, None)
      
      
      def test_diff_unreliable_when_prior_snapshot_lacks_version_stamp(tmp_path: Path) -> None:
          """A prior snapshot that never stamped a plugin version can't establish
          comparability, so the diff is flagged unreliable to suppress phantom
          transitions (issue #47, observation 4). The note names the exact reason."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.prior.json").write_text(json.dumps({
              # No plugin_version: seeded by hand / written by an older plugin.
              "files_scored": 50, "loc": {}, "ccn": {},
              "top_hotspots": [{"path": "src/old.go", "loc": 500, "ccn": 20, "commits": 5}],
          }))
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "plugin_version": "1.12.0",
              "files_scored": 55, "loc": {}, "ccn": {},
              "top_hotspots": [{"path": "src/new.go", "loc": 400, "ccn": 18, "commits": 4}],
          }))
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ctx["diff_reliable"] is False
          assert ctx["diff_version_note"] == "version not stamped in prior snapshot"
          assert ctx["diff_trend_reset"] is False
      
      
      def test_diff_reliable_when_plugin_versions_match(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          stats = {
              "plugin_version": "1.12.0", "files_scored": 50, "loc": {}, "ccn": {},
              "top_hotspots": [{"path": "src/a.go", "loc": 500, "ccn": 20, "commits": 5}],
          }
          (assess_dir / "complexity-stats.prior.json").write_text(json.dumps(stats))
          (assess_dir / "complexity-stats.json").write_text(json.dumps(stats))
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ctx["diff_reliable"] is True
          assert ctx["diff_version_note"] is None
      
      
      def _seed_prior_current(
          tmp_path: Path, prior_extra: dict, current_extra: dict,
      ) -> Path:
          """Seed a repo with a prior and current stats sidecar sharing one hotspot,
          each merged with the caller's version/schema/tool stamps."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          base = {
              "files_scored": 50, "loc": {}, "ccn": {},
              "top_hotspots": [{"path": "src/a.go", "loc": 500, "ccn": 20, "commits": 5}],
          }
          (assess_dir / "complexity-stats.prior.json").write_text(
              json.dumps({**base, **prior_extra})
          )
          (assess_dir / "complexity-stats.json").write_text(
              json.dumps({**base, **current_extra})
          )
          return repo
      
      
      def test_diff_reliable_across_patch_bump_keeps_gate_armed(tmp_path: Path) -> None:
          """A patch/minor plugin bump with an unchanged schema and toolchain keeps the
          diff reliable so the trend and regression gate stay armed."""
          stamp = {"schema_version": 1, "lizard_version": "1.23.0"}
          repo = _seed_prior_current(
              tmp_path,
              {"plugin_version": "1.54.0", **stamp},
              {"plugin_version": "1.54.1", **stamp},
          )
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ctx["diff_reliable"] is True
          assert ctx["diff_version_note"] is None
          assert ctx["diff_trend_reset"] is False
      
      
      def test_diff_unreliable_on_lizard_version_change_names_tool(tmp_path: Path) -> None:
          """A complexity-backend version change voids the diff and names the tool, so
          a score shift from the tool isn't read as a real regression."""
          repo = _seed_prior_current(
              tmp_path,
              {"plugin_version": "1.54.1", "schema_version": 1, "lizard_version": "1.22.0"},
              {"plugin_version": "1.54.1", "schema_version": 1, "lizard_version": "1.23.0"},
          )
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ctx["diff_reliable"] is False
          assert "lizard" in ctx["diff_version_note"]
          assert "1.22.0->1.23.0" in ctx["diff_version_note"]
          assert ctx["diff_trend_reset"] is False
      
      
      def test_diff_major_bump_resets_trend(tmp_path: Path) -> None:
          """A MAJOR plugin bump voids the diff and flags a trend reset, which the
          report discloses explicitly."""
          stamp = {"schema_version": 1, "lizard_version": "1.23.0"}
          repo = _seed_prior_current(
              tmp_path,
              {"plugin_version": "1.54.1", **stamp},
              {"plugin_version": "2.0.0", **stamp},
          )
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ctx["diff_reliable"] is False
          assert ctx["diff_trend_reset"] is True
          assert "major version changed 1.54.1->2.0.0" in ctx["diff_version_note"]
      
      
      def test_diff_unreliable_on_schema_change(tmp_path: Path) -> None:
          """A stats schema-version delta voids the diff: the sidecar shape the diff
          reads moved, so the comparison isn't trustworthy."""
          repo = _seed_prior_current(
              tmp_path,
              {"plugin_version": "1.54.1", "schema_version": 1, "lizard_version": "1.23.0"},
              {"plugin_version": "1.54.2", "schema_version": 2, "lizard_version": "1.23.0"},
          )
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ctx["diff_reliable"] is False
          assert "schema version changed 1->2" in ctx["diff_version_note"]
          assert ctx["diff_trend_reset"] is False
      
      
      def test_tool_versions_surface_in_run_context(tmp_path: Path) -> None:
          """The toolchain the snapshot was produced with is surfaced for the report
          and gate to reason about comparability."""
          repo = _seed_prior_current(
              tmp_path,
              {"plugin_version": "1.54.1", "schema_version": 1, "lizard_version": "1.23.0"},
              {"plugin_version": "1.54.1", "schema_version": 1, "lizard_version": "1.23.0",
               "scc_version": "3.7.0"},
          )
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ctx["tool_versions"] == {"lizard": "1.23.0", "scc": "3.7.0"}
          assert ctx["prior_tool_versions"] == {"lizard": "1.23.0"}
          assert ctx["schema_version"] == 1
      
      
      def test_first_flagged_unknown_when_prior_seeded_without_history(tmp_path: Path) -> None:
          """When prior stats are seeded but first-flagged.json isn't, a hotspot that
          predates this run must read 'unknown', not today's date
          (issue #47, observation 7)."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          # Same hotspot in prior and current -> persistent, predates this run.
          (assess_dir / "complexity-stats.prior.json").write_text(json.dumps({
              "plugin_version": "1.12.0", "files_scored": 50, "loc": {}, "ccn": {},
              "top_hotspots": [{"path": "src/old.go", "loc": 500, "ccn": 20, "commits": 5}],
          }))
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "plugin_version": "1.12.0", "files_scored": 50, "loc": {}, "ccn": {},
              "top_hotspots": [{"path": "src/old.go", "loc": 510, "ccn": 21, "commits": 6}],
          }))
          # No first-flagged.json on disk.
          build_run_context(repo_root=repo, run_date="2026-05-22")
          page = next((assess_dir / "hotspots").iterdir()).read_text(encoding="utf-8")
          assert "First flagged: unknown" in page
          assert "First flagged: 2026-05-22" not in page
      
      
      def test_first_flagged_stamps_today_for_genuinely_new_hotspot(tmp_path: Path) -> None:
          """A hotspot absent from the prior snapshot is genuinely new -> today."""
          repo = tmp_path / "repo"
          repo.mkdir()
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.prior.json").write_text(json.dumps({
              "plugin_version": "1.12.0", "files_scored": 50, "loc": {}, "ccn": {},
              "top_hotspots": [{"path": "src/old.go", "loc": 500, "ccn": 20, "commits": 5}],
          }))
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "plugin_version": "1.12.0", "files_scored": 50, "loc": {}, "ccn": {},
              "top_hotspots": [{"path": "src/fresh.go", "loc": 400, "ccn": 18, "commits": 4}],
          }))
          build_run_context(repo_root=repo, run_date="2026-05-22")
          fresh_page = next(p for p in (assess_dir / "hotspots").iterdir()
                            if p.name.startswith("src-fresh-go-"))
          assert "First flagged: 2026-05-22" in fresh_page.read_text(encoding="utf-8")
      
      
      def test_config_excludes_apply_to_all_scans(tmp_path: Path) -> None:
          """`.assess/config.toml` excludes are loaded once by the orchestrator
          and applied uniformly to the doc graph, doc staleness, and liveness
          scan. A `regulatory-raw/` dir vanishes from every layer's view, not
          just the treemap. This is the single load-bearing test for the
          consistent-excludes design - if it passes, the schema rename and the
          per-scan plumbing are wired correctly end-to-end."""
          repo = _minimal_repo(tmp_path)
          # Config opt-in.
          (repo / ".assess" / "config.toml").write_text(
              'exclude_dirs = ["regulatory-raw"]\n',
              encoding="utf-8",
          )
          # Two docs (one in scope, one excluded) and two code files (same).
          (repo / "README.md").write_text("see [main](./src/app.py)\n", encoding="utf-8")
          (repo / "src").mkdir()
          (repo / "src" / "app.py").write_text("def used(): pass\n", encoding="utf-8")
          (repo / "regulatory-raw").mkdir()
          (repo / "regulatory-raw" / "notes.md").write_text("ref data note\n", encoding="utf-8")
          (repo / "regulatory-raw" / "loader.py").write_text("x = 1\n", encoding="utf-8")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-05-29")
      
          # Doc graph: only README.md is counted.
          assert ctx["doc_graph"]["doc_count"] == 1
          # Doc staleness: only the in-scope doc + code file are counted.
          assert ctx["doc_staleness"]["association"]["doc_count"] == 1
          assert ctx["doc_staleness"]["association"]["code_file_count"] == 1
          # Liveness: any candidate paths from the dead-code scan must not
          # mention regulatory-raw (vulture etc. would either skip the dir
          # via --exclude or get post-filtered).
          for c in ctx["dead_code"].get("candidates", []):
              assert "regulatory-raw" not in c.get("path", "")
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # Task 4 - test_pressure wiring into build_run_context / run-context.json
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_test_pressure_block_present_in_ct
    • test_assess_finalize.py 38 KB
      """End-to-end test for assess_finalize - the LLM write-back script."""
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      import pytest
      
      from assess_finalize import FinalizeValidationError, finalize_run
      
      
      def _seed_run_context(
          assess_dir: Path,
          *,
          denominator: int = 8,
          hotspots: tuple[str, ...] = ("src/foo.go",),
          mutation_run: bool = True,
          run_id: str | None = None,
      ) -> None:
          """Write a minimal valid run-context.json for finalize to reconcile against.
      
          finalize now reads run-context.json first and enforces invariants against it,
          so every finalize test seeds a matching context. Defaults produce a
          software-repo (denominator 8) context whose top hotspots and mutation state
          satisfy the invariants; individual tests override to exercise a violation.
          """
          ctx: dict = {
              "archetype": {"available": True, "denominator": denominator},
              "stats_summary": {
                  "top_hotspots": [{"path": p} for p in hotspots],
              },
              "mutation_not_run_cap": {
                  "applies": not mutation_run,
                  "mutation_run": mutation_run,
                  "max_layer6_band": "Present" if mutation_run else "Partial",
                  "annotation": (
                      None if mutation_run
                      else "truth-pressure unproven (mutation not run)"
                  ),
              },
          }
          if run_id is not None:
              ctx["run_id"] = run_id
          (assess_dir / "run-context.json").write_text(json.dumps(ctx), encoding="utf-8")
      
      
      def _seed_log_md(assess_dir: Path) -> None:
          """Seed a log.md with one entry that has placeholders awaiting LLM fill."""
          (assess_dir / "log.md").write_text(
              "# Assess Log\n\n"
              "## 2026-05-22\n\n"
              "- **Files scored:** 100\n"
              "- **AI Readiness:** 0.0 / 8 ((LLM fills in))\n"
              "- **Instructions grade:** B+\n"
              "- **Hotspot transitions:** 1 graduated, 0 regressed, 0 new, 2 persistent\n"
              "- **Top action:** Deterministic ranker not yet wired (LLM picks Top 3)\n\n"
              "[Full report](./assess-report.md)\n\n"
              "---\n",
              encoding="utf-8",
          )
      
      
      def _seed_hotspot_page(assess_dir: Path, slug: str) -> None:
          (assess_dir / "hotspots").mkdir(exist_ok=True)
          (assess_dir / "hotspots" / f"{slug}.md").write_text(
              "# Hotspot: `src/foo.go`\n\n"
              "## Suggested actions\n\n"
              "- Pending LLM-generated suggestions\n",
              encoding="utf-8",
          )
      
      
      def test_finalize_updates_log_last_entry(tmp_assess_dir: Path) -> None:
          """The latest log.md entry gets its placeholders replaced with LLM-provided values."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          finalize_input = {
              "score": 6.0,
              "maturity_label": "Solid",
              "top_action": "Add cyclop rule to .golangci.yml (threshold 15)",
              "hotspot_actions": {},
          }
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
          content = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert "AI Readiness:** 6.0 / 8 (Solid)" in content
          assert "Top action:** Add cyclop rule to .golangci.yml (threshold 15)" in content
          # Placeholders must be gone
          assert "((LLM fills in))" not in content
          assert "Deterministic ranker not yet wired" not in content
      
      
      def test_finalize_knowledge_base_denominator(tmp_assess_dir: Path) -> None:
          """A KB run finalises the log over its applicable-layer denominator (#224).
      
          The score is renormalised in the log/report line; it is deliberately NOT
          written to the badge, which stays the deterministic form assess_core wrote.
          """
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir, denominator=3)
          finalize_input = {
              "score": 2.5,
              "maturity_label": "Knowledge Base · Solid (3 applicable layers)",
              "denominator": 3,
              "top_action": "Document the KB maintenance workflow in CLAUDE.md",
              "hotspot_actions": {},
          }
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
          content = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert "AI Readiness:** 2.5 / 3 (Knowledge Base · Solid (3 applicable layers))" in content
          assert "/ 8" not in content  # the misleading software denominator is gone
          # Finalize writes no badge - the score lives in the report line above, not
          # on the shipped (deterministic) badge.
          assert not (tmp_assess_dir / "badge.json").exists()
      
      
      def test_finalize_updates_hotspot_actions(tmp_assess_dir: Path) -> None:
          """Hotspot pages get their 'Suggested actions' section rewritten with LLM input."""
          # The slug for "src/foo.go" includes a sha256[:8] hash - use the same function the script uses
          from lib.wiki_writer import slug_for_path
          slug = slug_for_path("src/foo.go")
          _seed_hotspot_page(tmp_assess_dir, slug=slug)
          finalize_input = {
              "score": 6.0,
              "maturity_label": "Solid",
              "top_action": "x",
              "hotspot_actions": {
                  "src/foo.go": [
                      "Split parseLine into smaller functions",
                      "Add a test file at src/foo_test.go",
                  ],
              },
          }
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
          # Need a log.md too since finalize_run does both
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
      
          finalize_run(assess_dir=tmp_assess_dir)
          page = (tmp_assess_dir / "hotspots" / f"{slug}.md").read_text(encoding="utf-8")
          assert "Split parseLine" in page
          assert "Add a test file at src/foo_test.go" in page
          assert "Pending LLM-generated suggestions" not in page
      
      
      def test_finalize_missing_input_raises(tmp_assess_dir: Path) -> None:
          """If finalize-input.json doesn't exist, raise a clear error."""
          with pytest.raises(FileNotFoundError):
              finalize_run(assess_dir=tmp_assess_dir)
      
      
      def test_finalize_hotspot_without_match_is_skipped(tmp_assess_dir: Path) -> None:
          """An entry in hotspot_actions whose page doesn't exist is silently skipped.
      
          This is forward-compatible with the path lifecycle: a hotspot might graduate
          between when the LLM read the data and when finalize runs.
          """
          _seed_log_md(tmp_assess_dir)
          # The referenced path IS a real top hotspot in run-context (so it clears the
          # fabrication invariant); its wiki page simply doesn't exist, which is the
          # lifecycle case this test covers (graduated between LLM read and finalize).
          _seed_run_context(tmp_assess_dir, hotspots=("src/nonexistent.go",))
          finalize_input = {
              "score": 6.0,
              "maturity_label": "Solid",
              "top_action": "x",
              "hotspot_actions": {"src/nonexistent.go": ["something"]},
          }
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
      
          # Should not raise
          finalize_run(assess_dir=tmp_assess_dir)
      
      
      def test_finalize_log_handles_backslash_in_top_action(tmp_assess_dir: Path) -> None:
          """Top action text containing \\1 must be inserted literally, not as a regex backreference."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          finalize_input = {
              "score": 6.0,
              "maturity_label": "Solid",
              # Adversarial: contains \1 which would be a re.sub backreference if not handled
              "top_action": r"Replace `\1` capture group references in regexes",
              "hotspot_actions": {},
          }
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
          content = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert r"Replace `\1` capture group references" in content
      
      
      def test_finalize_hotspot_action_handles_backslash(tmp_assess_dir: Path) -> None:
          """Hotspot action text containing \\1 must be inserted literally."""
          from lib.wiki_writer import slug_for_path
          slug = slug_for_path("src/foo.go")
          _seed_hotspot_page(tmp_assess_dir, slug=slug)
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          finalize_input = {
              "score": 6.0,
              "maturity_label": "Solid",
              "top_action": "x",
              "hotspot_actions": {
                  "src/foo.go": [
                      r"Use \1 to denote first capture group",
                      "Add tests",
                  ],
              },
          }
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
          page = (tmp_assess_dir / "hotspots" / f"{slug}.md").read_text(encoding="utf-8")
          assert r"Use \1 to denote first capture group" in page
      
      
      def test_finalize_reads_from_cache_path(tmp_assess_dir: Path) -> None:
          """The preferred input location is `.assess/.cache/finalize-input.json`."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          cache_dir = tmp_assess_dir / ".cache"
          cache_dir.mkdir()
          (cache_dir / "finalize-input.json").write_text(
              json.dumps({
                  "score": 7.0, "maturity_label": "AI-Native",
                  "top_action": "x", "hotspot_actions": {},
              }),
              encoding="utf-8",
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
          content = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert "AI Readiness:** 7.0 / 8 (AI-Native)" in content
      
      
      def test_finalize_deletes_input_after_success(tmp_assess_dir: Path) -> None:
          """The input file is one-off: delete it on success so it can't leak into commits."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          legacy = tmp_assess_dir / "finalize-input.json"
          legacy.write_text(
              json.dumps({
                  "score": 6.0, "maturity_label": "Solid",
                  "top_action": "x", "hotspot_actions": {},
              }),
              encoding="utf-8",
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
          assert not legacy.exists()
      
      
      def test_finalize_cleans_up_legacy_when_cache_present(tmp_assess_dir: Path) -> None:
          """If both cache and legacy paths exist (e.g. an older run left a stale
          legacy file), finalize prefers cache and cleans up both.
          """
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          cache_dir = tmp_assess_dir / ".cache"
          cache_dir.mkdir()
          cache_path = cache_dir / "finalize-input.json"
          cache_path.write_text(
              json.dumps({
                  "score": 7.0, "maturity_label": "AI-Native",
                  "top_action": "current", "hotspot_actions": {},
              }),
              encoding="utf-8",
          )
          legacy = tmp_assess_dir / "finalize-input.json"
          legacy.write_text(
              json.dumps({
                  "score": 3.0, "maturity_label": "Basic",
                  "top_action": "stale", "hotspot_actions": {},
              }),
              encoding="utf-8",
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
          # Cache wins (the value the report just published).
          content = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert "AI-Native" in content
          # Both files cleaned up.
          assert not cache_path.exists()
          assert not legacy.exists()
      
      
      def test_finalize_updates_last_entry_when_older_entry_unfinalized(tmp_assess_dir: Path) -> None:
          """When an older log entry still has placeholders, finalize updates the LATEST.
      
          Log entries are appended (newest at bottom). If we finalized the first match,
          we'd overwrite stale historical data and leave the new run unfilled.
          The older unfinalized entry stays as evidence of "this run wasn't finalized."
          """
          _seed_run_context(tmp_assess_dir)
          # Seed two entries: older (unfinalized, placeholders intact) then newer (also placeholders)
          (tmp_assess_dir / "log.md").write_text(
              "# Assess Log\n\n"
              "## 2026-05-01\n\n"
              "- **Files scored:** 80\n"
              "- **AI Readiness:** 0.0 / 8 ((LLM fills in))\n"
              "- **Top action:** Deterministic ranker not yet wired (LLM picks Top 3)\n\n"
              "---\n\n"
              "## 2026-05-22\n\n"
              "- **Files scored:** 100\n"
              "- **AI Readiness:** 0.0 / 8 ((LLM fills in))\n"
              "- **Top action:** Deterministic ranker not yet wired (LLM picks Top 3)\n\n"
              "---\n",
              encoding="utf-8",
          )
          finalize_input = {
              "score": 6.5,
              "maturity_label": "Solid",
              "top_action": "Add cyclop rule (threshold 15) to .golangci.yml",
              "hotspot_actions": {},
          }
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
          content = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
      
          # The 2026-05-22 (latest) entry got filled
          latest_section = content.split("## 2026-05-22")[1]
          assert "AI Readiness:** 6.5 / 8 (Solid)" in latest_section
          assert "Top action:** Add cyclop rule (threshold 15) to .golangci.yml" in latest_section
      
          # The 2026-05-01 (older) entry's placeholders are PRESERVED as historical evidence
          older_section = content.split("## 2026-05-22")[0]
          assert "AI Readiness:** 0.0 / 8 ((LLM fills in))" in older_section
          assert "Deterministic ranker not yet wired" in older_section
      
      
      def _base_input() -> dict:
          return {
              "score": 6.0,
              "maturity_label": "Solid",
              "top_action": "Add cyclop rule",
              "hotspot_actions": {},
          }
      
      
      def _good_action(rank: int = 1) -> dict:
          return {
              "rank": rank,
              "action": "Add cyclop rule (threshold 15) to .golangci.yml",
              "layer": 3,
              "effort": "small",
              "files": [".golangci.yml"],
              "first_step": "Add cyclop under linters",
              "done_when": "golangci-lint run passes with the rule active",
              "scope_fence": "Only .golangci.yml; no source edits",
          }
      
      
      def _distinct_action(rank: int, *, finding: str | None = None) -> dict:
          """A good action with a rank-unique directive (the carry-forward identity)."""
          a = {**_good_action(rank=rank), "action": f"Action number {rank}"}
          if finding is not None:
              a["finding"] = finding
          return a
      
      
      def test_finalize_writes_actions_contract(tmp_assess_dir: Path) -> None:
          """An `actions` array in the input becomes the durable .assess/actions.json,
          sorted by rank, with the executor-critical fields intact (v2 schema)."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir, run_id="run-abc")
          finalize_input = {
              **_base_input(),
              "actions": [_distinct_action(rank=2), _distinct_action(rank=1)],
          }
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          contract = json.loads(
              (tmp_assess_dir / "actions.json").read_text(encoding="utf-8")
          )
          assert contract["schema"] == 2
          assert contract["run_id"] == "run-abc"
          assert [a["rank"] for a in contract["actions"]] == [1, 2]
          for a in contract["actions"]:
              assert a["done_when"]
              assert a["scope_fence"]
              # v2 lifecycle fields present and initialised for a first run.
              assert a["status"] == "pending"
              assert a["claimed_by"] is None
              assert a["completed_sha"] is None
              assert a["mode"] in {
                  "characterize_first", "verify_then_retire", "refactor_safe",
              }
      
      
      def test_finalize_derives_mode_from_finding(tmp_assess_dir: Path) -> None:
          """`mode` is derived deterministically from each action's finding type; an
          action with no finding falls back to the conservative characterize_first."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          finalize_input = {
              **_base_input(),
              "actions": [
                  _distinct_action(rank=1, finding="lying_map"),
                  _distinct_action(rank=2, finding="refactor_boundary"),
                  _distinct_action(rank=3),  # no finding -> default
              ],
          }
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          by_rank = {
              a["rank"]: a
              for a in json.loads(
                  (tmp_assess_dir / "actions.json").read_text(encoding="utf-8")
              )["actions"]
          }
          assert by_rank[1]["mode"] == "verify_then_retire"
          assert by_rank[2]["mode"] == "refactor_safe"
          assert by_rank[3]["mode"] == "characterize_first"
      
      
      def test_finalize_carries_status_across_runs(tmp_assess_dir: Path) -> None:
          """A done action stays done with its completed_sha on re-run - status,
          claimed_by, and completed_sha are carried forward by the action directive."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          # Run 1: writes a pending contract.
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps({**_base_input(), "actions": [_distinct_action(rank=1)]}),
              encoding="utf-8",
          )
          finalize_run(assess_dir=tmp_assess_dir)
      
          # An executor marks the action done out of band.
          contract_path = tmp_assess_dir / "actions.json"
          contract = json.loads(contract_path.read_text(encoding="utf-8"))
          contract["actions"][0].update(
              status="done", claimed_by="agent-7", completed_sha="deadbeef",
          )
          contract_path.write_text(json.dumps(contract), encoding="utf-8")
      
          # Run 2: the same action re-appears (rank changed) - it must stay done.
          # Same directive text (the carry-forward key), new rank.
          _seed_log_md(tmp_assess_dir)
          reranked = {**_distinct_action(rank=1), "rank": 3}
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps({**_base_input(), "actions": [reranked]}),
              encoding="utf-8",
          )
          finalize_run(assess_dir=tmp_assess_dir)
      
          after = json.loads(contract_path.read_text(encoding="utf-8"))["actions"][0]
          assert after["status"] == "done"
          assert after["claimed_by"] == "agent-7"
          assert after["completed_sha"] == "deadbeef"
          assert after["rank"] == 3  # rank is recomputed, not carried
      
      
      def test_finalize_reads_v1_actions_for_carry_forward(tmp_assess_dir: Path) -> None:
          """A pre-existing v1 actions.json (no lifecycle fields) is read without
          error; the re-run upgrades it to v2 with every action initialised pending."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          # A v1-shaped contract on disk from before this schema existed.
          (tmp_assess_dir / "actions.json").write_text(
              json.dumps({
                  "schema": 1,
                  "actions": [{
                      "rank": 1,
                      "action": "Action number 1",
                      "done_when": "x",
                      "scope_fence": "y",
                  }],
              }),
              encoding="utf-8",
          )
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps({**_base_input(), "actions": [_distinct_action(rank=1)]}),
              encoding="utf-8",
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          contract = json.loads(
              (tmp_assess_dir / "actions.json").read_text(encoding="utf-8")
          )
          assert contract["schema"] == 2
          entry = contract["actions"][0]
          assert entry["status"] == "pending"
          assert entry["claimed_by"] is None
          assert entry["completed_sha"] is None
      
      
      def test_finalize_without_actions_writes_no_contract(tmp_assess_dir: Path) -> None:
          """Backwards compatibility: an input with no `actions` key (the pre-1.41
          shape) finalizes the wiki exactly as before and writes no actions.json."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(_base_input()), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          assert not (tmp_assess_dir / "actions.json").exists()
          content = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert "AI Readiness:** 6.0 / 8 (Solid)" in content
      
      
      def test_finalize_drops_malformed_action_entries(tmp_assess_dir: Path) -> None:
          """An entry missing done_when/scope_fence is dropped (a malformed contract
          must not reach an executor as if complete); valid siblings still land."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          incomplete = {"rank": 2, "action": "vague intention"}  # no done_when/fence
          finalize_input = {
              **_base_input(),
              "actions": [_good_action(rank=1), incomplete],
          }
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          contract = json.loads(
              (tmp_assess_dir / "actions.json").read_text(encoding="utf-8")
          )
          assert len(contract["actions"]) == 1
          assert contract["actions"][0]["rank"] == 1
      
      
      def test_finalize_all_actions_malformed_writes_no_contract(tmp_assess_dir: Path) -> None:
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          finalize_input = {**_base_input(), "actions": [{"rank": 1}]}
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(finalize_input), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          assert not (tmp_assess_dir / "actions.json").exists()
      
      
      def test_finalize_leaves_deterministic_badge_untouched(tmp_assess_dir: Path) -> None:
          """Finalize does NOT overwrite the deterministic badge with the LLM score.
      
          The shipped badge stays the findings-count form assess_core wrote; the
          LLM-derived score lands in the report/log instead, so the badge never claims
          a grade a deterministic run cannot reproduce.
          """
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          deterministic_badge = (
              '{"schemaVersion": 1, "label": "AI-readiness", '
              '"message": "9 findings · 9 stale markers", "color": "orange", '
              '"link": "./assess-report.md"}'
          )
          (tmp_assess_dir / "badge.json").write_text(deterministic_badge, encoding="utf-8")
          (tmp_assess_dir / "finalize-input.json").write_text(
              json.dumps(_base_input()), encoding="utf-8"
          )
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          badge = json.loads((tmp_assess_dir / "badge.json").read_text(encoding="utf-8"))
          # Badge unchanged: still the deterministic findings form, no LLM score.
          assert badge["message"] == "9 findings · 9 stale markers"
          assert "6.0/8" not in badge["message"]
          assert badge["link"] == "./assess-report.md"
          # The LLM score still lands where it belongs: the report/log entry.
          log = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert "**AI Readiness:** 6.0 / 8 (Solid)" in log
      
      
      # --- Task 1: finalize reconciles run-context invariants (fail-closed) --------
      
      
      def _write_input(assess_dir: Path, data: dict) -> None:
          (assess_dir / "finalize-input.json").write_text(json.dumps(data), encoding="utf-8")
      
      
      def test_finalize_missing_run_context_refuses(tmp_assess_dir: Path) -> None:
          """A present input but no run-context.json is a hard, named failure - there
          is nothing to reconcile against, so finalize refuses and writes nothing."""
          _seed_log_md(tmp_assess_dir)  # note: no _seed_run_context
          _write_input(tmp_assess_dir, _base_input())
      
          with pytest.raises(FinalizeValidationError, match="run-context.json missing"):
              finalize_run(assess_dir=tmp_assess_dir)
      
          # Nothing written: the log placeholder survives untouched.
          content = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert "((LLM fills in))" in content
          assert not (tmp_assess_dir / "actions.json").exists()
      
      
      def test_finalize_denominator_mismatch_refuses(tmp_assess_dir: Path) -> None:
          """The input denominator must match run-context archetype.denominator."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir, denominator=3)  # KB
          _write_input(tmp_assess_dir, {**_base_input(), "denominator": 8})
      
          with pytest.raises(FinalizeValidationError, match="denominator mismatch"):
              finalize_run(assess_dir=tmp_assess_dir)
      
      
      def test_finalize_score_exceeds_denominator_refuses(tmp_assess_dir: Path) -> None:
          """A score above its denominator is impossible - refuse."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          _write_input(
              tmp_assess_dir,
              {**_base_input(), "score": 9.0, "maturity_label": "AI-Native"},
          )
      
          with pytest.raises(FinalizeValidationError, match="exceeds denominator"):
              finalize_run(assess_dir=tmp_assess_dir)
      
      
      def test_finalize_maturity_inconsistent_with_score_refuses(tmp_assess_dir: Path) -> None:
          """A label that overstates the score band (2.0/8 called 'AI-Native') is a
          lying self-description - refuse, naming the expected tier."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          _write_input(
              tmp_assess_dir,
              {**_base_input(), "score": 2.0, "maturity_label": "AI-Native"},
          )
      
          with pytest.raises(FinalizeValidationError, match="claims tier 'AI-Native'"):
              finalize_run(assess_dir=tmp_assess_dir)
      
      
      def test_finalize_valid_maturity_bands_pass(tmp_assess_dir: Path) -> None:
          """The documented ladder is accepted verbatim: each score earns its label."""
          for score, label in [
              (7.0, "AI-Native"),   # 0.875
              (6.0, "Solid"),       # 0.75
              (3.0, "Basic"),       # 0.375
              (1.0, "Not Ready"),   # 0.125
          ]:
              _seed_log_md(tmp_assess_dir)
              _seed_run_context(tmp_assess_dir)
              _write_input(
                  tmp_assess_dir,
                  {**_base_input(), "score": score, "maturity_label": label},
              )
              finalize_run(assess_dir=tmp_assess_dir)  # must not raise
      
      
      def test_finalize_fabricated_hotspot_path_refuses(tmp_assess_dir: Path) -> None:
          """A hotspot_actions key absent from run-context top_hotspots is fabricated;
          the error names the offending path."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir, hotspots=("src/foo.go",))
          _write_input(
              tmp_assess_dir,
              {**_base_input(), "hotspot_actions": {"src/invented.go": ["do a thing"]}},
          )
      
          with pytest.raises(FinalizeValidationError, match=r"src/invented\.go"):
              finalize_run(assess_dir=tmp_assess_dir)
      
      
      def test_finalize_writes_nothing_on_violation(tmp_assess_dir: Path) -> None:
          """A violation short-circuits before any write: no badge, no actions.json,
          log placeholders intact, and the input file is NOT consumed (so a fixed
          rerun can still find it)."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          bad = {**_base_input(), "score": 2.0, "maturity_label": "AI-Native",
                 "actions": [_good_action(rank=1)]}
          _write_input(tmp_assess_dir, bad)
      
          with pytest.raises(FinalizeValidationError):
              finalize_run(assess_dir=tmp_assess_dir)
      
          assert "((LLM fills in))" in (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert not (tmp_assess_dir / "actions.json").exists()
          assert not (tmp_assess_dir / "badge.json").exists()
          assert (tmp_assess_dir / "finalize-input.json").exists()  # not consumed
      
      
      # --- Task 2: run_id torn-write detection -------------------------------------
      
      
      def test_finalize_run_id_mismatch_refuses(tmp_assess_dir: Path) -> None:
          """finalize-input and run-context from different runs (mismatched run_id) is
          a torn write - refuse."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir, run_id="20260707120000-aaaaaaaa")
          _write_input(tmp_assess_dir, {**_base_input(), "run_id": "20260707130000-bbbbbbbb"})
      
          with pytest.raises(FinalizeValidationError, match="torn write"):
              finalize_run(assess_dir=tmp_assess_dir)
      
      
      def test_finalize_run_id_match_finalizes_log(tmp_assess_dir: Path) -> None:
          """A matching run_id passes the torn-write check and finalize completes.
      
          (The badge is deterministic and written by assess_core, so finalize no
          longer stamps it - the run_id proves the input was authored against this
          run, which is what unblocks the write-back.)
          """
          _seed_log_md(tmp_assess_dir)
          run_id = "20260707120000-abcdef01"
          _seed_run_context(tmp_assess_dir, run_id=run_id)
          _write_input(tmp_assess_dir, {**_base_input(), "run_id": run_id})
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          log = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert "**AI Readiness:** 6.0 / 8 (Solid)" in log
      
      
      def test_finalize_legacy_input_without_run_id_still_works(tmp_assess_dir: Path) -> None:
          """A legacy input carrying no run_id finalises even when run-context has one
          (backward compat: the torn-write check needs both stamps to fire)."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir, run_id="20260707120000-abcdef01")
          _write_input(tmp_assess_dir, _base_input())  # no run_id
      
          finalize_run(assess_dir=tmp_assess_dir)  # must not raise
          assert "AI Readiness:** 6.0 / 8 (Solid)" in (
              tmp_assess_dir / "log.md"
          ).read_text(encoding="utf-8")
      
      
      # --- Task 8: Layer 6 capped at Partial when mutation never ran ---------------
      
      
      def test_finalize_layer6_present_without_mutation_refuses(tmp_assess_dir: Path) -> None:
          """Mutation never ran + LLM scores Layer 6 Present (1.0) -> finalize rejects,
          naming the required annotation."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir, mutation_run=False)
          _write_input(tmp_assess_dir, {**_base_input(), "layer_scores": {"6": 1.0}})
      
          with pytest.raises(
              FinalizeValidationError,
              match="truth-pressure unproven",
          ):
              finalize_run(assess_dir=tmp_assess_dir)
      
      
      def test_finalize_layer6_present_with_mutation_allowed(tmp_assess_dir: Path) -> None:
          """When mutation actually ran, a Present Layer 6 is allowed."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir, mutation_run=True)
          _write_input(tmp_assess_dir, {**_base_input(), "layer_scores": {"6": 1.0}})
      
          finalize_run(assess_dir=tmp_assess_dir)  # must not raise
      
      
      def test_finalize_layer6_partial_without_mutation_allowed(tmp_assess_dir: Path) -> None:
          """Partial (0.5) is the ceiling when mutation didn't run - allowed."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir, mutation_run=False)
          _write_input(tmp_assess_dir, {**_base_input(), "layer_scores": {"6": 0.5}})
      
          finalize_run(assess_dir=tmp_assess_dir)  # must not raise
      
      
      # --- #362: finalize re-checks the evidence a layer verdict cites -------------
      
      
      def _seed_evidence_repo(assess_dir: Path) -> Path:
          """The repository root around ``assess_dir``: a doc, and a script a CI
          workflow calls. Returns the root (the parent of ``.assess/``)."""
          root = assess_dir.parent
          (root / "docs").mkdir(exist_ok=True)
          (root / "docs" / "guide.md").write_text("# guide\n", encoding="utf-8")
          (root / "scripts").mkdir(exist_ok=True)
          (root / "scripts" / "check-x.sh").write_text("echo ok\n", encoding="utf-8")
          wf = root / ".github" / "workflows"
          wf.mkdir(parents=True, exist_ok=True)
          (wf / "ci.yml").write_text(
              "on: push\njobs:\n  lint:\n    runs-on: ubuntu-latest\n"
              "    steps:\n      - run: bash scripts/check-x.sh\n",
              encoding="utf-8",
          )
          return root
      
      
      def _evidence_input(evidence: list | None) -> dict:
          data = {**_base_input(), "layer_scores": {"0": 1.0, "7": 1.0}}
          if evidence is not None:
              data["evidence"] = evidence
          return data
      
      
      def _assert_nothing_written(assess_dir: Path) -> None:
          assert "((LLM fills in))" in (assess_dir / "log.md").read_text(encoding="utf-8")
          assert (assess_dir / "finalize-input.json").exists()  # not consumed
      
      
      def test_finalize_evidence_path_absent_for_existing_file_refuses(tmp_assess_dir: Path) -> None:
          """A layer resting only on "docs/guide.md is absent", when it exists, is
          refused before any write, and the message names the entry."""
          _seed_evidence_repo(tmp_assess_dir)
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          _write_input(tmp_assess_dir, _evidence_input(
              [{"layer": 0, "kind": "path_absent", "path": "docs/guide.md"}]
          ))
      
          with pytest.raises(FinalizeValidationError, match=r"path_absent docs/guide\.md") as exc:
              finalize_run(assess_dir=tmp_assess_dir)
          assert "layer 0" in str(exc.value)
          _assert_nothing_written(tmp_assess_dir)
      
      
      def test_finalize_evidence_not_referenced_in_called_script_refuses(tmp_assess_dir: Path) -> None:
          """"No workflow calls scripts/check-x.sh", when ci.yml calls it, is refused;
          the message names the kind, the searched path and the needle."""
          _seed_evidence_repo(tmp_assess_dir)
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          _write_input(tmp_assess_dir, _evidence_input([{
              "layer": 7, "kind": "not_referenced_in",
              "needle": "scripts/check-x.sh", "path": ".github/workflows",
          }]))
      
          with pytest.raises(FinalizeValidationError) as exc:
              finalize_run(assess_dir=tmp_assess_dir)
          msg = str(exc.value)
          assert "not_referenced_in" in msg
          assert ".github/workflows" in msg
          assert "scripts/check-x.sh" in msg
          _assert_nothing_written(tmp_assess_dir)
      
      
      def test_finalize_evidence_all_true_finalizes(tmp_assess_dir: Path) -> None:
          """Every entry verifies: finalize writes exactly as it does without evidence."""
          _seed_evidence_repo(tmp_assess_dir)
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          _write_input(tmp_assess_dir, _evidence_input([
              {"layer": 7, "kind": "referenced_in",
               "needle": "scripts/check-x.sh", "path": ".github/workflows"},
              {"layer": 0, "kind": "path_absent", "path": "docs/missing.md"},
              {"layer": 0, "kind": "file_contains", "path": "docs/guide.md", "needle": "guide"},
          ]))
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          log = (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          assert "**AI Readiness:** 6.0 / 8 (Solid)" in log
          assert "((LLM fills in))" not in log
          assert not (tmp_assess_dir / "finalize-input.json").exists()
      
      
      def test_finalize_without_evidence_key_finalizes(tmp_assess_dir: Path) -> None:
          """An input with no evidence key (every input before #362) is accepted,
          as a legacy input without layer_scores is."""
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          _write_input(tmp_assess_dir, _evidence_input(None))
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          assert "**AI Readiness:** 6.0 / 8 (Solid)" in (tmp_assess_dir / "log.md").read_text(
              encoding="utf-8"
          )
      
      
      def test_finalize_evidence_mixed_layer_finalizes_and_warns(
          tmp_assess_dir: Path, capsys: pytest.CaptureFixture[str]
      ) -> None:
          """A layer with at least one verified entry keeps its verdict: finalize
          writes, and names each rejected entry on stderr as a warning."""
          _seed_evidence_repo(tmp_assess_dir)
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          _write_input(tmp_assess_dir, _evidence_input([
              {"layer": 0, "kind": "path_exists", "path": "docs/guide.md"},
              {"layer": 0, "kind": "path_exists", "path": "docs/invented.md"},
          ]))
      
          finalize_run(assess_dir=tmp_assess_dir)
      
          assert "((LLM fills in))" not in (tmp_assess_dir / "log.md").read_text(encoding="utf-8")
          err = capsys.readouterr().err
          assert "warning" in err
          assert "path_exists docs/invented.md" in err
          assert "docs/guide.md" not in err
      
      
      def test_finalize_evidence_one_layer_all_rejected_refuses_despite_other_layers(
          tmp_assess_dir: Path,
      ) -> None:
          """The rule is per layer: layer 7 verifying does not carry a layer 0 whose
          only entry is false."""
          _seed_evidence_repo(tmp_assess_dir)
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          _write_input(tmp_assess_dir, _evidence_input([
              {"layer": 7, "kind": "referenced_in",
               "needle": "scripts/check-x.sh", "path": ".github/workflows"},
              {"layer": 0, "kind": "path_exists", "path": "docs/invented.md"},
          ]))
      
          with pytest.raises(FinalizeValidationError, match=r"layer 0.*path_exists docs/invented\.md"):
              finalize_run(assess_dir=tmp_assess_dir)
          _assert_nothing_written(tmp_assess_dir)
      
      
      @pytest.mark.parametrize(
          ("evidence", "match"),
          [
              ({"layer": 0}, "must be a list"),
              (["docs/guide.md"], "not an object"),
              ([{"kind": "path_exists", "path": "docs/guide.md"}], "layer"),
              ([{"layer": 9, "kind": "path_exists", "path": "docs/guide.md"}], "layer"),
              ([{"layer": True, "kind": "path_exists", "path": "docs/guide.md"}], "layer"),
          ],
      )
      def test_finalize_malformed_evidence_refuses(
          tmp_assess_dir: Path, evidence: object, match: str
      ) -> None:
          """A malformed evidence value cannot be attributed to a layer, so it fails
          closed rather than being skipped."""
          _seed_evidence_repo(tmp_assess_dir)
          _seed_log_md(tmp_assess_dir)
          _seed_run_context(tmp_assess_dir)
          _write_input(tmp_assess_dir, {**_base_input(), "evidence": evidence})
      
          with pytest.raises(FinalizeValidationError, match=match):
              finalize_run(assess_dir=tmp_assess_dir)
          _assert_nothing_written(tmp_assess_dir)
      
      
      def test_finalize_evidence_refusal_through_core_and_cli(tmp_path: Path) -> None:
          """End to end: the log comes from the real core, and the CLI prints
          "finalize refused" naming the entry on stderr, exits 1, and writes nothing."""
          import subprocess
          import sys
      
          from assess_core import build_run_context
      
          root = tmp_path / "repo"
          (root / ".assess").mkdir(parents=True)
          _seed_evidence_repo(root / ".assess")
          ctx = build_run_context(repo_root=root, run_date="2026-09-18", non_interactive=True)
          den = int(ctx["archetype"].get("denominator") or 8)
          cache = root / ".assess" / ".cache"
          cache.mkdir(exist_ok=True)
          (cache / "finalize-input.json").write_text(json.dumps({
              "run_id": ctx["run_id"], "score": den * 0.75, "maturity_label": "Solid",
              "denominator": den, "top_action": "Add a gate",
              "layer_scores": {"0": 1.0, "7": 1.0},
              "evidence": [{"layer": 0, "kind": "path_absent", "path": "docs/guide.md"}],
          }), encoding="utf-8")
      
          script = Path(__file__).resolve().parents[1] / "scripts" / "assess_finalize.py"
          proc = subprocess.run(
              [sys.executable, str(script), str(root)], capture_output=True, text=True
          )
      
          assert proc.returncode == 1
          assert "finalize refused" in proc.stderr
          assert "path_absent docs/guide.md" in proc.stderr
          assert "((LLM fills in))" in (root / ".assess" / "log.md").read_text(encoding="utf-8")
          assert (cache / "finalize-input.json").exists()
      
    • test_assess_gate.py 15.5 KB
      """Tests for the CI regression gate (assess_gate.py) and its config loader.
      
      The gate is the enforcement half of the frozen harness: same run-context +
      config in, same pass/fail out, no LLM. These tests pin the warn-only defaults,
      the fail_on / warn_on precedence, the threshold checks, and the exit codes.
      """
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      
      from assess_gate import (
          check_complexity_threshold,
          check_containment_threshold,
          check_diff_regression,
          check_finding_regressions,
          evaluate,
          format_verdict,
          main,
      )
      from lib.assess_config import (
          GATE_CONCERN_FINDINGS,
          load_gate_config,
          load_gate_config_file,
      )
      from lib.keyhole_signals import FINDING_ORDER
      
      
      def _ctx(
          findings: list[dict] | None = None,
          ccn_p95: float = 50.0,
          safe_zones: int = 1,
          total_concerns: int = 5,
      ) -> dict:
          """A minimal run-context with the keys the gate reads."""
          return {
              "derived_findings": findings if findings is not None else [],
              "stats_summary": {"ccn": {"p95": ccn_p95}},
              "keyhole_summary": {"safe_zones": safe_zones, "total_concerns": total_concerns},
          }
      
      
      def _diff_ctx(regressed: list[dict] | None = None, prior: bool = True, reliable: bool = True) -> dict:
          """A run-context carrying the cross-run diff the regression check reads."""
          ctx = _ctx([])
          ctx["prior_stats_exists"] = prior
          ctx["diff_reliable"] = reliable
          ctx["diff_detail"] = {"regressed": regressed or []}
          return ctx
      
      
      def _write_config(tmp_path: Path, body: str) -> Path:
          (tmp_path / ".assess").mkdir(parents=True, exist_ok=True)
          (tmp_path / ".assess" / "config.toml").write_text(body, encoding="utf-8")
          return tmp_path
      
      
      # --- config loader --------------------------------------------------------
      
      
      def test_gate_concerns_match_keyhole_signals():
          """The default warn set must stay in sync with the canonical finding order."""
          expected = [f for f in FINDING_ORDER if f != "refactor_boundary"]
          assert GATE_CONCERN_FINDINGS == expected
      
      
      def test_load_gate_config_defaults_warn_only(tmp_path):
          """No config -> enabled, nothing fails, every concern warns, no thresholds."""
          gate = load_gate_config(tmp_path)
          assert gate["enabled"] is True
          assert gate["fail_on"] == []
          assert gate["warn_on"] == GATE_CONCERN_FINDINGS
          assert gate["ccn_p95_max"] is None
          assert gate["containment_min"] is None
      
      
      def test_load_gate_config_reads_section(tmp_path):
          _write_config(
              tmp_path,
              '[gate]\n'
              'enabled = true\n'
              'fail_on = ["lying_map", "hidden_coupling"]\n'
              'ccn_p95_max = 80\n'
              'containment_min = 0.5\n',
          )
          gate = load_gate_config(tmp_path)
          assert gate["fail_on"] == ["lying_map", "hidden_coupling"]
          assert gate["ccn_p95_max"] == 80.0
          assert gate["containment_min"] == 0.5
      
      
      def test_load_gate_config_explicit_empty_warn_on(tmp_path):
          """An explicit empty list silences warnings (distinct from missing key)."""
          _write_config(tmp_path, "[gate]\nwarn_on = []\n")
          assert load_gate_config(tmp_path)["warn_on"] == []
      
      
      def test_load_gate_config_rejects_bad_threshold(tmp_path):
          """Non-positive / non-numeric / boolean thresholds degrade to None."""
          _write_config(tmp_path, "[gate]\nccn_p95_max = 0\ncontainment_min = true\n")
          gate = load_gate_config(tmp_path)
          assert gate["ccn_p95_max"] is None
          assert gate["containment_min"] is None
      
      
      def test_load_gate_config_non_dict_section(tmp_path):
          """A malformed [gate] (scalar instead of table) falls back to defaults."""
          _write_config(tmp_path, 'gate = "nope"\n')
          gate = load_gate_config(tmp_path)
          assert gate["enabled"] is True
          assert gate["fail_on"] == []
      
      
      # --- finding regressions --------------------------------------------------
      
      
      def test_finding_fires_only_with_paths():
          ctx = _ctx([
              {"name": "lying_map", "paths": ["docs/old.md"]},
              {"name": "hidden_coupling", "paths": []},
          ])
          gate = {"fail_on": ["lying_map", "hidden_coupling"], "warn_on": []}
          failures, warnings = check_finding_regressions(ctx, gate)
          assert [f["finding"] for f in failures] == ["lying_map"]
          assert failures[0]["count"] == 1
          assert warnings == []
      
      
      def test_fail_on_takes_precedence_over_warn_on():
          ctx = _ctx([{"name": "lying_map", "paths": ["a", "b"]}])
          gate = {"fail_on": ["lying_map"], "warn_on": ["lying_map"]}
          failures, warnings = check_finding_regressions(ctx, gate)
          assert len(failures) == 1
          assert warnings == []  # not double-counted
      
      
      def test_warn_on_reports_without_failing():
          ctx = _ctx([{"name": "candidate_dead_weight", "paths": ["x.py"]}])
          gate = {"fail_on": [], "warn_on": ["candidate_dead_weight"]}
          failures, warnings = check_finding_regressions(ctx, gate)
          assert failures == []
          assert [w["finding"] for w in warnings] == ["candidate_dead_weight"]
      
      
      def test_paths_sample_capped_at_five():
          ctx = _ctx([{"name": "hidden_coupling", "paths": [str(i) for i in range(20)]}])
          gate = {"fail_on": ["hidden_coupling"], "warn_on": []}
          failures, _ = check_finding_regressions(ctx, gate)
          assert failures[0]["count"] == 20
          assert len(failures[0]["paths"]) == 5
      
      
      # --- thresholds -----------------------------------------------------------
      
      
      def test_complexity_threshold_breach():
          breaches = check_complexity_threshold(_ctx(ccn_p95=120.0), {"ccn_p95_max": 100.0})
          assert breaches[0]["metric"] == "ccn_p95"
          assert breaches[0]["value"] == 120.0
      
      
      def test_complexity_threshold_within_budget():
          assert check_complexity_threshold(_ctx(ccn_p95=90.0), {"ccn_p95_max": 100.0}) == []
      
      
      def test_complexity_threshold_unset():
          assert check_complexity_threshold(_ctx(ccn_p95=999.0), {"ccn_p95_max": None}) == []
      
      
      def test_containment_threshold_breach():
          # 1 safe / (1 + 9) = 0.1, below the 0.5 floor.
          breaches = check_containment_threshold(
              _ctx(safe_zones=1, total_concerns=9), {"containment_min": 0.5}
          )
          assert breaches[0]["metric"] == "containment"
          assert breaches[0]["value"] == 0.1
      
      
      def test_containment_threshold_met():
          assert check_containment_threshold(
              _ctx(safe_zones=9, total_concerns=1), {"containment_min": 0.5}
          ) == []
      
      
      def test_containment_no_units_scored():
          """Nothing flagged either way -> no ratio -> no breach."""
          assert check_containment_threshold(
              _ctx(safe_zones=0, total_concerns=0), {"containment_min": 0.5}
          ) == []
      
      
      # --- cross-run regression -------------------------------------------------
      
      
      def test_regression_not_opted_in():
          ctx = _diff_ctx(regressed=[{"path": "a.py"}])
          assert check_diff_regression(ctx, {"fail_on_regression": False}) == []
      
      
      def test_regression_fires_when_opted_in():
          ctx = _diff_ctx(regressed=[{"path": "a.py"}, {"path": "b.py"}])
          breaches = check_diff_regression(ctx, {"fail_on_regression": True})
          assert breaches[0]["metric"] == "regressed_hotspots"
          assert breaches[0]["count"] == 2
          assert breaches[0]["paths"] == ["a.py", "b.py"]
      
      
      def test_regression_skipped_on_first_run():
          """No prior snapshot -> a freshly-cloned repo can't trip the regression gate."""
          ctx = _diff_ctx(regressed=[{"path": "a.py"}], prior=False)
          assert check_diff_regression(ctx, {"fail_on_regression": True}) == []
      
      
      def test_regression_skipped_when_diff_unreliable():
          ctx = _diff_ctx(regressed=[{"path": "a.py"}], reliable=False)
          assert check_diff_regression(ctx, {"fail_on_regression": True}) == []
      
      
      def test_regression_clean_diff_no_breach():
          ctx = _diff_ctx(regressed=[])
          assert check_diff_regression(ctx, {"fail_on_regression": True}) == []
      
      
      def test_load_gate_config_fail_on_regression_default_false(tmp_path):
          assert load_gate_config(tmp_path)["fail_on_regression"] is False
      
      
      def test_load_gate_config_reads_fail_on_regression(tmp_path):
          _write_config(tmp_path, "[gate]\nfail_on_regression = true\n")
          assert load_gate_config(tmp_path)["fail_on_regression"] is True
      
      
      def test_regression_breach_renders_in_verdict():
          ctx = _diff_ctx(regressed=[{"path": "hot.py"}])
          verdict = evaluate(ctx, {"enabled": True, "fail_on": [], "warn_on": [], "fail_on_regression": True})
          assert verdict["failed"] is True
          assert "FAIL regression" in format_verdict(verdict)
      
      
      # --- explicit --config file -----------------------------------------------
      
      
      def test_load_gate_config_file_reads_explicit_path(tmp_path):
          cfg = tmp_path / "custom.toml"
          cfg.write_text('[gate]\nfail_on = ["lying_map"]\n', encoding="utf-8")
          gate = load_gate_config_file(cfg)
          assert gate["fail_on"] == ["lying_map"]
      
      
      def test_load_gate_config_file_missing_uses_defaults(tmp_path):
          gate = load_gate_config_file(tmp_path / "nope.toml")
          assert gate["fail_on"] == []
          assert gate["enabled"] is True
      
      
      # --- evaluate + verdict ---------------------------------------------------
      
      
      def test_evaluate_clean_passes():
          gate = load_gate_config(Path("/nonexistent"))  # warn-only defaults
          verdict = evaluate(_ctx([]), gate)
          assert verdict["failed"] is False
      
      
      def test_evaluate_fails_on_fail_on_finding():
          ctx = _ctx([{"name": "lying_map", "paths": ["doc.md"]}])
          verdict = evaluate(ctx, {"enabled": True, "fail_on": ["lying_map"], "warn_on": []})
          assert verdict["failed"] is True
          assert verdict["failures"][0]["finding"] == "lying_map"
      
      
      def test_disabled_gate_never_fails_but_reports():
          ctx = _ctx([{"name": "lying_map", "paths": ["doc.md"]}])
          verdict = evaluate(ctx, {"enabled": False, "fail_on": ["lying_map"], "warn_on": []})
          assert verdict["failed"] is False
          assert verdict["failures"]  # still collected for the log
          assert "disabled" in format_verdict(verdict)
      
      
      def test_format_verdict_clean():
          verdict = evaluate(_ctx([]), {"enabled": True, "fail_on": [], "warn_on": []})
          out = format_verdict(verdict)
          assert "RESULT: PASS" in out
          assert "clean snapshot" in out
      
      
      # --- config-exclusion disclosure ------------------------------------------
      
      
      def test_verdict_discloses_config_suppressed_findings():
          ctx = _ctx([])
          ctx["excluded_by_config"] = {
              "dirs": ["vendor"],
              "patterns": ["*.gen.py"],
              "affected_finding_paths": ["vendor/x.py", "a.gen.py"],
              "count": 2,
          }
          out = format_verdict(evaluate(ctx, {"enabled": True, "fail_on": [], "warn_on": []}))
          assert "2 findings suppressed by config excludes" in out
          assert "dirs: vendor" in out
          assert "patterns: *.gen.py" in out
      
      
      def test_verdict_singular_suppression_noun():
          ctx = _ctx([])
          ctx["excluded_by_config"] = {
              "dirs": ["vendor"], "patterns": [],
              "affected_finding_paths": ["vendor/x.py"], "count": 1,
          }
          out = format_verdict(evaluate(ctx, {"enabled": True, "fail_on": [], "warn_on": []}))
          assert "1 finding suppressed by config excludes" in out
          assert "patterns: none" in out
      
      
      def test_verdict_no_disclosure_when_no_excludes():
          ctx = _ctx([])
          ctx["excluded_by_config"] = {
              "dirs": [], "patterns": [], "affected_finding_paths": [], "count": 0,
          }
          out = format_verdict(evaluate(ctx, {"enabled": True, "fail_on": [], "warn_on": []}))
          assert "suppressed by config excludes" not in out
      
      
      def test_verdict_no_disclosure_when_block_absent():
          out = format_verdict(evaluate(_ctx([]), {"enabled": True, "fail_on": [], "warn_on": []}))
          assert "suppressed by config excludes" not in out
      
      
      # --- generated-file disclosure --------------------------------------------
      
      
      def test_verdict_names_each_generated_header_reason():
          ctx = _ctx([])
          ctx["excluded_generated"] = [
              {"path": "db/schema.sql", "reason": "generated-header"},
              {"path": "db/other.sql", "reason": "generated-header"},
              {"path": "assets/font.ts", "reason": "long-lines"},
          ]
          out = format_verdict(evaluate(ctx, {"enabled": True, "fail_on": [], "warn_on": []}))
          line = next(ln for ln in out.splitlines() if "generated-header" in ln)
          assert "3 files excluded from scoring as generated" in line
          assert "long-lines" in line
          assert line.count("generated-header") == 1
          assert "    db/schema.sql (generated-header)" in out
          assert "    assets/font.ts (long-lines)" in out
          assert "RESULT: PASS" in out
      
      
      def test_verdict_caps_generated_header_path_lines():
          ctx = _ctx([])
          ctx["excluded_generated"] = [
              {"path": f"gen/f{i:02}.sql", "reason": "generated-header"} for i in range(13)
          ]
          out = format_verdict(evaluate(ctx, {"enabled": True, "fail_on": [], "warn_on": []}))
          assert "gen/f09.sql (generated-header)" in out
          assert "gen/f10.sql" not in out
          assert "+3 more" in out
      
      
      def test_verdict_generated_header_disclosure_silent_when_empty():
          for ctx in (_ctx([]), {**_ctx([]), "excluded_generated": []}):
              out = format_verdict(evaluate(ctx, {"enabled": True, "fail_on": [], "warn_on": []}))
              assert "excluded from scoring as generated" not in out
      
      
      # --- CLI ------------------------------------------------------------------
      
      
      def _write_ctx(tmp_path: Path, ctx: dict) -> Path:
          (tmp_path / ".assess").mkdir(parents=True, exist_ok=True)
          (tmp_path / ".assess" / "run-context.json").write_text(json.dumps(ctx))
          return tmp_path
      
      
      def test_main_passes_clean(tmp_path, capsys):
          _write_ctx(tmp_path, _ctx([]))
          assert main([str(tmp_path)]) == 0
          assert "RESULT: PASS" in capsys.readouterr().out
      
      
      def test_main_fails_when_configured(tmp_path, capsys):
          _write_ctx(tmp_path, _ctx([{"name": "lying_map", "paths": ["doc.md"]}]))
          _write_config(tmp_path, '[gate]\nfail_on = ["lying_map"]\n')
          assert main([str(tmp_path)]) == 1
          assert "RESULT: FAIL" in capsys.readouterr().out
      
      
      def test_main_warn_only_default_passes_with_findings(tmp_path):
          """Findings present but no fail_on config -> warn-only -> exit 0."""
          _write_ctx(tmp_path, _ctx([{"name": "lying_map", "paths": ["doc.md"]}]))
          assert main([str(tmp_path)]) == 0
      
      
      def test_main_accepts_config_flag_without_consuming_repo_root(tmp_path):
          _write_ctx(tmp_path, _ctx([]))
          cfg = tmp_path / ".assess" / "config.toml"
          assert main([str(tmp_path), "--config", str(cfg)]) == 0
      
      
      def test_main_honors_explicit_config_path(tmp_path):
          """--config pointing at a relocated file drives the gate, not .assess/."""
          _write_ctx(tmp_path, _ctx([{"name": "lying_map", "paths": ["doc.md"]}]))
          cfg = tmp_path / "elsewhere.toml"
          cfg.write_text('[gate]\nfail_on = ["lying_map"]\n', encoding="utf-8")
          # No .assess/config.toml exists, so this fails only if --config is honored.
          assert main([str(tmp_path), "--config", str(cfg)]) == 1
      
      
      def test_main_missing_context_skips_not_fails(tmp_path, capsys):
          """A missing run-context is an infrastructure failure, not a finding: the
          gate skips with a clear notice and exits 0 so it never red-checks an
          unrelated PR (it runs again once the core produces a clean run-context)."""
          assert main([str(tmp_path)]) == 0
          err = capsys.readouterr().err
          assert "gate skipped" in err
          assert "infrastructure failure" in err
          assert "not a finding" in err
          assert "next push" in err
      
      
      def test_main_corrupt_context_skips_not_fails(tmp_path, capsys):
          """A corrupt (unparseable) run-context is also infra, not a finding: skip
          with a notice and exit 0 rather than fail the PR."""
          (tmp_path / ".assess").mkdir(parents=True)
          (tmp_path / ".assess" / "run-context.json").write_text(
              "{ not valid json", encoding="utf-8"
          )
          assert main([str(tmp_path)]) == 0
          err = capsys.readouterr().err
          assert "gate skipped" in err
          assert "infrastructure failure" in err
      
      
      def test_main_no_args_is_usage_error(capsys):
          assert main([]) == 2
          assert "Usage" in capsys.readouterr().err
      
    • test_assess_report.py 26.4 KB
      """Tests for the deterministic report renderer (assess_report.py).
      
      The renderer is the frozen harness: same context in, same Markdown out, no LLM.
      These tests pin its template substitution, the section renderers (hotspots,
      keyhole summary, findings, diff), the conditional fallbacks, and the honest
      boundary - it must NOT reproduce the LLM-only 0-8 score or Top 3 Actions.
      """
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      import pytest
      
      from assess_report import (
          _fmt,
          _render_commit_note,
          format_structure_drift_findings,
          load_context,
          main,
          render_diff_section,
          render_exclusion_disclosure,
          render_findings_section,
          render_generated_disclosure,
          render_hotspots_table,
          render_keyhole_summary,
          render_report,
      )
      
      
      def _full_ctx() -> dict:
          """A realistic, non-normalised run-context with every section populated."""
          return {
              "run_date": "2026-06-01",
              "plugin_version": "1.22.0",
              "measured_commit": {
                  "available": True,
                  "head_short": "abc1234",
                  "head_sha": "abc1234def5678",
                  "subject": "feat: add the thing",
                  "dirty": False,
                  "behind": 0,
              },
              "prior_stats_exists": True,
              "diff_reliable": True,
              "diff_version_note": None,
              "stats_summary": {
                  "files_scored": 58,
                  "loc": {"max": 761.0, "p50": 132.5, "p95": 483.65, "total": 10428},
                  "ccn": {"max": 169.0, "p50": 31.0, "p95": 107.35, "basis": "file-aggregate"},
                  "top_hotspots": [
                      {"path": "scripts/assess_core.py", "loc": 493, "ccn": 106.0, "commits": 14},
                      {"path": "lib/doc_graph.py", "loc": 514, "ccn": 169.0, "commits": 7},
                  ],
              },
              "diff": {"graduated": 1, "regressed": 1, "new": 1, "persistent": 1},
              "diff_detail": {
                  "graduated": [{"path": "old/file.py", "ccn_delta": 0, "loc_delta": 0}],
                  "new": [{"path": "new/file.py", "ccn_delta": 0, "loc_delta": 0}],
                  "regressed": [{"path": "hot/file.py", "ccn_delta": 12, "loc_delta": 60}],
                  "persistent": [{"path": "stable/file.py", "ccn_delta": 0, "loc_delta": 0}],
              },
              "doc_staleness": {"churn_window": "commits (last 12mo)", "available": True},
              "keyhole_summary": {
                  "concerns": [{"name": "hidden_coupling", "count": 5}],
                  "safe_zones": 1,
                  "total_concerns": 5,
                  "summary_text": "5 structural concerns (5 hidden coupling), 1 safe zone.",
              },
              "findings_markdown": (
                  "## Cross-Layer Findings (Keyhole Readiness)\n\n"
                  "### hidden_coupling\n\n"
                  "Action: investigate the seam\n\n"
                  "Paths:\n- scripts\n- scripts/tests\n"
              ),
              "prescribed_actions": [
                  {"path": "scripts", "action": "investigate the seam",
                   "findings": ["hidden_coupling"], "rank": 1},
              ],
              "structure_drift": _structure_drift_block(),
          }
      
      
      def _structure_drift_block() -> dict:
          """A populated structure_drift block: Tier 0 empty globs + Tier 1 counts."""
          return {
              "tier_0": {
                  "available": True,
                  "total_patterns": 30,
                  "matched_patterns": 8,
                  "empty_ownership_patterns": [
                      {"pattern": "../skills/marathon/SKILL.md",
                       "declared_in": "commands/README.md::Workflow",
                       "owners": []},
                      {"pattern": "./assess/SKILL.md",
                       "declared_in": "skills/README.md::Portable",
                       "owners": ["@platform-team"]},
                  ],
              },
              "tier_1": {
                  "available": True,
                  "human_grouped_static_splits_count": 6582,
                  "human_split_static_fuses_count": 0,
                  "human_grouped_never_cochange_count": 6636,
                  "human_split_but_cochange_count": 6,
                  "human_static_agree_count": 56,
                  "human_cochange_agree_count": 2,
                  "seam_allowlist_applied": True,
                  "allowlist_pairs_count": 2,
              },
          }
      
      
      def _minimal_ctx() -> dict:
          """The thinnest valid context: first run, no hotspots, no findings."""
          return {
              "run_date": "2026-06-01",
              "plugin_version": "1.22.0",
              "prior_stats_exists": False,
              "stats_summary": {"files_scored": 0, "loc": {}, "ccn": {}, "top_hotspots": []},
          }
      
      
      # --------------------------------------------------------------------------
      # _fmt
      # --------------------------------------------------------------------------
      
      def test_fmt_none_is_question_mark() -> None:
          assert _fmt(None) == "?"
      
      
      def test_fmt_whole_float_drops_decimal() -> None:
          assert _fmt(493.0) == "493"
      
      
      def test_fmt_fractional_float_one_decimal() -> None:
          out = _fmt(107.35)
          assert out.startswith("107.")
          assert len(out.split(".")[1]) == 1
      
      
      def test_fmt_int_passthrough() -> None:
          assert _fmt(14) == "14"
      
      
      # --------------------------------------------------------------------------
      # Full render
      # --------------------------------------------------------------------------
      
      def test_full_render_has_all_sections() -> None:
          report = render_report(_full_ctx(), "ai-native-toolkit")
          assert "# Deterministic Assessment Snapshot: ai-native-toolkit" in report
          assert "## Metrics Dashboard" in report
          assert "### Top Hotspots" in report
          assert "## Keyhole Readiness" in report
          assert "## Changes Since Last Run" in report
          # No unsubstituted placeholders leaked.
          assert "$" not in report
      
      
      # --------------------------------------------------------------------------
      # Config-exclusion disclosure
      # --------------------------------------------------------------------------
      
      
      def test_exclusion_disclosure_renders_when_findings_suppressed() -> None:
          disclosure = render_exclusion_disclosure({
              "excluded_by_config": {
                  "dirs": ["vendor"], "patterns": ["*.gen.py"],
                  "affected_finding_paths": ["vendor/x.py", "a.gen.py"], "count": 2,
              }
          })
          assert "2 findings suppressed by config excludes" in disclosure
          assert "dirs: vendor" in disclosure
          assert "patterns: *.gen.py" in disclosure
      
      
      def test_exclusion_disclosure_empty_without_excludes() -> None:
          assert render_exclusion_disclosure({}) == ""
          assert render_exclusion_disclosure(
              {"excluded_by_config": {"dirs": [], "patterns": [], "count": 0}}
          ) == ""
      
      
      def test_archive_paths_excluded_disclosed_in_report() -> None:
          """Archive paths left out of attention are named under the keyhole summary."""
          disclosure = render_exclusion_disclosure({
              "excluded_as_archive": {
                  "affected_finding_paths": ["docs/archive/PLAN.md"], "count": 1,
              }
          })
          assert disclosure == (
              "_1 archived path left out of the attention list: docs/archive/PLAN.md._"
          )
          both = render_exclusion_disclosure({
              "excluded_by_config": {"dirs": ["vendor"], "patterns": [], "count": 1},
              "excluded_as_archive": {"affected_finding_paths": ["a/attic/x.py", "b/archive/y.py"],
                                      "count": 2},
          })
          assert both.split("\n\n") == [
              "_1 finding suppressed by config excludes (dirs: vendor; patterns: none)._",
              "_2 archived paths left out of the attention list: a/attic/x.py, b/archive/y.py._",
          ]
          assert render_exclusion_disclosure(
              {"excluded_as_archive": {"affected_finding_paths": [], "count": 0}}
          ) == ""
          many = [f"archive/f{i}.py" for i in range(7)]
          capped = render_exclusion_disclosure(
              {"excluded_as_archive": {"affected_finding_paths": many, "count": 7}}
          )
          assert capped == (
              "_7 archived paths left out of the attention list: archive/f0.py, "
              "archive/f1.py, archive/f2.py, archive/f3.py, archive/f4.py +2 more._"
          )
      
      
      def test_pruned_finding_paths_disclosed_in_report() -> None:
          """Dead git-history paths dropped from the findings are named, capped at five."""
          assert render_exclusion_disclosure(
              {"pruned_finding_paths": {"paths": ["gone"], "count": 1}}
          ) == (
              "_1 git-history path no longer exists and was left out of the findings: gone._"
          )
          assert render_exclusion_disclosure(
              {"pruned_finding_paths": {"paths": [], "count": 0}}
          ) == ""
          many = [f"gone/d{i}" for i in range(6)]
          assert render_exclusion_disclosure(
              {"pruned_finding_paths": {"paths": many, "count": 6}}
          ).endswith("gone/d3, gone/d4 +1 more._")
      
      def test_report_includes_exclusion_disclosure() -> None:
          ctx = _full_ctx()
          ctx["excluded_by_config"] = {
              "dirs": ["vendor"], "patterns": [],
              "affected_finding_paths": ["vendor/x.py"], "count": 1,
          }
          report = render_report(ctx, "demo")
          assert "1 finding suppressed by config excludes" in report
          # It sits in the Keyhole Readiness section, right after the summary line.
          assert report.index("Keyhole Readiness") < report.index("suppressed by config")
      
      
      def test_report_no_disclosure_line_when_no_excludes() -> None:
          report = render_report(_full_ctx(), "demo")
          assert "suppressed by config excludes" not in report
      
      
      def test_full_render_metrics_values() -> None:
          report = render_report(_full_ctx(), "demo")
          assert "**Files scored:** 58" in report
          assert "**Total LOC:** 10428" in report
          assert "p95 LOC 483" in report
          assert "max 761" in report
          assert "p95 CCN 107" in report
          assert "max 169" in report
          assert "**Churn window:** commits (last 12mo)" in report
      
      
      def test_churn_window_carries_degenerate_caveat() -> None:
          """Issue #172: when the run-context flags a degenerate churn history, the
          churn-window line carries a 'snapshot / no usable history' caveat so the
          score line isn't read as backed by a live churn signal."""
          ctx = _full_ctx()
          ctx["churn_degenerate"] = True
          report = render_report(ctx, "demo")
          assert "snapshot / no usable history, churn signal flat" in report
      
          # Default (no flag) leaves the line clean - no regression for real histories.
          clean = render_report(_full_ctx(), "demo")
          assert "**Churn window:** commits (last 12mo)" in clean
          assert "snapshot / no usable history" not in clean
      
      
      def test_full_render_substitute_is_strict() -> None:
          # render_report must not raise on a fully populated context.
          render_report(_full_ctx(), "demo")
      
      
      def test_minimal_render_uses_fallbacks() -> None:
          report = render_report(_minimal_ctx(), "tiny")
          assert "_No hotspots identified._" in report
          assert "_No cross-layer findings recorded._" in report
          assert "_Keyhole readiness summary unavailable._" in report
          assert "first recorded snapshot" in report
          assert "$" not in report
      
      
      # --------------------------------------------------------------------------
      # Hotspots table
      # --------------------------------------------------------------------------
      
      def test_hotspots_table_empty_fallback() -> None:
          assert render_hotspots_table({"stats_summary": {"top_hotspots": []}}) == \
              "_No hotspots identified._"
      
      
      def test_hotspots_table_rows_and_header() -> None:
          out = render_hotspots_table(_full_ctx())
          assert "| Path | LOC | CCN | Commits |" in out
          assert "| `scripts/assess_core.py` | 493 | 106 | 14 |" in out
          assert "| `lib/doc_graph.py` | 514 | 169 | 7 |" in out
      
      
      def test_hotspots_table_missing_metric_renders_question_mark() -> None:
          ctx = {"stats_summary": {"top_hotspots": [{"path": "x.py"}]}}
          out = render_hotspots_table(ctx)
          assert "| `x.py` | ? | ? | ? |" in out
      
      
      def test_hotspots_table_caps_at_ten_rows() -> None:
          hotspots = [{"path": f"f{i}.py", "loc": 1, "ccn": 1, "commits": 1} for i in range(25)]
          out = render_hotspots_table({"stats_summary": {"top_hotspots": hotspots}})
          # 2 header lines + 10 data rows.
          assert len(out.splitlines()) == 12
          assert "f9.py" in out
          assert "f10.py" not in out
      
      
      # --------------------------------------------------------------------------
      # Keyhole summary + findings
      # --------------------------------------------------------------------------
      
      def test_keyhole_summary_consumed_verbatim() -> None:
          out = render_keyhole_summary(_full_ctx())
          assert out == "5 structural concerns (5 hidden coupling), 1 safe zone."
      
      
      def test_keyhole_summary_missing_fallback() -> None:
          assert render_keyhole_summary({}) == "_Keyhole readiness summary unavailable._"
      
      
      def test_findings_section_verbatim() -> None:
          out = render_findings_section(_full_ctx())
          assert out.startswith("## Cross-Layer Findings (Keyhole Readiness)")
          assert "### hidden_coupling" in out
          assert "Action: investigate the seam" in out
      
      
      def test_findings_section_empty_fallback() -> None:
          assert render_findings_section({"findings_markdown": "   "}) == \
              "_No cross-layer findings recorded._"
          assert render_findings_section({}) == "_No cross-layer findings recorded._"
      
      
      # --------------------------------------------------------------------------
      # Diff section
      # --------------------------------------------------------------------------
      
      def test_diff_no_prior_run() -> None:
          out = render_diff_section({"prior_stats_exists": False})
          assert "No prior run to compare against" in out
      
      
      def test_diff_unreliable_is_suppressed_with_note() -> None:
          ctx = {
              "prior_stats_exists": True,
              "diff_reliable": False,
              "diff_version_note": "prior stats from plugin 1.10.0, current 1.22.0",
          }
          out = render_diff_section(ctx)
          assert out.startswith("_Diff suppressed:")
          assert "1.10.0" in out
      
      
      def test_diff_unreliable_without_note_falls_back() -> None:
          out = render_diff_section({"prior_stats_exists": True, "diff_reliable": False})
          assert "not comparable" in out
      
      
      def test_diff_major_bump_renders_trend_reset_disclosure() -> None:
          """A MAJOR version bump renders an explicit trend-reset line, distinct from a
          plain suppression, so a voided diff isn't misread as an unchanged run."""
          ctx = {
              "prior_stats_exists": True,
              "diff_reliable": False,
              "diff_trend_reset": True,
              "diff_version_note": "major version changed 1.54.1->2.0.0",
          }
          out = render_diff_section(ctx)
          assert out.startswith("_Trend baseline reset:")
          assert "major version changed 1.54.1->2.0.0" in out
          assert "trend restarts" in out
      
      
      def test_diff_reliable_renders_all_categories() -> None:
          out = render_diff_section(_full_ctx())
          assert "- **Graduated** (left the hotspot list): 1" in out
          assert "- **New** (entered the hotspot list): 1" in out
          assert "- **Regressed** (complexity or churn increased): 1" in out
          assert "- **Persistent** (still in the hotspot list): 1" in out
          assert "- `old/file.py`" in out
          assert "- `new/file.py`" in out
          assert "- `stable/file.py`" in out
      
      
      def test_diff_regressed_shows_deltas() -> None:
          out = render_diff_section(_full_ctx())
          assert "- `hot/file.py` (CCN +12, LOC +60)" in out
      
      
      def test_diff_regressed_without_deltas_omits_suffix() -> None:
          ctx = {
              "prior_stats_exists": True,
              "diff_reliable": True,
              "diff": {"regressed": 1},
              "diff_detail": {"regressed": [{"path": "x.py", "ccn_delta": 0, "loc_delta": 0}]},
          }
          out = render_diff_section(ctx)
          assert "- `x.py`" in out
          assert "(CCN" not in out
      
      
      def test_diff_caps_rows_and_reports_overflow() -> None:
          entries = [{"path": f"f{i}.py"} for i in range(13)]
          ctx = {
              "prior_stats_exists": True,
              "diff_reliable": True,
              "diff": {"persistent": 13},
              "diff_detail": {"persistent": entries},
          }
          out = render_diff_section(ctx)
          assert "...and 3 more" in out
          assert "f9.py" in out
          assert "f10.py" not in out
      
      
      # --------------------------------------------------------------------------
      # Commit note
      # --------------------------------------------------------------------------
      
      def test_commit_note_clean() -> None:
          note = _render_commit_note(_full_ctx())
          assert "Measured at `abc1234`" in note
          assert '("feat: add the thing")' in note
          assert "dirty" not in note
          assert "behind" not in note
      
      
      def test_commit_note_dirty_and_behind() -> None:
          ctx = {"measured_commit": {
              "available": True, "head_short": "deadbee", "dirty": True, "behind": 3,
          }}
          note = _render_commit_note(ctx)
          assert "working tree dirty" in note
          assert "3 commit(s) behind upstream" in note
      
      
      def test_commit_note_unavailable_is_empty() -> None:
          assert _render_commit_note({"measured_commit": {"available": False}}) == ""
          assert _render_commit_note({}) == ""
      
      
      # --------------------------------------------------------------------------
      # Structure drift (Tier 0 ownership-map drift + Tier 1 grouping disagreement)
      # --------------------------------------------------------------------------
      
      def test_structure_drift_tier0_lists_empty_patterns() -> None:
          """Tier 0 surfaces each empty ownership pattern with its declared_in source
          and owners, framed as stale ownership dropping review coverage."""
          out = format_structure_drift_findings(_structure_drift_block())
          assert "Tier 0" in out
          assert "Ownership Map Drift" in out
          assert "`../skills/marathon/SKILL.md`" in out
          assert "commands/README.md::Workflow" in out
          assert "@platform-team" in out
          # The interpretation is review-coverage loss, not a blame.
          assert "review coverage" in out
      
      
      def test_structure_drift_tier0_omitted_when_no_empty_patterns() -> None:
          block = _structure_drift_block()
          block["tier_0"]["empty_ownership_patterns"] = []
          out = format_structure_drift_findings(block)
          assert "Tier 0" not in out
      
      
      def test_structure_drift_tier0_omitted_when_unavailable() -> None:
          block = _structure_drift_block()
          block["tier_0"] = {"available": False}
          out = format_structure_drift_findings(block)
          assert "Tier 0" not in out
      
      
      def test_structure_drift_tier1_renders_six_counts() -> None:
          """Tier 1 surfaces the six disagreement/agreement counts, objective first."""
          out = format_structure_drift_findings(_structure_drift_block())
          assert "Tier 1" in out
          assert "Grouping Disagreement" in out
          # All six magnitudes are present.
          assert "6582" in out
          assert "6636" in out
          assert "56" in out
          assert "2" in out
          # Zero counts are shown explicitly, not dropped.
          assert "human_split_static_fuses" in out
          assert "human_split_but_cochange" in out
      
      
      def test_structure_drift_tier1_omitted_when_unavailable() -> None:
          block = _structure_drift_block()
          block["tier_1"] = {"available": False}
          out = format_structure_drift_findings(block)
          assert "Grouping Disagreement" not in out
      
      
      def test_structure_drift_seam_allowlist_transparency() -> None:
          """When the allowlist fired, the report says so and how many pairs it cut."""
          out = format_structure_drift_findings(_structure_drift_block())
          assert "allowlist" in out
          assert "2" in out  # allowlist_pairs_count
      
      
      def test_structure_drift_never_auto_recommends_regenerate_codeowners() -> None:
          """The deterministic harness states counts; regenerating the ownership map is
          a human decision, never an auto-prescribed action."""
          out = format_structure_drift_findings(_structure_drift_block()).lower()
          assert "regenerate codeowners" not in out
          assert "regenerate the ownership map" not in out
      
      
      def test_structure_drift_empty_block_omitted() -> None:
          assert format_structure_drift_findings(None) == ""
          assert format_structure_drift_findings({}) == ""
      
      
      def test_structure_drift_deterministic() -> None:
          block = _structure_drift_block()
          assert format_structure_drift_findings(block) == \
              format_structure_drift_findings(block)
      
      
      def test_structure_drift_in_full_report() -> None:
          report = render_report(_full_ctx(), "demo")
          assert "Structure Drift" in report
          assert "Tier 0" in report
          assert "Tier 1" in report
          assert "$" not in report
      
      
      def test_structure_drift_absent_block_leaves_valid_report() -> None:
          ctx = _full_ctx()
          del ctx["structure_drift"]
          report = render_report(ctx, "demo")
          assert "Structure Drift" not in report
          assert "$" not in report
      
      
      # --------------------------------------------------------------------------
      # Honest boundary: must NOT reproduce LLM-only content
      # --------------------------------------------------------------------------
      
      def test_report_omits_llm_only_artifacts() -> None:
          report = render_report(_full_ctx(), "demo")
          # The 0-8 layered score and the Top 3 Actions priority narrative are the
          # LLM's job; the frozen report names the boundary, it does not fake them.
          assert "## Top 3 Actions" not in report
          assert "present/partial/missing" not in report
          assert "/ 8" not in report
          assert "require LLM judgement" in report
      
      
      # --------------------------------------------------------------------------
      # main() + IO
      # --------------------------------------------------------------------------
      
      def _seed_run_context(repo_root: Path, ctx: dict) -> None:
          assess_dir = repo_root / ".assess"
          assess_dir.mkdir(parents=True, exist_ok=True)
          (assess_dir / "run-context.json").write_text(json.dumps(ctx), encoding="utf-8")
      
      
      def test_load_context_roundtrip(tmp_path: Path) -> None:
          _seed_run_context(tmp_path, _full_ctx())
          loaded = load_context(tmp_path)
          assert loaded["run_date"] == "2026-06-01"
      
      
      def test_main_writes_report_file(tmp_path: Path, capsys: pytest.CaptureFixture) -> None:
          _seed_run_context(tmp_path, _full_ctx())
          rc = main([str(tmp_path)])
          assert rc == 0
          out_path = tmp_path / ".assess" / "deterministic-report.md"
          assert out_path.exists()
          report = out_path.read_text(encoding="utf-8")
          assert "# Deterministic Assessment Snapshot:" in report
          assert str(out_path) in capsys.readouterr().out
      
      
      def test_main_stdout_does_not_write_file(tmp_path: Path,
                                               capsys: pytest.CaptureFixture) -> None:
          _seed_run_context(tmp_path, _full_ctx())
          rc = main([str(tmp_path), "--stdout"])
          assert rc == 0
          assert not (tmp_path / ".assess" / "deterministic-report.md").exists()
          out = capsys.readouterr().out
          assert "# Deterministic Assessment Snapshot:" in out
      
      
      def test_main_no_args_usage_error(capsys: pytest.CaptureFixture) -> None:
          rc = main([])
          assert rc == 2
          assert "Usage:" in capsys.readouterr().err
      
      
      def test_main_missing_context_skips_not_fails(tmp_path: Path,
                                                     capsys: pytest.CaptureFixture) -> None:
          """A missing run-context is infrastructure, not a finding: the renderer skips
          with a notice and exits 0 rather than crashing the check."""
          rc = main([str(tmp_path)])
          assert rc == 0
          err = capsys.readouterr().err
          assert "report skipped" in err
          assert "infrastructure failure" in err
          assert not (tmp_path / ".assess" / "deterministic-report.md").exists()
      
      
      def test_main_corrupt_context_skips_not_fails(tmp_path: Path,
                                                     capsys: pytest.CaptureFixture) -> None:
          (tmp_path / ".assess").mkdir(parents=True)
          (tmp_path / ".assess" / "run-context.json").write_text(
              "{ not valid json", encoding="utf-8"
          )
          rc = main([str(tmp_path)])
          assert rc == 0
          assert "infrastructure failure" in capsys.readouterr().err
      
      
      # --------------------------------------------------------------------------
      # Generated-file disclosure (excluded_generated)
      # --------------------------------------------------------------------------
      
      
      def test_generated_header_exclusion_named_with_reason_on_one_line() -> None:
          ctx = _full_ctx()
          ctx["excluded_generated"] = [
              {"path": "db/schema.sql", "reason": "generated-header"},
              {"path": "assets/font.ts", "reason": "long-lines"},
          ]
          report = render_report(ctx, "demo")
          lines = report.splitlines()
          assert any("db/schema.sql" in ln and "generated-header" in ln for ln in lines)
          assert any("assets/font.ts" in ln and "long-lines" in ln for ln in lines)
          assert "2 files excluded from scoring as generated" in report
      
      
      def test_generated_header_disclosure_silent_when_empty() -> None:
          assert render_generated_disclosure({}) == ""
          assert render_generated_disclosure({"excluded_generated": []}) == ""
          assert "excluded from scoring as generated" not in render_report(_full_ctx(), "demo")
      
      
      def test_generated_header_disclosure_folds_rows_past_the_cap() -> None:
          rows = [{"path": f"gen/f{i:02}.sql", "reason": "generated-header"} for i in range(13)]
          out = render_generated_disclosure({"excluded_generated": rows})
          head, fold = out.split("<details>")
          assert "gen/f09.sql` (generated-header)" in head
          assert "gen/f10.sql" not in head
          assert "3 more</summary>" in fold
          for i in (10, 11, 12):
              assert f"- `gen/f{i}.sql` (generated-header)" in fold
          assert fold.rstrip().endswith("</details>")
      
      
      def test_pruned_finding_paths_disclosure_names_incomplete_rename_map() -> None:
          """A run whose rename map could not be built says so instead of reading
          like a run with nothing dead."""
          assert render_exclusion_disclosure({"pruned_finding_paths": {
              "paths": [], "count": 0, "rename_map_complete": False}}) == (
              "_Renames could not be read from git history: findings may name "
              "pre-rename paths, and none were pruned._"
          )
          assert render_exclusion_disclosure({"pruned_finding_paths": {
              "paths": [], "count": 0, "rename_map_complete": True}}) == ""
      
      
      def test_attention_low_signal_disclosed_in_report() -> None:
          """A low-signal ranking says why only rank 1 is prescribed; false says nothing."""
          assert render_exclusion_disclosure({"attention_low_signal": True}) == (
              "_Attention ranking is low signal (no attention row lands in more than one "
              "finding): only rank 1 is prescribed._"
          )
          assert render_exclusion_disclosure({"attention_low_signal": False}) == ""
      
      
      def test_report_code_data_maxima_quoted_separately() -> None:
          ctx = _full_ctx()
          ctx["stats_summary"]["loc"].update({"max_code": 761.0, "max_data": 7137.0})
          ctx["stats_summary"]["loc"]["max"] = 7137.0
          lines = [ln for ln in render_report(ctx, "repo").splitlines()
                   if "**Complexity profile:**" in ln]
          assert len(lines) == 1
          line = lines[0]
          assert "code 761" in line and "data 7137" in line
          assert "(max 7137;" in line
      
      
      def test_report_code_data_maxima_absent_on_older_snapshot() -> None:
          """A pre-split stats snapshot has no max_code / max_data; the line keeps
          its old shape rather than printing '?' placeholders."""
          line = next(ln for ln in render_report(_full_ctx(), "repo").splitlines()
                      if "**Complexity profile:**" in ln)
          assert line == ("- **Complexity profile:** p95 LOC 483.6 (max 761), "
                          "p95 CCN 107.3 (max 169)")
      
    • test_badge.py 4.6 KB
      """Tests for the shields.io endpoint badge (lib/badge.py) and its producers.
      
      The badge is a self-description, so the contract under test is honesty: colours
      are pure threshold functions, the fallback only claims what was measured, and a
      deterministic-only run never downgrades a finalized score badge.
      """
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      from lib.badge import (
          badge_exists,
          concern_count_from_findings,
          fallback_badge,
          maturity_band,
          score_badge,
          score_color,
          write_badge,
      )
      
      
      def test_score_color_bands():
          """Hand-computed band edges: first matching floor wins."""
          assert score_color(8.0) == "brightgreen"
          assert score_color(7.0) == "brightgreen"
          assert score_color(6.5) == "green"
          assert score_color(5.5) == "green"
          assert score_color(4.0) == "yellowgreen"
          assert score_color(2.5) == "yellow"
          assert score_color(1.0) == "orange"
          assert score_color(0.5) == "red"
          assert score_color(0.0) == "red"
      
      
      def test_score_badge_shape():
          badge = score_badge(7.0, "AI-Native")
          assert badge == {
              "schemaVersion": 1,
              "label": "AI-readiness",
              "message": "7.0/8 · AI-Native",
              "color": "brightgreen",
          }
      
      
      def test_fallback_badge_clean_repo_is_green():
          badge = fallback_badge(0, 0)
          assert badge["message"] == "0 findings · 0 stale markers"
          assert badge["color"] == "green"
      
      
      def test_fallback_badge_colors_scale_with_concerns():
          assert fallback_badge(1, 3)["color"] == "yellow"
          assert fallback_badge(2, 0)["color"] == "yellow"
          assert fallback_badge(3, 0)["color"] == "orange"
      
      
      def test_concern_count_ignores_refactor_boundary_and_empty():
          findings = [
              {"name": "hidden_coupling", "paths": ["a.py"], "action": "x"},
              {"name": "lying_map", "paths": [], "action": "x"},
              {"name": "unactioned_intent", "paths": ["b.py"], "action": "x"},
              {"name": "refactor_boundary", "paths": ["safe/"], "action": "x"},
          ]
          assert concern_count_from_findings(findings) == 2
      
      
      def test_write_and_exists_roundtrip(tmp_path: Path):
          assert not badge_exists(tmp_path)
          write_badge(tmp_path, score_badge(5.0, "Solid"))
          assert badge_exists(tmp_path)
          data = json.loads((tmp_path / "badge.json").read_text(encoding="utf-8"))
          assert data["schemaVersion"] == 1
          assert data["message"] == "5.0/8 · Solid"
      
      
      def test_score_badge_knowledge_base_denominator():
          """A KB renormalises the denominator over its applicable layers (#224)."""
          badge = score_badge(2.5, "Knowledge Base · Solid", denominator=3)
          assert badge["message"] == "2.5/3 · Knowledge Base · Solid"
          # 2.5/3 = 0.833 -> green band (>= 0.6875), not the misleading 2.5/8 yellow.
          assert badge["color"] == "green"
      
      
      def test_score_color_normalises_over_denominator():
          # Full marks on a KB denominator is brightgreen, not red.
          assert score_color(3.0, 3) == "brightgreen"
          assert score_color(0.0, 3) == "red"
          # Default denominator 8 reproduces the original absolute bands.
          assert score_color(7.0) == "brightgreen"
          assert score_color(7.0, 8) == "brightgreen"
      
      
      def test_maturity_band_ladder():
          """The documented ladder: >=0.875 AI-Native, >=0.625 Solid, >=0.375 Basic,
          else Not Ready. Band edges hit exactly."""
          assert maturity_band(7.0) == "AI-Native"   # 0.875
          assert maturity_band(6.9) == "Solid"       # 0.8625
          assert maturity_band(5.0) == "Solid"       # 0.625
          assert maturity_band(4.9) == "Basic"       # 0.6125
          assert maturity_band(3.0) == "Basic"       # 0.375
          assert maturity_band(2.9) == "Not Ready"   # 0.3625
          assert maturity_band(0.0) == "Not Ready"
      
      
      def test_maturity_band_normalises_over_denominator():
          # A KB scoring full marks earns AI-Native, not the misleading 2.5/8 read.
          assert maturity_band(3.0, 3) == "AI-Native"
          assert maturity_band(2.5, 3) == "Solid"   # 0.833
      
      
      def test_score_badge_stamps_run_id_when_supplied():
          badge = score_badge(6.0, "Solid", run_id="20260707120000-abcdef01")
          assert badge["run_id"] == "20260707120000-abcdef01"
          # Message/colour are unchanged by the extra provenance field.
          assert badge["message"] == "6.0/8 · Solid"
      
      
      def test_badge_run_id_omitted_by_default():
          """No run_id -> no key, so the badge dict is byte-identical to before."""
          assert "run_id" not in score_badge(6.0, "Solid")
          assert "run_id" not in fallback_badge(1, 0)
      
      
      def test_fallback_badge_stamps_run_id_when_supplied():
          badge = fallback_badge(2, 1, run_id="20260707120000-abcdef01")
          assert badge["run_id"] == "20260707120000-abcdef01"
          assert badge["message"] == "2 findings · 1 stale markers"
      
    • test_change_coupling.py 19.9 KB
      """Tests for the git-log change-coupling and authorship primitives (B1/B2/B4).
      
      Fixtures build synthetic git histories in tmp dirs. Expected values are
      hand-computed in each test's docstring/comments so the contract is auditable.
      """
      from __future__ import annotations
      
      import os
      import subprocess
      from pathlib import Path
      
      from lib.change_coupling import (
          RenameMap,
          authorship_analysis,
          build_rename_map,
          change_coupling_pairs,
          containment_ratio,
          find_self_referential_tests,
          fold_renames,
          parse_commit_file_sets,
      )
      
      
      def _git(repo: Path, *args: str, env: dict | None = None) -> None:
          full_env = {**os.environ, **(env or {})}
          subprocess.run(["git", "-C", str(repo), *args],
                         check=True, capture_output=True, text=True, env=full_env)
      
      
      def _write(repo: Path, rel: str, text: str) -> None:
          p = repo / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      def _commit(
          repo: Path,
          files: dict[str, str],
          message: str = "change",
          *,
          author: tuple[str, str] | None = None,
          committer: tuple[str, str] | None = None,
          co_authors: list[str] | None = None,
      ) -> None:
          """Write ``files`` (rel path -> contents), stage, and commit.
      
          ``author``/``committer`` are (name, email) tuples; ``co_authors`` is a list
          of "Name <email>" strings folded into Co-Authored-By trailers.
          """
          for rel, text in files.items():
              _write(repo, rel, text)
          _git(repo, "add", "-A")
          body = message
          for ca in co_authors or []:
              body += f"\n\nCo-Authored-By: {ca}"
          env: dict[str, str] = {}
          if author:
              env["GIT_AUTHOR_NAME"], env["GIT_AUTHOR_EMAIL"] = author
          if committer:
              env["GIT_COMMITTER_NAME"], env["GIT_COMMITTER_EMAIL"] = committer
          _git(repo, "commit", "-q", "-m", body, env=env)
      
      
      def _init_repo(tmp_path: Path) -> Path:
          repo = tmp_path / "repo"
          repo.mkdir()
          _git(repo, "init", "-q")
          _git(repo, "config", "user.email", "dev@example.com")
          _git(repo, "config", "user.name", "Dev Human")
          return repo
      
      
      # --- parse_commit_file_sets --------------------------------------------------
      
      def test_parse_commit_file_sets_one_set_per_commit(tmp_path: Path) -> None:
          repo = _init_repo(tmp_path)
          _commit(repo, {"a.py": "1", "b.py": "1"}, "c1")
          _commit(repo, {"a.py": "2"}, "c2")
      
          sets = parse_commit_file_sets(repo)
          # Newest first: [{a.py}, {a.py, b.py}]
          assert len(sets) == 2
          assert sets[0] == {Path("a.py")}
          assert sets[1] == {Path("a.py"), Path("b.py")}
      
      
      def test_parse_commit_file_sets_no_git_returns_empty(tmp_path: Path) -> None:
          plain = tmp_path / "nogit"
          plain.mkdir()
          (plain / "a.py").write_text("x", encoding="utf-8")
          assert parse_commit_file_sets(plain) == []
      
      
      def test_parse_commit_file_sets_since_window(tmp_path: Path) -> None:
          repo = _init_repo(tmp_path)
          old_date = "2020-01-01T00:00:00"
          _commit_env = {"GIT_AUTHOR_DATE": old_date, "GIT_COMMITTER_DATE": old_date}
          _write(repo, "old.py", "x")
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", "old", env=_commit_env)
          _commit(repo, {"new.py": "y"}, "new")  # committed "now"
      
          sets = parse_commit_file_sets(repo, since="1 year ago")
          flat = {p for s in sets for p in s}
          assert Path("new.py") in flat
          assert Path("old.py") not in flat
      
      
      # --- change_coupling_pairs (B1) ----------------------------------------------
      
      def test_change_coupling_detects_co_changing_trio(tmp_path: Path) -> None:
          """Three files always committed together over 4 commits -> 3 pairs, count 4."""
          repo = _init_repo(tmp_path)
          for i in range(4):
              _commit(repo, {"a.py": str(i), "b.py": str(i), "c.py": str(i)}, f"c{i}")
      
          sets = parse_commit_file_sets(repo)
          pairs = change_coupling_pairs(sets, min_support=3)
          # combinations of {a,b,c} = (a,b),(a,c),(b,c); each co-changed 4 times.
          assert len(pairs) == 3
          for p in pairs:
              assert p["co_change_count"] == 4
              # 4 of 4 commits -> 100% support.
              assert p["support_pct"] == 100.0
          keys = {(p["file_a"], p["file_b"]) for p in pairs}
          assert keys == {("a.py", "b.py"), ("a.py", "c.py"), ("b.py", "c.py")}
      
      
      def test_change_coupling_respects_min_support(tmp_path: Path) -> None:
          """A pair co-changing only twice is dropped at the default min_support=3."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"x.py": "1", "y.py": "1"}, "c1")
          _commit(repo, {"x.py": "2", "y.py": "2"}, "c2")  # x,y co-change = 2
          _commit(repo, {"x.py": "3"}, "c3")
      
          sets = parse_commit_file_sets(repo)
          assert change_coupling_pairs(sets, min_support=3) == []
          # Lowering the threshold surfaces it.
          low = change_coupling_pairs(sets, min_support=2)
          assert len(low) == 1
          assert low[0]["co_change_count"] == 2
          assert (low[0]["file_a"], low[0]["file_b"]) == ("x.py", "y.py")
      
      
      def test_change_coupling_single_file_commits_yield_no_pairs(tmp_path: Path) -> None:
          repo = _init_repo(tmp_path)
          for i in range(5):
              _commit(repo, {"solo.py": str(i)}, f"c{i}")
          sets = parse_commit_file_sets(repo)
          assert change_coupling_pairs(sets) == []
      
      
      def test_change_coupling_empty_input() -> None:
          assert change_coupling_pairs([]) == []
      
      
      # --- containment_ratio (B2) --------------------------------------------------
      
      def test_containment_fully_contained_module(tmp_path: Path) -> None:
          """A module whose commits never reach outside it has ratio 1.0."""
          repo = _init_repo(tmp_path)
          for i in range(3):
              _commit(repo, {f"mod/f{i}.py": str(i), "mod/core.py": str(i)}, f"c{i}")
          sets = parse_commit_file_sets(repo)
          assert containment_ratio(repo, "mod", sets) == 1.0
      
      
      def test_containment_bleeding_module(tmp_path: Path) -> None:
          """Module edits that routinely drag in outside files give a low ratio."""
          repo = _init_repo(tmp_path)
          # 1 self-contained commit...
          _commit(repo, {"mod/a.py": "1"}, "contained")
          # ...and 3 that also touch files outside the module.
          for i in range(3):
              _commit(repo, {"mod/a.py": f"v{i}", "other/b.py": f"v{i}"}, f"bleed{i}")
          sets = parse_commit_file_sets(repo)
          # 4 commits touch mod, only 1 touches mod-only -> 1/4 = 0.25
          assert containment_ratio(repo, "mod", sets) == 0.25
      
      
      def test_containment_zero_commits_returns_one(tmp_path: Path) -> None:
          """A module no commit touches is vacuously contained (documented: 1.0)."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"other/a.py": "1"}, "c1")
          sets = parse_commit_file_sets(repo)
          assert containment_ratio(repo, "nonexistent", sets) == 1.0
      
      
      def test_containment_accepts_absolute_module_path(tmp_path: Path) -> None:
          repo = _init_repo(tmp_path)
          for i in range(2):
              _commit(repo, {f"mod/f{i}.py": str(i)}, f"c{i}")
          sets = parse_commit_file_sets(repo)
          # Absolute path is normalised to repo-relative internally.
          assert containment_ratio(repo, repo / "mod", sets) == 1.0
      
      
      # --- authorship_analysis (B4) ------------------------------------------------
      
      def test_authorship_human_only(tmp_path: Path) -> None:
          repo = _init_repo(tmp_path)
          _commit(repo, {"app.py": "1"}, "c1",
                  author=("Alice", "alice@example.com"),
                  committer=("Alice", "alice@example.com"))
          r = authorship_analysis(repo, "app.py")
          assert r["authorship_class"] == "human"
          assert r["human_anchor"] is True
          assert r["intent_source"] is True
          assert r["contributors"][0]["email"] == "alice@example.com"
          assert r["contributors"][0]["classification"] == "human"
      
      
      def test_authorship_mixed_human_author_agent_coauthor(tmp_path: Path) -> None:
          """Human author + Claude co-author = agent involvement alongside a human."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"app.py": "1"}, "feat: thing",
                  author=("Bob", "bob@example.com"),
                  committer=("Bob", "bob@example.com"),
                  co_authors=["Claude <noreply@anthropic.com>"])
          r = authorship_analysis(repo, "app.py")
          assert r["authorship_class"] == "mixed"
          assert r["human_anchor"] is True
          assert r["intent_source"] is True
      
      
      def test_authorship_pure_agent_via_bot_committer(tmp_path: Path) -> None:
          """Bot author + bot committer with no human anywhere -> 'agent'."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"dep.lock": "1"}, "chore: bump",
                  author=("dependabot[bot]", "49699333+dependabot[bot]@users.noreply.github.com"),
                  committer=("dependabot[bot]", "49699333+dependabot[bot]@users.noreply.github.com"))
          r = authorship_analysis(repo, "dep.lock")
          assert r["authorship_class"] == "agent"
          assert r["human_anchor"] is False
          assert r["intent_source"] is False
          assert r["contributors"][0]["classification"] == "agent"
      
      
      def test_authorship_agent_authored_human_committed(tmp_path: Path) -> None:
          """Bot author + human committer: class is 'agent', but intent_source is True."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"x.py": "1"}, "apply bot patch",
                  author=("renovate[bot]", "29139614+renovate[bot]@users.noreply.github.com"),
                  committer=("Carol", "carol@example.com"))
          r = authorship_analysis(repo, "x.py")
          # agent author + human committer: agent involved, but no human *author*, and
          # n_unknown==0, so the class is 'agent'. The human committer still makes
          # them the intent source (a human directed/applied the change).
          assert r["authorship_class"] == "agent"
          assert r["human_anchor"] is False
          assert r["intent_source"] is True
      
      
      def test_authorship_human_named_claude_not_libelled(tmp_path: Path) -> None:
          """A real human named 'Claude' with a normal e-mail must NOT be called agent."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"app.py": "1"}, "c1",
                  author=("Claude Dupont", "claude.dupont@example.com"),
                  committer=("Claude Dupont", "claude.dupont@example.com"))
          r = authorship_analysis(repo, "app.py")
          assert r["authorship_class"] == "human"
          assert r["human_anchor"] is True
          assert r["contributors"][0]["classification"] == "human"
      
      
      def test_authorship_no_history_degrades(tmp_path: Path) -> None:
          plain = tmp_path / "nogit"
          plain.mkdir()
          r = authorship_analysis(plain, "whatever.py")
          assert r == {
              "human_anchor": False,
              "authorship_class": "unknown",
              "intent_source": False,
              "contributors": [],
          }
      
      
      def test_authorship_contributor_line_stats(tmp_path: Path) -> None:
          """numstat line counts aggregate per author across commits."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"app.py": "line1\nline2\n"}, "c1",
                  author=("Alice", "alice@example.com"),
                  committer=("Alice", "alice@example.com"))
          _commit(repo, {"app.py": "line1\nline2\nline3\n"}, "c2",
                  author=("Alice", "alice@example.com"),
                  committer=("Alice", "alice@example.com"))
          r = authorship_analysis(repo, "app.py")
          alice = r["contributors"][0]
          assert alice["commits"] == 2
          # First commit adds 2 lines; second adds 1 more. 3 added total, 0 removed.
          assert alice["lines_added"] == 3
          assert alice["lines_removed"] == 0
      
      
      # --- E2: find_self_referential_tests ------------------------------------------
      
      def test_self_referential_test_same_commit_flagged(tmp_path: Path) -> None:
          """Test + code introduced in one commit -> self-referential."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"pkg/svc.go": "package pkg",
                         "pkg/svc_test.go": "package pkg"}, "feat: svc + test together")
          result = find_self_referential_tests(
              repo, {"pkg/svc_test.go": "pkg/svc.go"}
          )
          assert result == [{
              "test_file": "pkg/svc_test.go",
              "source_file": "pkg/svc.go",
              "reason": "test added in same commit as code",
          }]
      
      
      def test_self_referential_test_separate_commits_not_flagged(tmp_path: Path) -> None:
          """Code first, test in a later commit -> not the same-commit signal."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"pkg/svc.go": "package pkg"}, "feat: svc")
          _commit(repo, {"pkg/svc_test.go": "package pkg"}, "test: svc")
          result = find_self_referential_tests(
              repo, {"pkg/svc_test.go": "pkg/svc.go"}
          )
          assert result == []
      
      
      def test_self_referential_test_empty_map_returns_empty(tmp_path: Path) -> None:
          repo = _init_repo(tmp_path)
          _commit(repo, {"a.py": "1"}, "c1")
          assert find_self_referential_tests(repo, {}) == []
      
      
      def test_self_referential_test_reuses_passed_commit_sets(tmp_path: Path) -> None:
          """commit_sets passed in (the orchestrator's single git-log parse) is used
          directly - no second git call needed."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"m.py": "1", "test_m.py": "1"}, "feat+test")
          commit_sets = parse_commit_file_sets(repo)
          result = find_self_referential_tests(
              repo, {"test_m.py": "m.py"}, commit_sets=commit_sets
          )
          assert [r["source_file"] for r in result] == ["m.py"]
      
      
      # --- build_rename_map / fold_renames (renamed paths) --------------------------
      
      def test_rename_map_folds_history_onto_current_paths(tmp_path: Path) -> None:
          """a/x.py and a/y.py co-change 3 times, then a/ -> b/ -> c/. The map
          resolves the chain to c/, and folding counts the pre-rename history under
          the current names: 3 edits + 2 rename commits = 5 co-changes."""
          repo = _init_repo(tmp_path)
          for i in range(3):
              _commit(repo, {"a/x.py": f"x = {i}\n" * 5, "a/y.py": f"y = {i}\n" * 5})
          _git(repo, "mv", "a", "b")
          _commit(repo, {}, "rename a -> b")
          _git(repo, "mv", "b", "c")
          _commit(repo, {}, "rename b -> c")
      
          rename_map = build_rename_map(repo)
          assert rename_map.complete
          assert rename_map.paths == {
              "a/x.py": "c/x.py", "a/y.py": "c/y.py",
              "b/x.py": "c/x.py", "b/y.py": "c/y.py",
          }
          pairs = change_coupling_pairs(
              fold_renames(parse_commit_file_sets(repo), rename_map.paths), min_support=1,
          )
          assert [(p["file_a"], p["file_b"], p["co_change_count"]) for p in pairs] == [
              ("c/x.py", "c/y.py", 5),
          ]
      
      
      def test_rename_map_skips_path_reused_at_head(tmp_path: Path) -> None:
          """A name that exists again at HEAD keeps its own history, so it is left
          out of the map; outside a git repo there is no history, so the map is
          empty and complete (only a failed git read marks it incomplete)."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"old.py": "v = 1\n" * 5})
          _git(repo, "mv", "old.py", "new.py")
          _commit(repo, {}, "rename")
          _commit(repo, {"old.py": "fresh = 1\n"}, "reuse the name")
      
          assert build_rename_map(repo) == RenameMap({}, complete=True)
          plain = tmp_path / "plain"
          plain.mkdir()
          assert build_rename_map(plain) == RenameMap({}, complete=True)
          assert fold_renames([{Path("a.py")}], {}) == [{Path("a.py")}]
      
      
      def test_rename_map_incomplete_when_git_log_times_out(tmp_path: Path, monkeypatch) -> None:
          """A git timeout yields an empty, incomplete map rather than an empty map
          that reads as "nothing was renamed"."""
          import lib.change_coupling as cc
      
          repo = _init_repo(tmp_path)
          _commit(repo, {"a.py": "v = 1\n"})
          top = cc.repo_top(repo)
          assert top is not None
      
          def boom(*args, **kwargs):
              raise subprocess.TimeoutExpired(cmd="git log", timeout=cc.GIT_TIMEOUT_SECONDS)
      
          monkeypatch.setattr(cc.subprocess, "run", boom)
          assert cc.build_rename_map(repo, top=top) == RenameMap({}, complete=False)
      
      
      def test_rename_map_resolves_chain_through_reused_intermediate(tmp_path: Path) -> None:
          """a -> b, b -> c, then a fresh b. The chain resolves before the reused-name
          filter, so a maps to c (its real successor), and b, which exists again,
          is left out."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"a.py": "orig = 1\n" * 5})
          _git(repo, "mv", "a.py", "b.py")
          _commit(repo, {}, "a -> b")
          _git(repo, "mv", "b.py", "c.py")
          _commit(repo, {}, "b -> c")
          _commit(repo, {"b.py": "fresh = 1\n"}, "fresh b")
      
          assert build_rename_map(repo) == RenameMap({"a.py": "c.py"}, complete=True)
      
      
      def test_rename_map_keeps_non_ascii_paths_literal(tmp_path: Path) -> None:
          """A non-ASCII path comes back as written, not octal-escaped, in both the
          rename map and the commit file sets, so it matches the file on disk."""
          repo = _init_repo(tmp_path)
          for i in range(2):
              _commit(repo, {"caf\u00e9/x.py": f"x = {i}\n" * 5})
          _git(repo, "mv", "caf\u00e9", "cr\u00e8me")
          _commit(repo, {}, "rename")
      
          assert build_rename_map(repo).paths == {"caf\u00e9/x.py": "cr\u00e8me/x.py"}
          files = set().union(*parse_commit_file_sets(repo))
          assert Path("cr\u00e8me/x.py") in files
          assert all((repo / f).exists() or f.parts[0] == "caf\u00e9" for f in files)
      
      
      def test_rename_map_orders_chain_edges_in_time(tmp_path: Path) -> None:
          """A chain follows only renames made in later commits. b -> c frees the
          name b, and a later a -> b refills it: a maps to b, not through to c. The
          reverse order (a -> b, then b -> c) is a real chain and a maps to c."""
          (tmp_path / "freed").mkdir()
          (tmp_path / "chained").mkdir()
          freed = _init_repo(tmp_path / "freed")
          _commit(freed, {"a.py": "a = 1\n" * 5, "b.py": "b = 2\n" * 5})
          _git(freed, "mv", "b.py", "c.py")
          _commit(freed, {}, "b -> c")
          _git(freed, "mv", "a.py", "b.py")
          _commit(freed, {}, "a -> b refills the freed name")
          # b.py exists again, so it keeps its own entry out of the map.
          assert build_rename_map(freed) == RenameMap({"a.py": "b.py"}, complete=True)
      
          chained = _init_repo(tmp_path / "chained")
          _commit(chained, {"a.py": "a = 1\n" * 5})
          _git(chained, "mv", "a.py", "b.py")
          _commit(chained, {}, "a -> b")
          _git(chained, "mv", "b.py", "c.py")
          _commit(chained, {}, "b -> c")
          assert build_rename_map(chained) == RenameMap(
              {"a.py": "c.py", "b.py": "c.py"}, complete=True)
      
      
      
      def test_rename_map_starts_chain_from_first_rename(tmp_path: Path) -> None:
          """a -> b, then an unrelated a is created and later renamed a -> c. The
          original a's history belongs to b, so the chain starts from a's first
          rename, the same rule the rest of the walk follows."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"a.py": "original = 1\n" * 5})
          _git(repo, "mv", "a.py", "b.py")
          _commit(repo, {}, "a -> b")
          _commit(repo, {"a.py": "unrelated = 2\n" * 5}, "a fresh a")
          _git(repo, "mv", "a.py", "c.py")
          _commit(repo, {}, "second a -> c")
          assert build_rename_map(repo) == RenameMap({"a.py": "b.py"}, complete=True)
      
      
      def _sibling_rename_merge(repo: Path, into: str, other: str) -> None:
          """Branch ``refill`` frees b.py and renames a.py -> b.py; ``main`` renames
          b.py -> c.py. Merge ``other`` into ``into`` and keep both files."""
          _commit(repo, {"a.py": "a = 1\n" * 5, "b.py": "b = 2\n" * 5}, "base")
          _git(repo, "branch", "-M", "main")
          _git(repo, "checkout", "-q", "-b", "refill")
          _git(repo, "rm", "-q", "b.py")
          _commit(repo, {}, "free b")
          _git(repo, "mv", "a.py", "b.py")
          _commit(repo, {}, "a -> b")
          _git(repo, "checkout", "-q", "main")
          _git(repo, "mv", "b.py", "c.py")
          _commit(repo, {}, "b -> c")
          _git(repo, "checkout", "-q", into)
          subprocess.run(["git", "-C", str(repo), "merge", "-q", "--no-edit", other],
                         capture_output=True, text=True)
          for leftover in ("a.py",):
              if (repo / leftover).exists():
                  (repo / leftover).unlink()
          _write(repo, "b.py", "a = 1\n" * 5)
          _write(repo, "c.py", "b = 2\n" * 5)
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "--allow-empty", "-m", "merge")
      
      
      def test_rename_map_does_not_chain_across_sibling_branches(tmp_path: Path) -> None:
          """One branch renames b -> c while a sibling frees b and renames a -> b.
          After the merge both b and c exist; the two renames are unrelated, so a maps
          to b, never through to c. Merged both ways, so either branch can be the one
          git log lists first."""
          for into, other in (("main", "refill"), ("refill", "main")):
              (tmp_path / into).mkdir()
              repo = _init_repo(tmp_path / into)
              _sibling_rename_merge(repo, into, other)
              assert build_rename_map(repo) == RenameMap({"a.py": "b.py"}, complete=True), into
      
    • test_ci_workflow.py 9.9 KB
      """Tests for the frozen-harness workflow emitter (lib/ci_workflow.py).
      
      The emitter bakes the discovered toolchain into a GitHub Action. These tests pin
      the substitution (version, branch, tool steps), the literal-dollar escaping for
      shell vars, the supply-chain pins (no @latest, actions on commit SHAs), the
      warn-only infra degrade (a failed fetch/install skips instead of failing), and
      that the result is well-formed YAML when a parser is available.
      """
      from __future__ import annotations
      
      import re
      
      import pytest
      
      from lib.ci_workflow import emit_ci_workflow, find_path_filtered_workflow, render_ci_workflow
      
      # Every external tool the emitter knows how to install - renders the maximal
      # workflow so the supply-chain assertions cover all recipes.
      ALL_EXTERNAL_TOOLS = ["scc", "staticcheck", "ts-prune", "knip"]
      
      
      def test_render_substitutes_version_and_branch():
          out = render_ci_workflow(plugin_version="1.23.0", default_branch="develop")
          assert "ai-native-toolkit v1.23.0" in out
          assert "uses: bjcoombs/ai-native-toolkit@v1.23.0" in out
          assert "branches: [develop]" in out
      
      
      def test_render_leaves_no_unresolved_placeholders():
          out = render_ci_workflow(plugin_version="1.23.0")
          assert "$plugin_version" not in out
          assert "$tool_steps" not in out
          assert "$default_branch" not in out
          assert "$generated_date" not in out
      
      
      def test_working_directory_uses_actions_expression_not_shell_var():
          """`working-directory:` only expands `${{ }}` expressions, not shell `${VAR}`.
      
          Regression: a `working-directory: ${RUNNER_TEMP}/...` cd's into a literal
          `${RUNNER_TEMP}` dir and the step dies with 'No such file or directory'. The
          runner temp dir must be referenced as the GitHub expression `${{ runner.temp }}`.
          """
          out = render_ci_workflow(plugin_version="1.23.0")
          for line in out.splitlines():
              stripped = line.strip()
              if stripped.startswith("working-directory:"):
                  assert "${{ runner.temp }}" in stripped, stripped
                  assert "${RUNNER_TEMP}" not in stripped, stripped
      
      
      def test_render_emits_scc_install_step():
          out = render_ci_workflow(plugin_version="1.23.0", discovered_tools=["lizard", "scc"])
          assert "Install scc" in out
          assert "go install github.com/boyter/scc" in out
      
      
      def test_render_contains_no_floating_latest():
          """Supply-chain pin: @latest makes the frozen contract non-deterministic.
      
          A future scc (or other tool) release could shift complexity-stats.json and
          move the regression baseline without any change in the assessed tree.
          """
          out = render_ci_workflow(plugin_version="1.23.0", discovered_tools=ALL_EXTERNAL_TOOLS)
          assert "@latest" not in out
      
      
      def test_render_pins_go_and_npm_tools_to_versions():
          out = render_ci_workflow(plugin_version="1.23.0", discovered_tools=ALL_EXTERNAL_TOOLS)
          for line in out.splitlines():
              if "go install" in line or "npm install" in line:
                  assert re.search(r"@v?\d", line), f"unpinned install: {line.strip()}"
      
      
      def test_render_pins_actions_to_commit_shas():
          """Every third-party action rides a full commit SHA with a version comment.
      
          Mutable tags (@v4) can be re-pointed by the action's maintainer (or an
          attacker with push access), silently changing what runs in the gate.
          """
          out = render_ci_workflow(plugin_version="1.23.0", discovered_tools=ALL_EXTERNAL_TOOLS)
          uses_lines = [ln for ln in out.splitlines() if ln.strip().startswith("uses:") or " uses:" in ln]
          assert uses_lines, "expected at least one uses: step"
          for line in uses_lines:
              if "bjcoombs/ai-native-toolkit@" in line:
                  # First-party exception: this repo publishes IMMUTABLE releases, so
                  # the tag cannot be re-pointed - the guarantee SHA-pinning buys for
                  # third-party actions. A tag pin here is what lets Dependabot
                  # propose readable version bumps to consumers.
                  ref = line.split("@", 1)[1].strip()
                  assert re.fullmatch(r"v\d+\.\d+\.\d+", ref), f"not semver-tag-pinned: {line.strip()}"
                  continue
              ref = line.split("@", 1)[1].split("#", 1)[0].strip()
              assert re.fullmatch(r"[0-9a-f]{40}", ref), f"not SHA-pinned: {line.strip()}"
              assert re.search(r"#\s*v\d", line), f"missing version comment: {line.strip()}"
      
      
      def test_checkout_does_not_persist_credentials():
          """Nothing downstream pushes, so the checked-out token must not linger."""
          out = render_ci_workflow(plugin_version="1.23.0")
          assert "persist-credentials: false" in out
      
      
      def test_render_delegates_to_the_action_not_a_clone():
          """The gate logic ships as a composite action; the emitted workflow is a
          thin pin. No `git clone` of the toolkit may remain - a clone inside a run:
          step is invisible to Dependabot/Renovate, which was the defect this action
          exists to fix. Infra degrade now lives inside the action itself."""
          out = render_ci_workflow(plugin_version="1.23.0")
          assert "git clone" not in out
          assert "uses: bjcoombs/ai-native-toolkit@v1.23.0" in out
          assert "config: .assess/config.toml" in out
          # The Dependabot enablement snippet ships in the header comment.
          assert "package-ecosystem: github-actions" in out
      
      
      def test_tool_install_failures_do_not_red_the_check():
          """Each install step degrades to reduced coverage, not a failed check."""
          out = render_ci_workflow(plugin_version="1.23.0", discovered_tools=ALL_EXTERNAL_TOOLS)
          installs = out.count("go install") + out.count("npm install")
          assert installs == len(ALL_EXTERNAL_TOOLS)
          # One continue-on-error per tool install; rg/uv degrade now lives inside
          # the composite action, not the emitted workflow.
          assert out.count("continue-on-error: true") == installs
      
      
      def test_render_skips_python_dep_tools():
          """Python deps (lizard, grimp) ride uv - they never get an OS install step."""
          out = render_ci_workflow(plugin_version="1.23.0", discovered_tools=["lizard", "grimp"])
          assert "Install scc" not in out
          assert "without an install recipe" not in out
      
      
      def test_render_comments_unknown_tools():
          out = render_ci_workflow(plugin_version="1.23.0", discovered_tools=["weirdtool"])
          assert "without an install recipe: weirdtool" in out
      
      
      def test_render_dedupes_tools():
          out = render_ci_workflow(plugin_version="1.23.0", discovered_tools=["scc", "scc"])
          assert out.count("Install scc") == 1
      
      
      def test_render_is_valid_yaml():
          yaml = pytest.importorskip("yaml")
          out = render_ci_workflow(plugin_version="1.23.0", discovered_tools=ALL_EXTERNAL_TOOLS)
          doc = yaml.safe_load(out)
          assert doc["name"] == "Assess Gate"
          # PyYAML parses the unquoted `on:` key as boolean True (the YAML 1.1 norm).
          on = doc.get("on", doc.get(True))
          assert "pull_request" in on
          assert "assess" in doc["jobs"]
      
      
      def test_emit_writes_workflow_file(tmp_path):
          path = emit_ci_workflow(tmp_path, ["scc"], "1.23.0", default_branch="main")
          assert path == tmp_path / ".github" / "workflows" / "assess-gate.yml"
          assert path.is_file()
          assert "Assess Gate" in path.read_text()
      
      
      def test_emit_creates_nested_dirs(tmp_path):
          """No pre-existing .github/ - the emitter creates the full path."""
          path = emit_ci_workflow(tmp_path, [], "1.23.0")
          assert path.is_file()
      
      
      def _pull_request(out: str) -> dict:
          yaml = pytest.importorskip("yaml")
          doc = yaml.safe_load(out)
          on = doc.get("on", doc.get(True))  # PyYAML reads a bare `on:` key as True
          return on["pull_request"]
      
      
      def test_render_paths_ignore_flag_yields_valid_yaml():
          out = render_ci_workflow(plugin_version="1.23.0", paths_ignore=["**/*.md"])
          assert _pull_request(out) == {"branches": ["main"], "paths-ignore": ["**/*.md"]}
      
      
      def test_render_paths_flag_repeatable_keeps_order():
          out = render_ci_workflow(plugin_version="1.23.0", paths=["src/**", "lib/**", "it's/**"])
          assert _pull_request(out) == {"branches": ["main"], "paths": ["src/**", "lib/**", "it's/**"]}
      
      
      def test_render_rejects_paths_with_paths_ignore():
          with pytest.raises(ValueError):
              render_ci_workflow(plugin_version="1.23.0", paths=["src/**"], paths_ignore=["**/*.md"])
      
      
      def test_render_without_path_filters_is_unchanged():
          out = render_ci_workflow(plugin_version="1.23.0")
          assert "paths" not in out
          assert "    branches: [main]\n\npermissions:" in out
      
      
      def _workflows(tmp_path, files: dict[str, str]):
          wf = tmp_path / ".github" / "workflows"
          wf.mkdir(parents=True)
          for name, body in files.items():
              (wf / name).write_text(body)
          return wf
      
      
      @pytest.mark.parametrize(
          "body",
          [
              "on:\n  pull_request:\n    paths:\n      - src/**\n",
              "on:\n  push:\n  pull_request_target:\n    branches: [main]\n    # docs\n\n    paths-ignore: ['docs/**']\n",
              "on:\n  pull_request: {branches: [main], paths: ['src/**']}\n",
              "jobs:\n  t:\n    steps:\n      - uses: dorny/paths-filter@v3\n",
          ],
      )
      def test_path_filter_default_applied_detects_filtered_workflow(tmp_path, body):
          wf = _workflows(tmp_path, {"a-plain.yml": "on: push\n", "ci.yaml": body})
          assert find_path_filtered_workflow(tmp_path) == wf / "ci.yaml"
      
      
      def test_path_filter_default_not_applied_without_filtered_workflow(tmp_path):
          assert find_path_filtered_workflow(tmp_path) is None  # no .github/workflows at all
          _workflows(
              tmp_path,
              {
                  "ci.yml": "on:\n  pull_request:\njobs:\n  t:\n    steps:\n      - uses: actions/cache@v4\n        with:\n          path: ~/.cache\n",
                  # The gate's own file is excluded, so a regenerated gate never detects its own default.
                  "assess-gate.yml": "on:\n  pull_request:\n    paths-ignore:\n      - '**/*.md'\n",
                  "notes.txt": "paths: [src]\n",
                  # A paths: filter on push only (a publish trigger) says nothing about PR checks.
                  "publish.yml": "on:\n  push:\n    paths: [.claude-plugin/plugin.json]\n  pull_request:\n    branches: [main]\njobs:\n  t:\n    steps:\n      - uses: actions/upload-artifact@v4\n        with:\n          paths: dist\n",
              },
          )
          assert find_path_filtered_workflow(tmp_path) is None
      
    • test_complexity_treemap.py 56.3 KB
      """Unit tests for the pure logic inside complexity-treemap.py.
      
      The script imports lizard/matplotlib/numpy at module load (it's a CLI wrapper),
      so those are stubbed in sys.modules before import. We only exercise functions
      that don't touch the real heavy deps: the build-artifact filter, the plugin
      version stamp, and the stats-sidecar enrichment (field naming + hotspot rank).
      """
      from __future__ import annotations
      
      import importlib.util
      import json
      import sys
      import types
      from pathlib import Path
      
      import pytest
      
      _SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "complexity-treemap.py"
      
      
      class _StubNumpy(types.ModuleType):
          """Minimal numpy: only percentile, with numpy's default linear interp."""
      
          @staticmethod
          def percentile(values, q):
              s = sorted(values)
              if not s:
                  return 0.0
              k = (len(s) - 1) * q / 100.0
              f = int(k)
              c = min(f + 1, len(s) - 1)
              return float(s[f] + (s[c] - s[f]) * (k - f))
      
      
      def _load_treemap():
          """Import complexity-treemap.py with heavy deps stubbed out."""
          for name in ("lizard", "matplotlib", "matplotlib.pyplot", "squarify"):
              sys.modules.setdefault(name, types.ModuleType(name))
          sys.modules.setdefault("numpy", _StubNumpy("numpy"))
          # complexity-treemap does `import matplotlib.pyplot as plt`
          sys.modules["matplotlib"].pyplot = sys.modules["matplotlib.pyplot"]
          spec = importlib.util.spec_from_file_location("complexity_treemap", _SCRIPT)
          mod = importlib.util.module_from_spec(spec)
          spec.loader.exec_module(mod)
          return mod
      
      
      @pytest.fixture(scope="module")
      def treemap():
          return _load_treemap()
      
      
      @pytest.fixture(scope="module")
      def render_lib(treemap):
          """The shared treemap_render module. Depends on `treemap` so the scripts
          dir is on sys.path and numpy is stubbed before import."""
          import importlib
      
          return importlib.import_module("lib.treemap_render")
      
      
      @pytest.mark.parametrize("name", [
          "canvaskit.js",
          "canvaskit/chromium/canvaskit.js",  # nested, basename still matches
          "skwasm.js",
          "skwasm_heavy.js",
          "main.dart.js",                     # pre-existing Flutter artifact
      ])
      def test_flutter_runtime_bundles_are_filtered(treemap, name):
          assert treemap._is_build_artifact(Path(name)) is True
      
      
      @pytest.mark.parametrize("name", ["app.js", "widget.dart", "canvaskit_helper.dart"])
      def test_real_source_is_not_filtered(treemap, name):
          assert treemap._is_build_artifact(Path(name)) is False
      
      
      def test_plugin_version_is_stamped_from_plugin_json(treemap):
          # Resolves the real .claude-plugin/plugin.json three dirs up; must be a real
          # version string, never the "unknown" fallback.
          version = treemap._read_plugin_version()
          assert version != "unknown"
          assert version[0].isdigit()
      
      
      def test_write_stats_uses_commits_field_and_balanced_rank(treemap, tmp_path):
          """The per-file churn count is emitted as `commits` (what consumers read),
          and the balanced composite ranks a moderately-complex active file above a
          very-complex frozen one (issue #47, observation 2 + 5)."""
          root = tmp_path
          frozen_complex = root / "frozen.go"   # high ccn, barely touched
          active_moderate = root / "active.go"  # moderate ccn, churning
          files = [
              (frozen_complex, 800, 1396.0, "lizard"),
              (active_moderate, 300, 140.0, "lizard"),
          ]
          aux_data = {frozen_complex: 1, active_moderate: 45}
          out = root / "stats.json"
          # The composite now includes a sqrt(est_tokens) size axis. Hold size equal
          # so this test isolates the churn axis it is about (issue #47); otherwise
          # frozen.go's larger size would confound the ranking.
          tokens = {frozen_complex: 1000, active_moderate: 1000}
          treemap.write_stats(files, aux_data, "commits (last 12mo)", root, out,
                              tokens_by_path=tokens)
      
          stats = json.loads(out.read_text())
          assert "plugin_version" in stats
          hotspots = stats["top_hotspots"]
          # Balanced composite: the active moderate-complexity file leads.
          assert hotspots[0]["path"] == "active.go"
          # Field is `commits`, not the legacy `churn`.
          for h in hotspots:
              assert "commits" in h
              assert "churn" not in h
          by_path = {h["path"]: h for h in hotspots}
          assert by_path["active.go"]["commits"] == 45
          assert by_path["frozen.go"]["commits"] == 1
      
      
      def test_write_stats_stamps_schema_and_tool_versions(treemap, tmp_path, monkeypatch):
          """The sidecar stamps the stats schema version and the complexity backend
          versions so a later run can detect a schema or tool change and void a
          non-comparable diff. scc_version is present only when scc scored files."""
          monkeypatch.setattr(treemap, "_lizard_version", lambda: "1.23.0")
          monkeypatch.setattr(treemap, "_scc_version", lambda: "3.7.0")
          root = tmp_path
          lz = root / "a.go"
          sc = root / "b.rb"
          out = root / "stats.json"
      
          # lizard-only: no scc_version key.
          treemap.write_stats([(lz, 100, 5.0, "lizard")], None, None, root, out)
          stats = json.loads(out.read_text())
          assert stats["schema_version"] == treemap.STATS_SCHEMA_VERSION
          assert stats["lizard_version"] == "1.23.0"
          assert "scc_version" not in stats
      
          # A file scored by scc adds scc_version.
          treemap.write_stats(
              [(lz, 100, 5.0, "lizard"), (sc, 80, 4.0, "scc")], None, None, root, out
          )
          stats = json.loads(out.read_text())
          assert stats["lizard_version"] == "1.23.0"
          assert stats["scc_version"] == "3.7.0"
      
      
      def test_tool_versions_omits_scc_when_not_scored(treemap, monkeypatch):
          """_tool_versions always carries lizard; scc appears only when a file was
          scored by scc AND scc is resolvable."""
          monkeypatch.setattr(treemap, "_lizard_version", lambda: "1.23.0")
          monkeypatch.setattr(treemap, "_scc_version", lambda: "3.7.0")
          assert treemap._tool_versions([(Path("a.go"), 1, 1.0, "lizard")]) == {
              "lizard": "1.23.0"
          }
          assert treemap._tool_versions([(Path("b.rb"), 1, 1.0, "scc")]) == {
              "lizard": "1.23.0", "scc": "3.7.0"
          }
      
      
      def test_tool_versions_drops_scc_when_binary_absent(treemap, monkeypatch):
          """When scc scored files but the binary can't report a version, scc is
          omitted rather than stamped as a false 'unknown'."""
          monkeypatch.setattr(treemap, "_lizard_version", lambda: "1.23.0")
          monkeypatch.setattr(treemap, "_scc_version", lambda: None)
          assert treemap._tool_versions([(Path("b.rb"), 1, 1.0, "scc")]) == {
              "lizard": "1.23.0"
          }
      
      
      def test_write_stats_records_churn_degenerate_flag(treemap, tmp_path):
          """Issue #172: the stats sidecar carries ``churn_degenerate`` so the report
          and a reader know the saturation axis / commits column is inactive. Default
          is False; passing the flag records True."""
          root = tmp_path
          f = root / "a.go"
          out = root / "stats.json"
          treemap.write_stats([(f, 100, 5.0, "lizard")], {f: 1}, "commits (all-time)",
                              root, out)
          assert json.loads(out.read_text())["churn_degenerate"] is False
      
          treemap.write_stats([(f, 100, 5.0, "lizard")], {f: 1}, "commits (all-time)",
                              root, out, churn_degenerate=True)
          assert json.loads(out.read_text())["churn_degenerate"] is True
      
      
      def test_write_stats_commits_none_without_git(treemap, tmp_path):
          """No churn data (no git) -> commits is None, distinct from a real 0."""
          root = tmp_path
          f = root / "a.go"
          out = root / "stats.json"
          treemap.write_stats([(f, 100, 5.0, "lizard")], None, None, root, out)
          stats = json.loads(out.read_text())
          assert stats["top_hotspots"][0]["commits"] is None
      
      
      def test_write_stats_separates_aggregate_from_per_function_ccn(treemap, tmp_path):
          """Issue #58: the file-level aggregate ccn (sum of per-function complexity)
          must be labelled as an aggregate and never conflated with the per-function
          value a linter threshold gates. A file summing to ccn 136 whose worst
          single function is only 13 is NOT a per-function violation."""
          root = tmp_path
          f = root / "service_modules.go"   # the issue's actual offender shape
          out = root / "stats.json"
          # 13 functions whose complexities sum to 136 (the reported aggregate),
          # worst single function = 13 (under a cyclop:15 threshold).
          fn_ccns = [13.0, 13.0, 12.0, 12.0, 11.0, 11.0, 10.0, 10.0,
                     9.0, 9.0, 8.0, 8.0, 10.0]
          assert sum(fn_ccns) == 136.0
          treemap.write_stats(
              [(f, 800, 136.0, "lizard")], {f: 3}, "commits (last 12mo)", root, out,
              fn_ccn_by_path={f: fn_ccns},
          )
          stats = json.loads(out.read_text())
      
          # The aggregate block self-labels and the per-function block is separate.
          assert stats["ccn"]["basis"] == "file-aggregate"
          assert stats["ccn"]["max"] == 136.0
          assert stats["fn_ccn"]["basis"] == "per-function"
          assert stats["fn_ccn"]["function_count"] == 13
          assert stats["fn_ccn"]["max"] == 13.0   # worst function, not the sum
      
          row = stats["top_complex"][0]
          assert row["ccn"] == 136.0              # aggregate preserved for the hue
          assert row["ccn_basis"] == "file-aggregate"
          assert row["max_fn_ccn"] == 13.0        # the per-function truth for Layer 3
      
      
      def test_write_stats_scc_file_has_null_max_fn_ccn(treemap, tmp_path):
          """scc reports file-level complexity with no function breakdown, so a
          scc-scored file carries max_fn_ccn=null - the report must not invent a
          per-function value it never measured."""
          root = tmp_path
          f = root / "report.sql"
          out = root / "stats.json"
          # No fn_ccn_by_path entry for this path -> scc-style, per-function unknown.
          treemap.write_stats([(f, 400, 50.0, "scc")], None, None, root, out,
                              fn_ccn_by_path={})
          stats = json.loads(out.read_text())
          assert stats["top_complex"][0]["max_fn_ccn"] is None
          assert stats["fn_ccn"]["function_count"] == 0
      
      
      def test_hotspot_rank_favours_per_function_offender(treemap, tmp_path):
          """Issue #115: for a class-per-file language the hotspot composite must rank
          on the worst single function, not the file aggregate, so a broad coordinator
          class can't bury a genuinely complex single method.
      
          Real shape from the first Java/JVM run: a coordinator at aggregate ccn 107
          whose worst method is only 14 (not a violation) out-ranked a DAO at ccn 28
          that is one complex method. With equal churn the DAO must now lead."""
          root = tmp_path
          coordinator = root / "Coordinator.java"  # broad: ccn 107, worst method 14
          dao = root / "Dao.java"                   # one genuinely complex method
          files = [
              (coordinator, 600, 107.0, "lizard"),
              (dao, 200, 28.0, "lizard"),
          ]
          # Many small methods summing to 107, worst single = 14.
          coordinator_fns = [14.0, 13.0, 12.0, 11.0, 10.0, 10.0, 9.0,
                             9.0, 8.0, 6.0, 5.0]
          assert sum(coordinator_fns) == 107.0
          dao_fns = [28.0]  # the single complex method is the whole file's ccn
          aux_data = {coordinator: 5, dao: 5}  # equal churn isolates the ccn re-weight
          out = root / "stats.json"
          # Equal est_tokens too, so the new sqrt(est_tokens) size axis doesn't
          # confound the per-function re-weight this test is about (issue #115).
          tokens = {coordinator: 1000, dao: 1000}
          treemap.write_stats(
              files, aux_data, "commits (last 12mo)", root, out,
              fn_ccn_by_path={coordinator: coordinator_fns, dao: dao_fns},
              tokens_by_path=tokens,
          )
          stats = json.loads(out.read_text())
          hotspots = stats["top_hotspots"]
      
          # The true per-function offender ranks at or above the coordinator class.
          assert hotspots[0]["path"] == "Dao.java"
          # The aggregate is still reported faithfully - only the ranking changed.
          by_path = {h["path"]: h for h in hotspots}
          assert by_path["Coordinator.java"]["ccn"] == 107.0
          assert by_path["Coordinator.java"]["max_fn_ccn"] == 14.0
          # The complexity-only rank (treemap hue) stays aggregate-driven.
          assert stats["top_complex"][0]["path"] == "Coordinator.java"
      
      
      def test_hotspot_rank_unchanged_for_single_function_per_file(treemap, tmp_path):
          """Must-not-regress guard (issue #115): for single-function-per-file
          languages (Python/Go) the aggregate is the worst function, so the
          per-function re-weight is a no-op and the existing ranking is preserved -
          the more-complex-and-equally-churned file still leads."""
          root = tmp_path
          complex_go = root / "complex.go"   # one big function, ccn 40
          simple_go = root / "simple.go"     # one small function, ccn 8
          files = [
              (complex_go, 300, 40.0, "lizard"),
              (simple_go, 120, 8.0, "lizard"),
          ]
          # aggregate == worst function: the per-function weight collapses to aggregate.
          aux_data = {complex_go: 10, simple_go: 10}
          out = root / "stats.json"
          treemap.write_stats(
              files, aux_data, "commits (last 12mo)", root, out,
              fn_ccn_by_path={complex_go: [40.0], simple_go: [8.0]},
          )
          stats = json.loads(out.read_text())
          hotspots = stats["top_hotspots"]
      
          # Ranking is unchanged: the genuinely complex file still leads.
          assert hotspots[0]["path"] == "complex.go"
          # And it matches the aggregate-only rank - no per-function divergence here.
          assert stats["top_complex"][0]["path"] == "complex.go"
      
      
      def test_effective_ccn_collapses_to_aggregate_without_per_function_data(treemap):
          """`_effective_ccn` returns the raw aggregate when there is no per-function
          signal (scc files: max_fn_ccn is None), and when the worst function already
          equals the aggregate (single-function file) - the two no-regression paths."""
          assert treemap._effective_ccn(50.0, None) == 50.0   # scc: no breakdown
          assert abs(treemap._effective_ccn(40.0, 40.0) - 40.0) < 1e-9  # single fn
          # A coordinator (aggregate >> worst fn) is pulled below its aggregate but
          # never below the worst function itself.
          eff = treemap._effective_ccn(107.0, 14.0)
          assert 14.0 < eff < 107.0
      
      
      def test_assess_dir_is_self_excluded_by_default(treemap):
          """A prior run's run-context.json must not be scored on the next run -
          the script's own output directory is in EXCLUDE_DIRS. Otherwise re-runs
          pollute the heatmap with their own past output (issue #50 bonus)."""
          assert ".assess" in treemap.EXCLUDE_DIRS
      
      
      def test_is_user_excluded_matches_dir_name(treemap):
          """A plain dir name in the user excludes filters every file under it,
          at any depth."""
          extra_dirs = {"regulatory-raw"}
          assert treemap._is_user_excluded(
              Path("regulatory-raw/2024-Q1/data.csv"), extra_dirs, []
          ) is True
          assert treemap._is_user_excluded(
              Path("src/data/sub/regulatory-raw/file.txt"), extra_dirs, []
          ) is True
          # A different directory must not be filtered.
          assert treemap._is_user_excluded(
              Path("src/data/file.txt"), extra_dirs, []
          ) is False
      
      
      def test_is_user_excluded_matches_glob_pattern(treemap):
          """A glob pattern matches by basename, not by full path."""
          extra_patterns = ["*.csv", "seed-*.json"]
          assert treemap._is_user_excluded(
              Path("data/reference.csv"), set(), extra_patterns
          ) is True
          assert treemap._is_user_excluded(
              Path("fixtures/seed-orders.json"), set(), extra_patterns
          ) is True
          # A glob that doesn't match the basename must not filter.
          assert treemap._is_user_excluded(
              Path("src/main.py"), set(), extra_patterns
          ) is False
          # No globs at all => no excludes.
          assert treemap._is_user_excluded(Path("anything.txt"), set(), []) is False
      
      
      def test_is_user_excluded_dir_and_pattern_combine(treemap):
          """Dir excludes and pattern excludes are independent - either match
          is enough to exclude. Mirrors how the built-in defaults already work."""
          extra_dirs = {"vetted-context"}
          extra_patterns = ["*.parquet"]
          # Dir hit
          assert treemap._is_user_excluded(
              Path("vetted-context/note.md"), extra_dirs, extra_patterns
          ) is True
          # Pattern hit
          assert treemap._is_user_excluded(
              Path("data/silver/events.parquet"), extra_dirs, extra_patterns
          ) is True
      
      
      def test_cli_exclude_classifies_glob_vs_dir(treemap, monkeypatch, tmp_path):
          """The CLI's `--exclude X` argument routes globby patterns to
          extra_patterns and plain strings to extra_dirs, transparently to the
          caller. Verified by capturing what collect() receives."""
          captured = {}
      
          def fake_collect(*args, **kwargs):
              captured["extra_dirs"] = kwargs.get("extra_exclude_dirs")
              captured["extra_patterns"] = kwargs.get("extra_exclude_patterns")
              # Return an empty result so main bails out early but cleanly.
              return [], "complexity", None, None, {}
      
          monkeypatch.setattr(treemap, "collect", fake_collect)
          monkeypatch.setattr(
              sys, "argv",
              ["complexity-treemap.py", str(tmp_path),
               "--exclude", "regulatory-raw",
               "--exclude", "*.csv",
               "--exclude", "seed-data",
               "--exclude", "data-*.json"],
          )
          rc = treemap.main()
          assert rc == 1  # "no scoreable files" - expected with empty collect()
          assert captured["extra_dirs"] == {"regulatory-raw", "seed-data"}
          assert sorted(captured["extra_patterns"]) == ["*.csv", "data-*.json"]
      
      
      def test_argparse_help_builds_on_current_python(treemap, monkeypatch, capsys):
          """Regression for the Python 3.14 crash: argparse now eagerly validates help
          strings and rejects a bare ``%`` (it must be escaped ``%%``). Building the
          parser via ``--help`` must raise SystemExit (help printed), never ValueError
          ('badly formed help string'). Runs under whatever Python the suite is on, so
          a 3.14 CI job catches a reintroduced bare ``%`` in any help text."""
          monkeypatch.setattr(sys, "argv", ["complexity-treemap.py", "--help"])
          with pytest.raises(SystemExit) as exc:
              treemap.main()
          assert exc.value.code == 0
          assert "--test-pressure" in capsys.readouterr().out
      
      
      def test_cli_exclude_merges_with_config_toml(treemap, monkeypatch, tmp_path):
          """`.assess/config.toml` and `--exclude` both layer onto the defaults;
          neither replaces the other. Config-supplied dirs join CLI dirs, and
          glob patterns merge across both sources."""
          (tmp_path / ".assess").mkdir()
          (tmp_path / ".assess" / "config.toml").write_text(
              'exclude_dirs = ["vetted-context", "regulatory-raw"]\n'
              'exclude_patterns = ["*.parquet"]\n',
              encoding="utf-8",
          )
          captured = {}
      
          def fake_collect(*args, **kwargs):
              captured["extra_dirs"] = kwargs.get("extra_exclude_dirs")
              captured["extra_patterns"] = kwargs.get("extra_exclude_patterns")
              return [], "complexity", None, None, {}
      
          monkeypatch.setattr(treemap, "collect", fake_collect)
          monkeypatch.setattr(
              sys, "argv",
              ["complexity-treemap.py", str(tmp_path),
               "--exclude", "seed-data",
               "--exclude", "*.csv"],
          )
          rc = treemap.main()
          assert rc == 1  # no scoreable files
          assert captured["extra_dirs"] == {
              "vetted-context", "regulatory-raw", "seed-data",
          }
          assert sorted(captured["extra_patterns"]) == ["*.csv", "*.parquet"]
      
      
      def test_config_loader_missing_file_is_empty(tmp_path):
          """A repo with no .assess/config.toml degrades silently - no warning,
          no error, just an empty config (the common case)."""
          from lib.assess_config import load_excludes
      
          dirs, pats = load_excludes(tmp_path)
          assert dirs == set()
          assert pats == []
      
      
      def test_config_loader_malformed_toml_returns_empty(tmp_path, capsys):
          """A broken TOML file must never block the assessment - the loader
          returns empty excludes and prints a one-line warning."""
          from lib.assess_config import load_excludes
      
          (tmp_path / ".assess").mkdir()
          (tmp_path / ".assess" / "config.toml").write_text(
              "this is not valid = = toml\n", encoding="utf-8",
          )
          dirs, pats = load_excludes(tmp_path)
          assert dirs == set()
          assert pats == []
          captured = capsys.readouterr()
          assert "could not read" in captured.err
      
      
      def test_config_loader_drops_non_string_entries(tmp_path):
          """A schema violation in one entry doesn't poison the rest - e.g.
          `exclude_dirs = ["regulatory-raw", 42]` keeps the string and drops
          the integer."""
          from lib.assess_config import load_excludes
      
          (tmp_path / ".assess").mkdir()
          (tmp_path / ".assess" / "config.toml").write_text(
              'exclude_dirs = ["regulatory-raw", 42, "vetted-context"]\n'
              'exclude_patterns = ["*.csv", true]\n',
              encoding="utf-8",
          )
          dirs, pats = load_excludes(tmp_path)
          assert dirs == {"regulatory-raw", "vetted-context"}
          assert pats == ["*.csv"]
      
      
      def test_config_loader_no_legacy_section_needed(tmp_path):
          """The schema is top-level - no `[treemap]` or `[exclude]` wrapper.
          The file is already namespaced by living under `.assess/config.toml`."""
          from lib.assess_config import load_excludes
      
          (tmp_path / ".assess").mkdir()
          (tmp_path / ".assess" / "config.toml").write_text(
              'exclude_dirs = ["regulatory-raw"]\n'
              'exclude_patterns = ["*.csv"]\n',
              encoding="utf-8",
          )
          dirs, pats = load_excludes(tmp_path)
          assert dirs == {"regulatory-raw"}
          assert pats == ["*.csv"]
      
      
      def test_config_loader_scalar_string_degrades_to_empty(tmp_path):
          """`exclude_dirs = "regulatory-raw"` (string, not list) used to iterate
          character-by-character, silently producing single-char "dir names"
          that match unexpectedly. The loader now rejects non-list values."""
          from lib.assess_config import load_excludes
      
          (tmp_path / ".assess").mkdir()
          (tmp_path / ".assess" / "config.toml").write_text(
              'exclude_dirs = "regulatory-raw"\n',
              encoding="utf-8",
          )
          dirs, pats = load_excludes(tmp_path)
          assert dirs == set()
          assert pats == []
      
      
      def test_config_loader_scalar_int_does_not_raise(tmp_path):
          """`exclude_dirs = 5` is valid TOML but the wrong type. It used to
          raise `TypeError` (int not iterable), propagate through `load_excludes`,
          and abort the whole assessment - the opposite of "degrade silently"."""
          from lib.assess_config import load_excludes
      
          (tmp_path / ".assess").mkdir()
          (tmp_path / ".assess" / "config.toml").write_text(
              'exclude_dirs = 5\nexclude_patterns = true\n',
              encoding="utf-8",
          )
          # The test passes if this call returns without raising.
          dirs, pats = load_excludes(tmp_path)
          assert dirs == set()
          assert pats == []
      
      
      # ── survivor-density overlay (task 5) ─────────────────────────────────────────
      
      
      @pytest.mark.parametrize("density,expected", [
          (None, ""),       # unknown -> no overlay
          (0.0, ""),
          (0.30, ""),       # boundary: must exceed, not equal
          (0.31, "diag"),
          (0.50, "diag"),   # boundary: cross only above 0.5
          (0.51, "cross"),
          (0.95, "cross"),
      ])
      def test_hatch_for_density_thresholds(treemap, density, expected):
          assert treemap._hatch_for_density(density) == expected
      
      
      def test_survivor_overrides_applies_hatch_per_file(treemap):
          p1, p2, p3 = Path("/repo/a.py"), Path("/repo/b.py"), Path("/repo/c.py")
          files = [(p1, 100, 5.0, "lizard"),
                   (p2, 50, 3.0, "lizard"),
                   (p3, 10, 1.0, "lizard")]
          density = {p1: 0.6, p2: 0.4, p3: 0.1}
          overrides = treemap._survivor_overrides(files, density)
          assert overrides[p1] == {"hatch": "cross"}
          assert overrides[p2] == {"hatch": "diag"}
          assert p3 not in overrides  # below threshold -> no overlay
      
      
      def test_survivor_overrides_empty_data_is_silent(treemap):
          """Absent or empty survivor data renders no overlay, no error."""
          files = [(Path("/repo/a.py"), 100, 5.0, "lizard")]
          assert treemap._survivor_overrides(files, None) == {}
          assert treemap._survivor_overrides(files, {}) == {}
      
      
      def test_write_svg_emits_hatch_overlay_and_legend(render_lib, tmp_path):
          """A hatched node gets a pattern-filled overlay, the <defs> patterns are
          emitted, and the legend explains what the hatch means."""
          node = render_lib.Node(name="a.py", rel_path="a.py", loc=100,
                                 metric=5.0, color=(0.8, 0.2, 0.1, 1.0),
                                 is_file=True, hatch="diag")
          rects = [(0.0, 0.0, 100.0, 100.0, node)]
          out = tmp_path / "hatched.svg"
          render_lib.write_svg(rects, Path("/repo"), 1600.0, 1000.0, out,
                               False, "ccn", show_survivor_legend=True)
          svg = out.read_text()
          # pattern definition + overlay reference
          assert 'id="survivor-diag"' in svg
          assert 'fill="url(#survivor-diag)"' in svg
          # legend explains the survivor meaning with both thresholds
          assert "survivor density" in svg.lower()
          assert "30%" in svg
          assert "50%" in svg
          # canvas extended by the legend band (1000 + 84)
          assert 'height="1084"' in svg
      
      
      def test_write_svg_emits_a11y_title_and_desc(render_lib, tmp_path):
          """Task 17: the root <svg> is role="img" with a <title>/<desc> pair as its
          first children, so a screen reader announces the image and how it encodes."""
          node = render_lib.Node(name="a.py", rel_path="a.py", loc=100,
                                 metric=5.0, color=(0.8, 0.2, 0.1, 1.0),
                                 is_file=True)
          rects = [(0.0, 0.0, 100.0, 100.0, node)]
          out = tmp_path / "a11y.svg"
          render_lib.write_svg(rects, Path("/repo"), 1600.0, 1000.0, out, False, "ccn")
          svg = out.read_text()
          assert 'role="img"' in svg
          assert "<title>Complexity Hotspot Heatmap</title>" in svg
          assert "hue indicates cyclomatic complexity" in svg
          assert "saturation indicates git churn" in svg
          # <title>/<desc> are the root's first children (before the <style> block).
          assert svg.index("<title>") < svg.index("<style>")
          assert svg.index("<desc>") < svg.index("<style>")
      
      
      def test_write_svg_no_overlay_without_survivor_data(render_lib, tmp_path):
          """No hatch and no legend flag -> original full-canvas treemap, untouched:
          no survivor patterns, no <defs>, no extra legend band."""
          node = render_lib.Node(name="a.py", rel_path="a.py", loc=100,
                                 metric=5.0, color=(0.8, 0.2, 0.1, 1.0),
                                 is_file=True)
          rects = [(0.0, 0.0, 100.0, 100.0, node)]
          out = tmp_path / "plain.svg"
          render_lib.write_svg(rects, Path("/repo"), 1600.0, 1000.0, out,
                               False, "ccn")
          svg = out.read_text()
          assert "survivor-" not in svg
          assert "<defs>" not in svg
          assert 'height="1000"' in svg
      
      
      def test_load_survivor_density_from_per_file(treemap, tmp_path):
          """Per-file density is survived/total; entries without a total (mutmut)
          are skipped, and paths resolve against the repo root."""
          ctx = {"test_pressure": {"per_file": [
              {"file": "src/a.py", "killed": 2, "survived": 8, "total": 10},
              {"file": "src/b.py", "killed": 9, "survived": 1, "total": 10},
              {"file": "src/c.py", "killed": None, "survived": 4, "total": None},
          ]}}
          j = tmp_path / "run-context.json"
          j.write_text(json.dumps(ctx), encoding="utf-8")
          density = treemap.load_survivor_density(j, tmp_path)
          assert density[(tmp_path / "src/a.py").resolve()] == 0.8
          assert density[(tmp_path / "src/b.py").resolve()] == 0.1
          assert (tmp_path / "src/c.py").resolve() not in density  # no total
      
      
      def test_mutmut_junitxml_drives_hatch_overlay(treemap, tmp_path, fixtures_dir):
          """End to end: a real mutmut junitxml parse -> run-context per_file ->
          load_survivor_density -> hatch overrides. Before the junitxml fix the mutmut
          path carried no totals, so this overlay could never render on a Python repo."""
          from lib.test_pressure import _parse_mutmut_junitxml
      
          per_file = _parse_mutmut_junitxml(fixtures_dir / "mutmut-junitxml.xml")
          ctx = {"test_pressure": {"per_file": per_file}}
          j = tmp_path / "run-context.json"
          j.write_text(json.dumps(ctx), encoding="utf-8")
      
          density = treemap.load_survivor_density(j, tmp_path)
          calc = (tmp_path / "src/calc.py").resolve()
          util = (tmp_path / "src/util.py").resolve()
          assert density[calc] == 2 / 3      # real density, not skipped
          assert density[util] == 0.0         # all killed -> real 0 density (has a total)
      
          files = [(calc, 100, 5.0, "lizard"), (util, 50, 3.0, "lizard")]
          overrides = treemap._survivor_overrides(files, density)
          assert overrides[calc] == {"hatch": "cross"}  # 0.67 > 0.5
          assert util not in overrides                   # 0.0 below hatch threshold
      
      
      def test_load_survivor_density_absent_block_is_empty(treemap, tmp_path):
          j = tmp_path / "run-context.json"
          j.write_text(json.dumps({"doc_graph": {}}), encoding="utf-8")
          assert treemap.load_survivor_density(j, tmp_path) == {}
      
      
      def test_load_survivor_density_missing_file_is_empty(treemap, tmp_path):
          assert treemap.load_survivor_density(tmp_path / "nope.json", tmp_path) == {}
      
      
      # --- Estimated tokens as the keyhole size unit (PRD 2026-06) -----------------
      
      def test_est_token_count_is_chars_over_four(treemap, tmp_path):
          """est_tokens = ceil(len(text)/4) for a real on-disk file."""
          f = tmp_path / "a.py"
          f.write_text("x" * 800, encoding="utf-8")  # 800 chars -> 200 tokens
          assert treemap.est_token_count(f, 0) == 200
      
      
      def test_est_token_count_falls_back_to_loc_when_unreadable(treemap, tmp_path):
          """An unreadable/absent file estimates from loc - a conservative floor that
          can't inflate a benign file's rank."""
          missing = tmp_path / "gone.py"
          assert treemap.est_token_count(missing, 137) == 137
      
      
      def test_write_stats_emits_est_tokens_per_row_and_uses_real_text(treemap, tmp_path):
          """Each row carries est_tokens; for on-disk files it is the char-based
          estimate, not the loc (a dense file reads higher than its line count)."""
          root = tmp_path
          dense = root / "dense.py"
          dense.write_text("y" * 4000, encoding="utf-8")  # 1000 est tokens
          out = root / "stats.json"
          treemap.write_stats([(dense, 50, 5.0, "lizard")], None, None, root, out)
          stats = json.loads(out.read_text())
          row = stats["top_hotspots"][0]
          assert row["est_tokens"] == 1000          # chars/4, not the 50 loc
          assert row["loc"] == 50                    # loc preserved alongside
          assert stats["est_tokens"]["total"] == 1000
      
      
      def test_hotspot_score_includes_token_factor(treemap, tmp_path):
          """With ccn and churn held equal, the larger file (more estimated tokens)
          ranks first - the size axis is live in the composite."""
          root = tmp_path
          big = root / "big.go"
          small = root / "small.go"
          files = [(big, 200, 30.0, "lizard"), (small, 200, 30.0, "lizard")]
          aux_data = {big: 10, small: 10}
          tokens = {big: 40000, small: 4000}  # same ccn + churn, 10x size
          out = root / "stats.json"
          treemap.write_stats(files, aux_data, "commits (last 12mo)", root, out,
                              tokens_by_path=tokens)
          hotspots = json.loads(out.read_text())["top_hotspots"]
          assert hotspots[0]["path"] == "big.go"
      
      
      def test_big_but_simple_stable_file_does_not_top_on_size_alone(treemap, tmp_path):
          """PRD validation guard: a large-but-simple-stable file (a long config or
          data table - low ccn, no churn, huge size) must NOT top the hotspot list.
          The sqrt bounding keeps its single big axis from dominating a genuine
          complex+churning hotspot."""
          root = tmp_path
          data_table = root / "data.json"      # huge, trivial, frozen
          hotspot = root / "engine.go"         # complex AND churning, modest size
          files = [(data_table, 5000, 1.0, "lizard"), (hotspot, 300, 100.0, "lizard")]
          aux_data = {data_table: 0, hotspot: 30}
          tokens = {data_table: 200000, hotspot: 5000}  # data table 40x bigger
          out = root / "stats.json"
          treemap.write_stats(files, aux_data, "commits (last 12mo)", root, out,
                              tokens_by_path=tokens)
          hotspots = json.loads(out.read_text())["top_hotspots"]
          assert hotspots[0]["path"] == "engine.go"  # complexity+churn beats raw size
      
      
      def test_keyhole_budget_rollup_counts_files_and_subtrees(treemap, tmp_path):
          """The est_tokens.budget block reports the repo total plus how many files
          and top-level subtrees exceed one context-window keyhole."""
          root = tmp_path
          over_file = root / "giant.py"           # single file over budget
          a1 = root / "pkg_a" / "x.py"            # pkg_a subtree over budget in sum
          a2 = root / "pkg_a" / "y.py"
          small = root / "tiny.py"
          files = [
              (over_file, 10, 1.0, "lizard"),
              (a1, 10, 1.0, "lizard"),
              (a2, 10, 1.0, "lizard"),
              (small, 10, 1.0, "lizard"),
          ]
          tokens = {over_file: 250000, a1: 150000, a2: 120000, small: 100}
          out = root / "stats.json"
          treemap.write_stats(files, None, None, root, out, tokens_by_path=tokens)
          budget = json.loads(out.read_text())["est_tokens"]["budget"]
          assert budget["budget"] == treemap.CONTEXT_WINDOW_BUDGET_TOKENS
          assert budget["total"] == 250000 + 150000 + 120000 + 100
          assert budget["files_over_budget"] == 1           # only giant.py alone
          # pkg_a (270k) and giant.py-as-its-own-subtree (250k) both exceed 200k.
          assert budget["subtrees_over_budget"] == 2
          over_names = {s["path"] for s in budget["over_budget_subtrees"]}
          assert "pkg_a" in over_names and "giant.py" in over_names
      
      
      def test_est_tokens_are_post_artifact_filter(treemap, tmp_path):
          """est_tokens are computed only over the files passed in (already filtered
          by collect), so a filtered bundle never inflates the totals or budget."""
          root = tmp_path
          real = root / "real.py"
          real.write_text("z" * 400, encoding="utf-8")  # 100 tokens
          out = root / "stats.json"
          # The bundle is simply absent from `files` (collect dropped it) - the
          # totals reflect only the surviving file.
          treemap.write_stats([(real, 20, 5.0, "lizard")], None, None, root, out)
          stats = json.loads(out.read_text())
          assert stats["est_tokens"]["total"] == 100
      
      
      def test_build_tree_size_by_decouples_area_from_loc(render_lib):
          """size_by overrides the block area (estimated tokens) while each leaf keeps
          its real loc and records est_tokens for the tooltip."""
          root = Path("/repo")
          a = root / "a.py"
          files_colored = [(a, 500, 5.0, "lizard", (0.8, 0.2, 0.1, 1.0))]
          tree = render_lib.build_tree(files_colored, root, size_by={a: 1800})
          leaf = tree.children[0]
          assert leaf.size == 1800        # layout area = estimated tokens
          assert leaf.loc == 500          # real loc preserved
          assert leaf.est_tokens == 1800
      
      
      def test_build_tree_without_size_by_is_unchanged(render_lib):
          """No size_by (the docs-heatmap path) keeps area == loc, est_tokens 0."""
          root = Path("/repo")
          a = root / "a.py"
          files_colored = [(a, 500, 5.0, "lizard", (0.8, 0.2, 0.1, 1.0))]
          leaf = render_lib.build_tree(files_colored, root).children[0]
          assert leaf.size == 500 and leaf.loc == 500 and leaf.est_tokens == 0
      
      
      def test_write_svg_tooltip_shows_est_tokens_and_loc(render_lib, tmp_path):
          """A code-heatmap node (est_tokens set) leads its tooltip with estimated
          tokens and keeps LOC alongside."""
          node = render_lib.Node(name="a.py", rel_path="a.py", loc=514,
                                 est_tokens=8200, metric=12.0,
                                 color=(0.8, 0.2, 0.1, 1.0), is_file=True)
          rects = [(0.0, 0.0, 100.0, 100.0, node)]
          out = tmp_path / "tok.svg"
          render_lib.write_svg(rects, Path("/repo"), 1600.0, 1000.0, out, False, "ccn")
          svg = out.read_text()
          assert "8,200 est. tokens" in svg
          assert "514 loc" in svg
      
      
      def test_write_stats_stamps_run_id_and_schema_version(treemap, tmp_path):
          """The complexity-stats sidecar carries an artifact_schema_version and a
          unique run_id (assess-obey-thyself), so each stats emission is traceable.
          Distinct from the stats-layout `schema_version` (an int, versions the diff
          comparability)."""
          root = tmp_path
          f = root / "a.go"
          out = root / "stats.json"
          treemap.write_stats([(f, 100, 5.0, "lizard")], None, None, root, out)
          stats = json.loads(out.read_text())
          assert stats["artifact_schema_version"] == treemap.ARTIFACT_SCHEMA_VERSION == "1.1.0"
          # The stats-layout schema_version (from #244) still coexists as an int.
          assert stats["schema_version"] == treemap.STATS_SCHEMA_VERSION
          run_id = stats["run_id"]
          stamp, _, suffix = run_id.partition("-")
          assert len(stamp) == 14 and stamp.isdigit()
          assert len(suffix) == 8
      
          out2 = root / "stats2.json"
          treemap.write_stats([(f, 100, 5.0, "lizard")], None, None, root, out2)
          assert json.loads(out2.read_text())["run_id"] != run_id
      
      
      # --- generated-file exclusion (header sniff, long lines, filename globs) -----
      
      @pytest.mark.parametrize("name", [
          "types.generated.ts", "schema.generated.sql", "client.gen.ts",
          "database.types.ts",
      ])
      def test_generated_header_free_filename_globs_are_filtered(treemap, name):
          assert treemap._is_build_artifact(Path("src") / name) is True
      
      
      def _fake_scorers(treemap, monkeypatch, paths):
          monkeypatch.setattr(
              treemap, "lizard_scores",
              lambda root, **kw: {p: (10, 3.0, [3.0]) for p in paths},
          )
          monkeypatch.setattr(treemap, "scc_scores", lambda root, **kw: {})
      
      
      def test_collect_drops_generated_header_and_long_line_files(
              treemap, tmp_path, monkeypatch):
          root = tmp_path
          (root / "db").mkdir()
          (root / "src").mkdir()
          schema = root / "db" / "schema.sql"
          schema.write_text("-- GENERATED FILE - DO NOT EDIT\nCREATE TABLE t (id int);\n")
          font = root / "src" / "font.ts"
          font.write_text('export const F = "' + "A" * 40000 + '";\n')
          hand = root / "src" / "hand.py"
          hand.write_text("def f():\n    return 1\n" + "# x\n" * 196
                          + "# do not edit the table above\n")
          _fake_scorers(treemap, monkeypatch, [schema, font, hand])
      
          excluded: list[dict] = []
          files, *_rest, fn_ccn = treemap.collect(
              root, by="complexity", excluded_generated=excluded)
          assert [f[0] for f in files] == [hand]
          assert set(fn_ccn) == {hand}
          assert excluded == [
              {"path": "db/schema.sql", "reason": "generated-header"},
              {"path": "src/font.ts", "reason": "long-lines"},
          ]
      
      
      def test_collect_include_artifacts_keeps_generated_header_files(
              treemap, tmp_path, monkeypatch):
          schema = tmp_path / "schema.sql"
          schema.write_text("-- @generated\nCREATE TABLE t (id int);\n")
          _fake_scorers(treemap, monkeypatch, [schema])
          excluded: list[dict] = []
          files, *_ = treemap.collect(tmp_path, by="complexity",
                                      include_artifacts=True,
                                      excluded_generated=excluded)
          assert [f[0] for f in files] == [schema]
          assert excluded == []
      
      
      def test_write_stats_carries_generated_header_exclusions(treemap, tmp_path):
          root = tmp_path
          f = root / "a.py"
          f.write_text("x = 1\n")
          out = root / "stats.json"
          listed = [{"path": "db/schema.sql", "reason": "generated-header"}]
          treemap.write_stats([(f, 1, 1.0, "lizard")], None, None, root, out,
                              excluded_generated=listed)
          stats = json.loads(out.read_text())
          assert stats["excluded_generated"] == listed
          assert isinstance(stats["schema_version"], int) and stats["schema_version"] > 1
          treemap.write_stats([(f, 1, 1.0, "lizard")], None, None, root, out)
          assert json.loads(out.read_text())["excluded_generated"] == []
      
      
      def test_generated_header_all_excluded_error_names_the_exclusion(
              treemap, tmp_path, monkeypatch, capsys):
          schema = tmp_path / "schema.sql"
          schema.write_text("-- GENERATED FILE - DO NOT EDIT\nCREATE TABLE t (id int);\n")
          _fake_scorers(treemap, monkeypatch, [schema])
          monkeypatch.setattr(sys, "argv", ["complexity-treemap.py", str(tmp_path)])
          assert treemap.main() == 1
          err = capsys.readouterr().err
          assert "no scoreable files found" in err
          assert "1 excluded as generated" in err
          assert "--include-artifacts" in err
      
      
      def test_write_stats_paths_match_generated_header_list_separator(treemap, tmp_path):
          """Row paths use forward slashes on every host, the same form as
          excluded_generated, so assess_core can intersect the two sets on Windows
          (where str() of a relative path would use backslashes)."""
          (tmp_path / "db").mkdir()
          f = tmp_path / "db" / "a.py"
          f.write_text("x = 1\n")
          out = tmp_path / "stats.json"
          treemap.write_stats([(f, 1, 1.0, "lizard")], None, None, tmp_path, out)
          paths = [r["path"] for r in json.loads(out.read_text())["top_large"]]
          assert paths == ["db/a.py"]
          src = (Path(treemap.__file__)).read_text()
          rel_body = src[src.index("    def rel(p: Path) -> str:"):][:400]
          assert "as_posix()" in rel_body and "str(p" not in rel_body
      
      
      # --- generated test reports, code/data maxima, scc-only hint -----------------
      
      @pytest.mark.parametrize("rel", [
          "web/tests/html-report/index.html",
          "e2e/playwright-report/index.html",
          "web/accessibility/lighthouse-report.html",
          "web/accessibility/lighthouse-results.json",
          "security/zap-report.html",
          "security/zap-report.json",
          "security/zap_report.html",
          "mcp/test/fixtures/big/lines.jsonl",
          "a/fixtures/lines.jsonl",
      ])
      def test_report_default_excludes_drop_generated_reports(treemap, rel):
          path = Path(rel)
          in_dir = any(part in treemap.EXCLUDE_DIRS for part in path.parts)
          assert in_dir or treemap._is_build_artifact(path)
      
      
      @pytest.mark.parametrize("rel", [
          "data/events.jsonl",           # .jsonl outside any fixtures/ directory
          "fixtures/lines.jsonl",        # bare top-level fixtures/ stays scored
          "fixtures/taxonomy/concepts.json",
          "mcp/test/fixtures/big/case.json",  # only .jsonl leaves nested fixtures/
          "src/report.html",
          "security/zap_report.py",  # the script that runs ZAP, not its output
      ])
      def test_report_default_excludes_keep_hand_kept_files(treemap, rel):
          path = Path(rel)
          assert not any(part in treemap.EXCLUDE_DIRS for part in path.parts)
          assert treemap._is_build_artifact(path) is False
      
      
      def test_report_default_excludes_bypassed_by_include_artifacts(
              treemap, tmp_path, monkeypatch):
          """The nested-fixture .jsonl rule is a filename default like the globs, so
          --include-artifacts scores it."""
          import subprocess as sp
      
          nested = tmp_path / "mcp" / "fixtures" / "lines.jsonl"
          nested.parent.mkdir(parents=True)
          nested.write_text('{"a": 1}\n')
          payload = json.dumps([{"Name": "JSONL", "Files": [
              {"Location": str(nested), "Code": 1, "Complexity": 0}]}])
          monkeypatch.setattr(treemap.shutil, "which", lambda _: "/usr/bin/scc")
          monkeypatch.setattr(treemap.subprocess, "run", lambda *a, **k:
                              sp.CompletedProcess(a, 0, stdout=payload))
          assert treemap.scc_scores(tmp_path) == {}
          langs: dict = {}
          assert treemap.scc_scores(tmp_path, include_artifacts=True,
                                    languages=langs) == {nested.resolve(): (1, 0.0)}
          assert langs == {nested.resolve(): "JSONL"}
      
      
      def test_code_data_maxima_split_by_scc_language(treemap, tmp_path):
          root = tmp_path
          code = root / "app.py"
          code.write_text("x = 1\n" * 30)
          data = root / "big.json"
          data.write_text('{"k": 1}\n' * 500)
          conf = root / "settings.yaml"
          conf.write_text("k: 1\n" * 100)
          out = root / "stats.json"
          langs = {data: "JSON", conf: "YAML"}
          treemap.write_stats(
              [(code, 30, 4.0, "lizard"), (data, 500, 0.0, "scc"),
               (conf, 100, 0.0, "scc")],
              None, None, root, out, languages_by_path=langs)
          stats = json.loads(out.read_text())
          rows = {r["path"]: r for r in stats["top_large"]}
          assert stats["loc"]["max"] == 500
          assert stats["loc"]["max_code"] == rows["app.py"]["loc"] == 30
          assert stats["loc"]["max_data"] == rows["big.json"]["loc"] == 500
          assert stats["est_tokens"]["max_code"] == rows["app.py"]["est_tokens"]
          assert stats["est_tokens"]["max_data"] == rows["big.json"]["est_tokens"]
          assert stats["schema_version"] >= 3
      
      
      def test_code_data_maxima_empty_side_reports_zero(treemap, tmp_path):
          f = tmp_path / "a.py"
          f.write_text("x = 1\n")
          out = tmp_path / "stats.json"
          treemap.write_stats([(f, 1, 1.0, "lizard")], None, None, tmp_path, out)
          stats = json.loads(out.read_text())
          assert stats["loc"]["max_code"] == 1
          assert stats["loc"]["max_data"] == 0
          assert stats["est_tokens"]["max_data"] == 0
      
      
      def _scc_row(tmp_path, name, loc, ccn=0.0, src="scc"):
          p = tmp_path / name
          return (p, loc, ccn, src)
      
      
      _LANG_BY_SUFFIX = {".json": "JSON", ".py": "Python", ".ex": "Elixir",
                         ".md": "Markdown"}
      
      
      def _langs(files):
          return {f[0]: _LANG_BY_SUFFIX[f[0].suffix] for f in files}
      
      
      def test_scc_only_hint_fires_when_largest_files_are_scc_ccn_zero(
              treemap, tmp_path, capsys):
          files = [_scc_row(tmp_path, f"d{i}.json", 200) for i in range(12)]
          files.append(_scc_row(tmp_path, "tiny.py", 2, 1.0, "lizard"))
          tokens = {f[0]: f[1] * 10 for f in files}
          treemap._hint_if_largest_files_scc_only(files, tokens, _langs(files))
          err = capsys.readouterr().err
          assert ".assess/config.toml" in err
          assert "d0.json" in err
      
      
      def test_scc_only_hint_silent_when_largest_file_is_code(
              treemap, tmp_path, capsys):
          files = [_scc_row(tmp_path, f"d{i}.json", 200) for i in range(12)]
          files.append(_scc_row(tmp_path, "big.py", 1200, 600.0, "lizard"))
          tokens = {f[0]: f[1] * 10 for f in files}
          treemap._hint_if_largest_files_scc_only(files, tokens, _langs(files))
          assert capsys.readouterr().err == ""
      
      
      def test_scc_only_hint_silent_on_scored_scc_code(treemap, tmp_path, capsys):
          """An scc-scored file with complexity above 0 is code (an Elixir or Dart
          module), so it keeps the hint quiet."""
          files = [_scc_row(tmp_path, f"m{i}.ex", 200, 3.0) for i in range(12)]
          tokens = {f[0]: f[1] * 10 for f in files}
          treemap._hint_if_largest_files_scc_only(files, tokens, _langs(files))
          assert capsys.readouterr().err == ""
      
      
      def test_scc_only_hint_silent_on_markdown(treemap, tmp_path, capsys):
          """scc gives Markdown complexity 0 too, but on a docs-first repo those
          blocks are the deliverable, not data to exclude."""
          files = [_scc_row(tmp_path, f"s{i}.md", 200) for i in range(12)]
          tokens = {f[0]: f[1] * 10 for f in files}
          treemap._hint_if_largest_files_scc_only(files, tokens, _langs(files))
          assert capsys.readouterr().err == ""
      
      
      def test_scc_only_hint_silent_below_top_n(treemap, tmp_path, capsys):
          files = [_scc_row(tmp_path, f"d{i}.json", 200)
                   for i in range(treemap.SCC_ONLY_HINT_TOP_N - 1)]
          tokens = {f[0]: f[1] * 10 for f in files}
          treemap._hint_if_largest_files_scc_only(files, tokens, _langs(files))
          assert capsys.readouterr().err == ""
      
      
      @pytest.mark.parametrize("include_artifacts", [False, True])
      def test_scc_only_hint_skipped_under_include_artifacts(
              treemap, tmp_path, monkeypatch, capsys, include_artifacts):
          paths = []
          for i in range(treemap.SCC_ONLY_HINT_TOP_N):
              p = tmp_path / f"d{i}.json"
              p.write_text('{"k": 1}\n' * 50)
              paths.append(p)
      
          def fake_collect(root, **kw):
              kw["scc_languages"].update({p: "JSON" for p in paths})
              return [(p, 50, 0.0, "scc") for p in paths], "hotspot", None, None, {}
      
          monkeypatch.setattr(treemap, "collect", fake_collect)
          monkeypatch.setattr(treemap, "render", lambda *a, **k: None)
          argv = ["complexity-treemap.py", str(tmp_path), "-o",
                  str(tmp_path / "out.svg")]
          if include_artifacts:
              argv.append("--include-artifacts")
          monkeypatch.setattr(sys, "argv", argv)
          assert treemap.main() == 0
          hinted = ".assess/config.toml" in capsys.readouterr().err
          assert hinted is not include_artifacts
      
      
      # --------------------------------------------------------------------------
      # Per-function backend per language and the worst function's name (issue #363)
      
      
      def test_write_stats_backend_by_language_maps_lizard_and_null(treemap, tmp_path):
          """fn_ccn.source lists the backends that scored a file, as objects, and
          backend_by_language maps each programming language to its backend or to
          null when only scc scored it at file level. Data and markup languages,
          where scc counts no decision points, carry no key."""
          root = tmp_path
          py = root / "src" / "app.py"
          ex = root / "lib" / "router.ex"
          js = root / "data.json"
          md = root / "README.md"
          out = root / "stats.json"
          treemap.write_stats(
              [(py, 20, 8.0, "lizard"), (ex, 9, 2.0, "scc"),
               (js, 50, 0.0, "scc"), (md, 30, 0.0, "scc")],
              None, None, root, out,
              fn_ccn_by_path={py: [1.0, 7.0]},
              fn_name_by_path={py: "gnarly"},
              languages_by_path={py: "Python", ex: "Elixir",
                                 js: "JSON", md: "Markdown"},
          )
          fn = json.loads(out.read_text())["fn_ccn"]
          assert fn["source"] == [{"name": "lizard", "approximate": False}]
          assert fn["backend_by_language"] == {"Python": "lizard", "Elixir": None}
      
      
      def test_write_stats_backend_by_language_partial_coverage_is_null(
              treemap, tmp_path):
          """A language a backend covers only in part maps to null: one lizard file
          must not make the language's scc-only files read as covered. A file with
          no decision points loses no per-function figure and does not downgrade."""
          root = tmp_path
          cpp, ipp = root / "a.cpp", root / "a.ipp"
          js, mjs = root / "a.js", root / "b.mjs"
          out = root / "stats.json"
          treemap.write_stats(
              [(cpp, 20, 8.0, "lizard"), (ipp, 30, 4.0, "scc"),
               (js, 20, 3.0, "lizard"), (mjs, 5, 0.0, "scc")],
              None, None, root, out,
              fn_ccn_by_path={cpp: [8.0], js: [3.0]},
              languages_by_path={cpp: "C++", ipp: "C++",
                                 js: "JavaScript", mjs: "JavaScript"},
          )
          fn = json.loads(out.read_text())["fn_ccn"]
          assert fn["backend_by_language"] == {"C++": None, "JavaScript": "lizard"}
      
      
      def test_write_stats_backend_by_language_empty_source_when_no_backend(
              treemap, tmp_path):
          """An scc-only run lists no backend rather than claiming lizard."""
          root = tmp_path
          ex = root / "router.ex"
          out = root / "stats.json"
          treemap.write_stats([(ex, 9, 2.0, "scc")], None, None, root, out,
                              languages_by_path={ex: "Elixir"})
          fn = json.loads(out.read_text())["fn_ccn"]
          assert fn["source"] == []
          assert fn["backend_by_language"] == {"Elixir": None}
      
      
      def test_write_stats_max_fn_name_names_worst_function(treemap, tmp_path):
          """Every ranked row carries max_fn_name beside max_fn_ccn; it is null
          wherever max_fn_ccn is null (an scc-scored file, or a lizard file with no
          functions)."""
          root = tmp_path
          py = root / "app.py"
          flat = root / "flat.py"
          ex = root / "router.ex"
          out = root / "stats.json"
          treemap.write_stats(
              [(py, 20, 8.0, "lizard"), (flat, 5, 1.0, "lizard"),
               (ex, 9, 2.0, "scc")],
              None, None, root, out,
              fn_ccn_by_path={py: [1.0, 7.0], flat: []},
              fn_name_by_path={py: "gnarly"},
          )
          stats = json.loads(out.read_text())
          for key in ("top_hotspots", "top_complex", "top_large"):
              rows = {r["path"]: r for r in stats[key]}
              assert rows["app.py"]["max_fn_ccn"] == 7.0
              assert rows["app.py"]["max_fn_name"] == "gnarly"
              assert rows["flat.py"]["max_fn_ccn"] is None
              assert rows["flat.py"]["max_fn_name"] is None
              assert rows["router.ex"]["max_fn_name"] is None
      
      
      def test_lizard_scores_fills_max_fn_name_with_worst_function(
              treemap, tmp_path, monkeypatch):
          """lizard_scores records the name of each file's highest-ccn function in
          the optional fn_names map, and collect threads it through."""
          src = tmp_path / "app.py"
          src.write_text("def simple(a):\n    return a\n")
      
          def fn(name, ccn):
              return types.SimpleNamespace(name=name, cyclomatic_complexity=ccn)
      
          fake = types.SimpleNamespace(
              filename=str(src), nloc=10,
              function_list=[fn("simple", 1), fn("gnarly", 7), fn("tie", 7)],
          )
          monkeypatch.setattr(treemap.lizard, "analyze",
                              lambda **kw: [fake], raising=False)
          monkeypatch.setattr(treemap, "scc_scores", lambda root, **kw: {})
          names: dict = {}
          scores = treemap.lizard_scores(tmp_path, fn_names=names)
          assert scores[src.resolve()][2] == [1.0, 7.0, 7.0]
          assert names == {src.resolve(): "gnarly"}
      
          via_collect: dict = {}
          treemap.collect(tmp_path, by="complexity", fn_names=via_collect)
          assert via_collect == {src.resolve(): "gnarly"}
      
      
      def test_stats_schema_version_raised_for_backend_by_language(treemap):
          assert treemap.STATS_SCHEMA_VERSION >= 4
      
      
      # Approximate per-function backend for Dart (issue #364)
      
      
      def test_collect_dart_scanner_scores_scc_dart_files(
              treemap, tmp_path, monkeypatch):
          """collect adds the Dart scanner's per-function figures to scc-scored
          .dart files, names the worst function and records the backend; other
          scc files stay without a breakdown."""
          dart = tmp_path / "lib" / "order.dart"
          dart.parent.mkdir()
          dart.write_text("int a(x) { if (x) {} return 1; }\nint b() => 2;\n")
          ex = tmp_path / "router.ex"
          ex.write_text("defmodule R do\nend\n")
          monkeypatch.setattr(treemap, "lizard_scores", lambda root, **kw: {})
          monkeypatch.setattr(
              treemap, "scc_scores",
              lambda root, **kw: {dart.resolve(): (2, 3.0), ex.resolve(): (2, 1.0)})
          names: dict = {}
          backends: dict = {}
          *_, fn_ccn = treemap.collect(tmp_path, by="complexity",
                                       fn_names=names, fn_backends=backends)
          assert fn_ccn == {dart.resolve(): [2.0, 1.0]}
          assert names == {dart.resolve(): "a"}
          assert backends == {dart.resolve(): "dart-scanner"}
      
      
      def test_write_stats_dart_scanner_marked_approximate(treemap, tmp_path):
          """The Dart scanner appears in fn_ccn.source with approximate true, maps
          Dart in backend_by_language, and fills the Dart row's max_fn_ccn and
          max_fn_name."""
          root = tmp_path
          py = root / "app.py"
          dart = root / "order.dart"
          out = root / "stats.json"
          treemap.write_stats(
              [(py, 20, 8.0, "lizard"), (dart, 40, 30.0, "scc")],
              None, None, root, out,
              fn_ccn_by_path={py: [7.0], dart: [1.0, 12.0]},
              fn_name_by_path={py: "gnarly", dart: "routeOrder"},
              languages_by_path={py: "Python", dart: "Dart"},
              fn_backend_by_path={dart: "dart-scanner"},
          )
          stats = json.loads(out.read_text())
          fn = stats["fn_ccn"]
          assert fn["source"] == [{"name": "dart-scanner", "approximate": True},
                                  {"name": "lizard", "approximate": False}]
          assert fn["backend_by_language"] == {"Dart": "dart-scanner",
                                               "Python": "lizard"}
          row = {r["path"]: r for r in stats["top_hotspots"]}["order.dart"]
          assert (row["max_fn_ccn"], row["max_fn_name"]) == (12.0, "routeOrder")
      
      
      def test_write_stats_version_keys_are_tool_versions_or_listed_non_tools(
              treemap, tmp_path, monkeypatch):
          """Every `*_version` key write_stats emits is either a tool version that
          assess_core._stats_tool_versions reads or a stamp listed in
          _NON_TOOL_VERSION_KEYS, so the writer and the reader cannot drift."""
          import assess_core
      
          monkeypatch.setattr(treemap, "_scc_version", lambda: "3.7.0")
          root = tmp_path
          py, dart = root / "app.py", root / "order.dart"
          out = root / "stats.json"
          treemap.write_stats(
              [(py, 20, 8.0, "lizard"), (dart, 40, 30.0, "scc")],
              None, None, root, out,
              fn_ccn_by_path={py: [7.0], dart: [12.0]},
              fn_backend_by_path={dart: "dart-scanner"},
          )
          stats = json.loads(out.read_text())
          version_keys = {k for k in stats if k.endswith("_version")}
          tools = {f"{t}_version" for t in assess_core._stats_tool_versions(stats)}
          assert tools == {"lizard_version", "scc_version"}
          assert version_keys - tools == set(assess_core._NON_TOOL_VERSION_KEYS)
      
      
      def test_collect_dart_scanner_skips_files_with_no_function(
              treemap, tmp_path, monkeypatch):
          """A Dart file the scanner finds no function in stays scc-only, so it
          records no backend and cannot make Dart read as covered."""
          dart = tmp_path / "consts.dart"
          dart.write_text("const a = 1;\n")
          monkeypatch.setattr(treemap, "lizard_scores", lambda root, **kw: {})
          monkeypatch.setattr(treemap, "scc_scores",
                              lambda root, **kw: {dart.resolve(): (1, 1.0)})
          backends: dict = {}
          *_, fn_ccn = treemap.collect(tmp_path, by="complexity",
                                       fn_backends=backends)
          assert fn_ccn == {} and backends == {}
      
      
      def test_effective_ccn_clamps_dart_scanner_max_to_scc_aggregate(treemap):
          """A Dart row takes ccn from scc and max_fn_ccn from the scanner; a
          scanner figure above the aggregate must not lift the effective value
          past it. Lizard rows, where max <= aggregate, are unchanged."""
          assert treemap._effective_ccn(1.0, 5.0) == 1.0
          assert treemap._effective_ccn(0.0, 3.0) == 0.0
          w = treemap.PER_FUNCTION_WEIGHT
          expected = 10.0 ** w * 100.0 ** (1 - w)
          assert abs(treemap._effective_ccn(100.0, 10.0) - expected) < 1e-9
      
      
      def test_stats_schema_version_raised_for_dart_scanner(treemap):
          assert treemap.STATS_SCHEMA_VERSION >= 5
      
    • test_config_drift.py 21.2 KB
      """Tests for lib/config_drift.py and the shared gh helper lib/gh_cli.py.
      
      GitHub is faked with a `gh` shell script placed first on PATH: it logs every
      call and answers from JSON files in a directory, so the tests exercise the real
      subprocess path, the remote-then-auth-then-calls order and the 403 mapping.
      """
      from __future__ import annotations
      
      import json
      import os
      import stat
      import subprocess
      import sys
      from pathlib import Path
      
      import pytest
      
      sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
      
      from lib.config_drift import diff_values, find_snapshots, scan_config_drift  # noqa: E402
      from lib.gh_cli import (  # noqa: E402
          GhUnavailable,
          gh_json,
          open_github,
          parse_github_remote,
      )
      
      FAKE_GH = """#!/bin/sh
      echo "$*" >> "$FAKE_GH/log"
      serve() { [ -f "$FAKE_GH/$1" ] && cat "$FAKE_GH/$1" && exit 0; }
      case "$1:$*" in
        auth:*) [ -f "$FAKE_GH/noauth" ] || exit 0 ;;
        pr:*) serve prs.json ;;
        api:*rulesets/[0-9]*) serve ruleset.json ;;
        api:*rulesets*) serve rulesets.json ;;
        api:*/protection*) serve protection.json ;;
      esac
      cat "$FAKE_GH/fail" >&2
      exit 1
      """
      
      
      def _ruleset(strict: bool, **extra) -> dict:
          return {
              "id": 7, "name": "main", "target": "branch", "enforcement": "active",
              "updated_at": extra.pop("updated_at", "2026-01-01T00:00:00Z"),
              "rules": [
                  {"type": "deletion"},
                  {"type": "pull_request", "parameters": {"required_approving_review_count": 1}},
                  {"type": "required_status_checks", "parameters": {
                      "strict_required_status_checks_policy": strict,
                      "required_status_checks": [{"context": "ci"}],
                  }},
              ],
              **extra,
          }
      
      
      def _protection(strict: bool) -> dict:
          return {
              "url": "https://api.github.com/repos/acme/widget/branches/main/protection",
              "required_status_checks": {"strict": strict, "contexts": ["ci"]},
              "enforce_admins": {"url": "https://x/enforce_admins", "enabled": True},
              "required_pull_request_reviews": {"required_approving_review_count": 1},
          }
      
      
      def _git(repo: Path, *args: str) -> None:
          env = {**os.environ, "GIT_CONFIG_GLOBAL": "/dev/null"}
          subprocess.run(
              ["git", "-C", str(repo), "-c", "user.email=t@example.com", "-c", "user.name=t", *args],
              check=True, capture_output=True, env=env,
          )
      
      
      @pytest.fixture
      def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
          """A git repo with a github.com origin and a fake gh first on PATH."""
          repo = tmp_path / "repo"
          (repo / "src").mkdir(parents=True)
          (repo / "src" / "a.py").write_text("x = 1\n")
          _git(repo, "init", "-q", "-b", "main")
          _git(repo, "remote", "add", "origin", "https://github.com/acme/widget.git")
      
          bindir, ghdir = tmp_path / "bin", tmp_path / "gh"
          bindir.mkdir()
          ghdir.mkdir()
          gh = bindir / "gh"
          gh.write_text(FAKE_GH)
          gh.chmod(gh.stat().st_mode | stat.S_IEXEC)
          (ghdir / "log").write_text("")
          (ghdir / "fail").write_text("gh: Not Found (HTTP 404)\n")
          monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ['PATH']}")
          monkeypatch.setenv("FAKE_GH", str(ghdir))
      
          class World:
              root = repo
      
              def track(self, rel: str, doc: object) -> None:
                  path = repo / rel
                  path.parent.mkdir(parents=True, exist_ok=True)
                  path.write_text(json.dumps(doc))
                  _git(repo, "add", rel)
      
              def serve(self, name: str, doc: object) -> None:
                  (ghdir / name).write_text(json.dumps(doc))
      
              def fail_with(self, line: str) -> None:
                  (ghdir / "fail").write_text(line + "\n")
      
              def calls(self) -> list[str]:
                  return (ghdir / "log").read_text().splitlines()
      
          return World()
      
      
      # ---------------------------------------------------------------------------
      # gh helper
      # ---------------------------------------------------------------------------
      
      
      @pytest.mark.parametrize("url", [
          "https://github.com/acme/widget.git",
          "https://github.com/acme/widget",
          "git@github.com:acme/widget.git",
          "ssh://git@github.com/acme/widget.git",
      ])
      def test_parse_github_remote_accepts_github_forms(url: str) -> None:
          repo = parse_github_remote(url)
          assert repo is not None and repo.slug == "acme/widget"
      
      
      def test_parse_github_remote_rejects_other_hosts() -> None:
          assert parse_github_remote("https://gitlab.com/acme/widget.git") is None
      
      
      def test_no_remote_never_invokes_gh(world) -> None:
          _git(world.root, "remote", "remove", "origin")
          with pytest.raises(GhUnavailable) as e:
              open_github(world.root)
          assert e.value.reason
          assert world.calls() == []
      
      
      def test_unauthenticated_gh_degrades(world) -> None:
          (Path(os.environ["FAKE_GH"]) / "noauth").write_text("")
          with pytest.raises(GhUnavailable) as e:
              open_github(world.root)
          assert e.value.reason.startswith("not_authenticated")
      
      
      def test_gh_json_parses_pr_list(world) -> None:
          world.serve("prs.json", [{"number": 1, "extra": "x"}])
          assert gh_json(["pr", "list", "--state", "merged", "--json", "number"])[0]["number"] == 1
      
      
      # ---------------------------------------------------------------------------
      # config_drift
      # ---------------------------------------------------------------------------
      
      
      def test_ruleset_drift_reports_one_entry(world) -> None:
          world.track(".github/rulesets/main.json", _ruleset(False))
          world.serve("rulesets.json", [{"id": 7, "name": "main"}])
          world.serve("ruleset.json", _ruleset(True, updated_at="2026-08-01T00:00:00Z"))
          block = scan_config_drift(world.root)
          assert block["available"] is True
          assert block["entries"] == [{
              "file": ".github/rulesets/main.json",
              "key": "rules[required_status_checks].parameters.strict_required_status_checks_policy",
              "tracked": False,
              "live": True,
          }]
      
      
      def test_identical_ruleset_gives_empty_entries(world) -> None:
          world.track(".github/rulesets/main.json", _ruleset(False))
          world.serve("rulesets.json", [{"id": 7, "name": "main"}])
          world.serve("ruleset.json", _ruleset(False, updated_at="2026-08-01T00:00:00Z",
                                               node_id="X", _links={"self": {"href": "h"}}))
          block = scan_config_drift(world.root)
          assert block == {"available": True, "entries": [], "dropped": 0,
                           "snapshots": [{"file": ".github/rulesets/main.json", "kind": "ruleset"}]}
      
      
      def test_ruleset_matched_by_name_outside_rulesets_dir(world) -> None:
          doc = _ruleset(False)
          del doc["id"]
          world.track("infra/github/protect-main.json", doc)
          world.serve("rulesets.json", [{"id": 99, "name": "main"}])
          world.serve("ruleset.json", _ruleset(True))
          block = scan_config_drift(world.root)
          assert [e["file"] for e in block["entries"]] == ["infra/github/protect-main.json"]
      
      
      def test_rule_dropped_or_added_live_is_drift_without_the_live_object() -> None:
          tracked = _ruleset(False)
          live = _ruleset(False)
          live["rules"] = live["rules"][1:] + [{"type": "non_fast_forward", "parameters": {"x": 1}}]
          assert diff_values(tracked, live) == [
              ("rules[deletion]", "present", "absent"),
              ("rules[non_fast_forward]", "absent", "present"),
          ]
      
      
      def test_reordered_lists_are_not_drift() -> None:
          tracked = {"required_status_checks": {"contexts": ["ci", "lint"]},
                     "bypass_actors": [{"actor_id": 1, "actor_type": "Team"},
                                       {"actor_id": 2, "actor_type": "Integration"}]}
          live = {"required_status_checks": {"contexts": ["lint", "ci"]},
                  "bypass_actors": [{"actor_id": 2, "actor_type": "Integration", "bypass_mode": "always"},
                                    {"actor_id": 1, "actor_type": "Team", "bypass_mode": "always"}]}
          assert diff_values(tracked, live) == []
      
      
      def test_changed_scalar_list_is_one_bounded_entry() -> None:
          tracked = {"required_status_checks": {"contexts": ["ci"]}}
          live = {"required_status_checks": {"contexts": ["lint", "ci"]}}
          assert diff_values(tracked, live) == [
              ("required_status_checks.contexts",
               {"count": 1, "removed": 0, "sample": []},
               {"count": 2, "added": 1, "sample": ["lint"]})]
      
      
      def test_scalar_list_drift_never_stores_the_whole_live_list() -> None:
          tracked = {"required_status_checks": {"contexts": ["ci", "old-a", "old-b", "old-c", "old-d"]}}
          live_names = ["ci"] + [f"zzlive-{i:02d}" for i in range(30)]
          live = {"required_status_checks": {"contexts": live_names}}
          [(key, was, now)] = diff_values(tracked, live)
          assert key == "required_status_checks.contexts"
          assert was == {"count": 5, "removed": 4, "sample": ["old-a", "old-b", "old-c"]}
          assert now == {"count": 31, "added": 30, "sample": ["zzlive-00", "zzlive-01", "zzlive-02"]}
          assert json.dumps(now).count("zzlive") == 3
      
      
      def test_identity_less_object_list_reports_counts_only() -> None:
          tracked = {"x": [{"a": 1}]}
          live = {"x": [{"a": 1}, {"a": 2, "secret_ish": "org detail"}]}
          assert diff_values(tracked, live) == [("x.count", 1, 2)]
      
      
      @pytest.mark.parametrize("doc", [
          {"description": "not an export"},                       # no rules at all
          {"rules": [{"type": "lint-rule"}]},                     # rules but no name/id
      ])
      def test_non_export_json_is_not_a_snapshot(world, doc) -> None:
          world.track(".github/rulesets/schema.json", doc)
          world.track("config/eslint-ish.json", doc)
          assert find_snapshots(world.root) == []
          assert scan_config_drift(world.root)["entries"] == []
          assert world.calls() == []
      
      
      def test_deleted_branch_is_drift_not_outage(world) -> None:
          world.track(".github/branch-protection/old.json", _protection(True) | {"url": ""})
          world.track(".github/rulesets/main.json", _ruleset(False))
          world.serve("rulesets.json", [{"id": 7, "name": "main"}])
          world.serve("ruleset.json", _ruleset(True))
          world.fail_with("gh: Branch not found (HTTP 404)")
          block = scan_config_drift(world.root)
          assert block["available"] is True
          assert {"file": ".github/branch-protection/old.json", "key": "branch",
                  "tracked": "old", "live": "absent"} in block["entries"]
          assert any(e["file"] == ".github/rulesets/main.json" for e in block["entries"])
      
      
      def test_unprotected_branch_is_drift(world) -> None:
          world.track(".github/branch-protection/main.json", _protection(True))
          world.fail_with("gh: Branch not protected (HTTP 404)")
          block = scan_config_drift(world.root)
          assert block["entries"] == [{"file": ".github/branch-protection/main.json",
                                       "key": "branch_protection", "tracked": "present",
                                       "live": "absent"}]
      
      
      def test_other_404_degrades_the_block(world) -> None:
          world.track(".github/branch-protection/main.json", _protection(True))
          block = scan_config_drift(world.root)  # default fail: "gh: Not Found (HTTP 404)"
          assert block["available"] is False and block["reason"].startswith("not_found")
      
      
      def test_missing_live_ruleset_is_drift(world) -> None:
          world.track(".github/rulesets/main.json", _ruleset(False))
          world.serve("rulesets.json", [])
          block = scan_config_drift(world.root)
          assert block["entries"] == [{"file": ".github/rulesets/main.json", "key": "ruleset",
                                       "tracked": "main", "live": "absent"}]
      
      
      def test_branch_protection_export_drift(world) -> None:
          world.track(".github/branch-protection/main.json", _protection(False))
          world.serve("protection.json", _protection(True))
          block = scan_config_drift(world.root)
          assert block["available"] is True
          assert block["entries"] == [{"file": ".github/branch-protection/main.json",
                                       "key": "required_status_checks.strict",
                                       "tracked": False, "live": True}]
          assert any("repos/acme/widget/branches/main/protection" in c for c in world.calls())
      
      
      def test_branch_protection_branch_from_file_stem_and_write_shape(world) -> None:
          doc = _protection(True)
          del doc["url"]
          doc["enforce_admins"] = True  # the PUT payload shape
          world.track(".github/protection/release.json", doc)
          world.serve("protection.json", _protection(True))
          block = scan_config_drift(world.root)
          assert block["entries"] == []
          assert any("branches/release/protection" in c for c in world.calls())
      
      
      def test_403_degrades_to_no_access(world) -> None:
          world.track(".github/rulesets/main.json", _ruleset(False))
          world.fail_with("gh: Resource not accessible by personal access token (HTTP 403)")
          block = scan_config_drift(world.root)
          assert block["available"] is False
          assert "no_access" in block["reason"]
          assert world.calls()
      
      
      def test_no_remote_degrades_without_calling_gh(world) -> None:
          world.track(".github/rulesets/main.json", _ruleset(False))
          _git(world.root, "remote", "remove", "origin")
          block = scan_config_drift(world.root)
          assert block["available"] is False and block["reason"]
          assert world.calls() == []
      
      
      def test_no_snapshots_is_clean_without_calling_gh(world) -> None:
          world.track("package.json", {"name": "x", "rules": "not a ruleset"})
          assert scan_config_drift(world.root) == {"available": True, "entries": [], "dropped": 0,
                                                    "snapshots": []}
          assert world.calls() == []
      
      
      def test_untracked_snapshot_is_ignored(world) -> None:
          path = world.root / ".github" / "rulesets" / "main.json"
          path.parent.mkdir(parents=True)
          path.write_text(json.dumps(_ruleset(False)))
          assert find_snapshots(world.root) == []
      
      
      def test_key_omitted_live_is_absent_not_null() -> None:
          tracked = {"required_status_checks": {"strict": True},
                     "required_pull_request_reviews": {"required_approving_review_count": 1}}
          live = {"required_status_checks": {"strict": True}}
          assert diff_values(tracked, live) == [("required_pull_request_reviews", "present", "absent")]
          assert diff_values({"a": {"b": False}}, {"a": {}}) == [("a.b", False, "absent")]
          assert diff_values({"a": None}, {"a": None}) == []
      
      
      def test_ruleset_list_excludes_inherited_rulesets(world) -> None:
          world.track(".github/rulesets/main.json", _ruleset(False))
          world.serve("rulesets.json", [{"id": 7, "name": "main"}])
          world.serve("ruleset.json", _ruleset(False))
          scan_config_drift(world.root)
          listing = [c for c in world.calls() if "rulesets" in c and "rulesets/" not in c]
          assert listing and all("includes_parents=false" in c for c in listing)
      
      
      def test_json_without_a_snapshot_key_is_not_parsed(world, monkeypatch) -> None:
          import lib.config_drift as cd
      
          world.track("data/big.json", {"items": list(range(100))})
          parsed: list[str] = []
          real = cd.json.loads
          monkeypatch.setattr(cd.json, "loads", lambda t: parsed.append(t) or real(t))
          assert find_snapshots(world.root) == []
          assert parsed == []
      
      
      def test_entries_rank_worst_first(world) -> None:
          tracked = _protection(True)  # required_status_checks precedes enforce_admins
          live = _protection(True)
          live["required_status_checks"]["contexts"] = ["ci-renamed"]
          live["enforce_admins"] = {"url": "u", "enabled": False}
          del live["required_pull_request_reviews"]
          world.track(".github/branch-protection/main.json", tracked)
          world.serve("protection.json", live)
          keys = [e["key"] for e in scan_config_drift(world.root)["entries"]]
          assert keys == ["required_pull_request_reviews", "enforce_admins",
                          "required_status_checks.contexts"]
      
      
      def test_entries_are_capped_with_a_dropped_count(world) -> None:
          from lib.config_drift import MAX_ENTRIES
          n = MAX_ENTRIES + 7
          tracked = _ruleset(False)
          tracked["rules"] = [{"type": f"rule-{i:02d}", "parameters": {"v": 1}} for i in range(n)]
          live = json.loads(json.dumps(tracked))
          for rule in live["rules"]:
              rule["parameters"]["v"] = 2
          world.track(".github/rulesets/main.json", tracked)
          world.serve("rulesets.json", [{"id": 7, "name": "main"}])
          world.serve("ruleset.json", live)
          block = scan_config_drift(world.root)
          assert len(block["entries"]) == MAX_ENTRIES
          assert block["dropped"] == 7
      
      
      def test_no_drift_reports_zero_dropped(world) -> None:
          world.track(".github/rulesets/main.json", _ruleset(False))
          world.serve("rulesets.json", [{"id": 7, "name": "main"}])
          world.serve("ruleset.json", _ruleset(False))
          block = scan_config_drift(world.root)
          assert block["entries"] == [] and block["dropped"] == 0
      
      
      def test_absent_key_reports_the_normalised_tracked_value() -> None:
          tracked = {"enforce_admins": {"url": "u", "enabled": False}}
          assert diff_values(tracked, {}) == [("enforce_admins", False, "absent")]
      
      
      def test_type_mismatch_never_emits_the_live_object() -> None:
          tracked = {"required_pull_request_reviews": None, "restrictions": None}
          live = {"required_pull_request_reviews": {"dismissal_restrictions": {
                      "users": [{"login": "zzuser"}], "teams": [{"slug": "zzteam"}]}},
                  "restrictions": {"users": [], "teams": [{"slug": "zzteam"}]}}
          out = diff_values(tracked, live)
          assert out == [("required_pull_request_reviews", None, "present"),
                         ("restrictions", None, "present")]
          assert "zz" not in json.dumps(out)
          assert diff_values({"a": {"b": 1}}, {"a": 3}) == [("a", "present", 3)]
      
      
      def test_tracked_null_against_omitted_live_key_is_not_drift() -> None:
          tracked = {"required_status_checks": {"strict": True},
                     "required_pull_request_reviews": None, "restrictions": None}
          live = {"required_status_checks": {"strict": True}}
          assert diff_values(tracked, live) == []
          live_flip = {"required_status_checks": {"strict": False}}
          assert diff_values(tracked, live_flip) == [("required_status_checks.strict", True, False)]
      
      
      def _user(login: str, uid: int) -> dict:
          # A GitHub simple-user object: `type` is the class discriminator, not an identity.
          return {"login": login, "id": uid, "type": "User", "site_admin": False}
      
      
      @pytest.mark.parametrize("path", ["restrictions", "required_pull_request_reviews.dismissal_restrictions"])
      def test_single_user_swap_is_one_removed_and_one_added(path: str) -> None:
          def shape(user: dict) -> dict:
              doc: dict = {"users": [user], "teams": []}
              for part in reversed(path.split(".")):
                  doc = {part: doc}
              return doc
          out = diff_values(shape(_user("alice", 1)), shape(_user("bob", 2)))
          assert out == [(f"{path}.users[alice]", "present", "absent"),
                         (f"{path}.users[bob]", "absent", "present")]
          assert all(v in ("present", "absent") for _, was, now in out for v in (was, now))
      
      
      def test_single_team_swap_is_one_removed_and_one_added() -> None:
          # Team objects carry `type` ("organization" / "enterprise") as a discriminator.
          tracked = {"restrictions": {"users": [], "teams": [
              {"slug": "core", "name": "Core", "id": 1, "type": "organization"}]}}
          live = {"restrictions": {"users": [], "teams": [
              {"slug": "infra", "name": "Infra", "id": 2, "type": "organization"}]}}
          assert diff_values(tracked, live) == [
              ("restrictions.teams[core]", "present", "absent"),
              ("restrictions.teams[infra]", "absent", "present")]
      
      
      def test_ruleset_rules_still_pair_on_type() -> None:
          tracked = {"rules": [{"type": "pull_request",
                                "parameters": {"required_approving_review_count": 1}}]}
          live = {"rules": [{"type": "pull_request",
                             "parameters": {"required_approving_review_count": 2}}]}
          assert diff_values(tracked, live) == [
              ("rules[pull_request].parameters.required_approving_review_count", 1, 2)]
      
      
      _WRITE_VS_READ = [
          ("restrictions.users", ["octocat"], [_user("octocat", 1)]),
          ("restrictions.teams", ["core"],
           [{"slug": "core", "name": "Core", "id": 1, "type": "organization"}]),
          ("restrictions.apps", ["deploy-bot"],
           [{"slug": "deploy-bot", "name": "Deploy Bot", "id": 9, "owner": {"login": "acme"}}]),
          ("required_pull_request_reviews.dismissal_restrictions.users", ["octocat"],
           [_user("octocat", 1)]),
          ("required_pull_request_reviews.dismissal_restrictions.teams", ["core"],
           [{"slug": "core", "name": "Core", "id": 1, "type": "organization"}]),
      ]
      
      
      def _nest(path: str, value: object) -> dict:
          doc: object = value
          for part in reversed(path.split(".")):
              doc = {part: doc}
          assert isinstance(doc, dict)
          return doc
      
      
      @pytest.mark.parametrize("path,write,read", _WRITE_VS_READ)
      def test_write_shape_names_match_read_shape_objects(path: str, write: list, read: list) -> None:
          assert diff_values(_nest(path, write), _nest(path, read)) == []
      
      
      @pytest.mark.parametrize("path,write,read", _WRITE_VS_READ)
      def test_write_shape_names_report_a_real_change(path: str, write: list, read: list) -> None:
          [(key, was, now)] = diff_values(_nest(path, ["someone-else"]), _nest(path, read))
          assert key == path
          assert was["removed"] == 1 and now["added"] == 1
      
      
      def test_write_shape_protection_snapshot_is_clean_against_the_read(world) -> None:
          doc = _protection(True)
          doc["restrictions"] = {"users": ["octocat"], "teams": ["core"], "apps": ["deploy-bot"]}
          live = _protection(True)
          live["restrictions"] = {"users": [_user("octocat", 1)],
                                  "teams": [{"slug": "core", "name": "Core", "id": 1}],
                                  "apps": [{"slug": "deploy-bot", "name": "Deploy Bot", "id": 9}]}
          world.track(".github/branch-protection/main.json", doc)
          world.serve("protection.json", live)
          block = scan_config_drift(world.root)
          assert block["available"] is True and block["entries"] == []
      
    • test_coupling_analysis.py 9.5 KB
      """Tests for the B3 static-vs-historical disagreement cross (coupling_analysis).
      
      The inputs (per-directory containment ratios + an optional per-directory
      static-modularity view) are **mocked directly** rather than derived from real
      git histories and grimp graphs: B3 is a pure function of those two upstream
      signals, which already have their own contract tests (test_change_coupling.py,
      test_structure_graph.py). Mocking keeps these tests fast and makes the
      disagreement logic auditable in isolation. Expected classifications are spelled
      out in each test so the contract is explicit.
      """
      from __future__ import annotations
      
      from lib.coupling_analysis import (
          DEFAULT_HIGH_CONTAINMENT,
          DEFAULT_LOW_CONTAINMENT,
          detect_hidden_coupling,
          find_refactor_boundaries,
      )
      
      
      def _finding_for(results: list[dict], path: str):
          """The `finding` for `path` in a results list, or KeyError-free None."""
          for r in results:
              if r["path"] == path:
                  return r["finding"]
          return "ABSENT"
      
      
      # --------------------------------------------------------------------------
      # detect_hidden_coupling
      # --------------------------------------------------------------------------
      
      def test_high_static_modularity_plus_low_containment_is_hidden_coupling():
          """Looks modular statically (high Q) but bleeds historically -> hidden_coupling."""
          containment = {"pkg/a": 0.1}
          static = {"pkg/a": {"modularity_q": 0.6, "front_door_ratio": 0.95}}
          results = detect_hidden_coupling(containment, static_modularity=static)
          assert len(results) == 1
          entry = results[0]
          assert entry["finding"] == "hidden_coupling"
          assert entry["path"] == "pkg/a"
          assert entry["containment_ratio"] == 0.1
          assert "seam" in entry["recommendation"].lower()
      
      
      def test_high_front_door_alone_triggers_hidden_coupling():
          """Front-door ratio high (A3) is enough to count as 'looks modular'."""
          containment = {"pkg/a": 0.2}
          # modularity low, but a clean front door -> still looks modular statically.
          static = {"pkg/a": {"modularity_q": 0.0, "front_door_ratio": 0.9}}
          results = detect_hidden_coupling(containment, static_modularity=static)
          assert _finding_for(results, "pkg/a") == "hidden_coupling"
      
      
      def test_low_containment_no_static_input_is_bleeding_module():
          """No static graph at all -> graceful historical-only fallback label."""
          containment = {"pkg/a": 0.15}
          results = detect_hidden_coupling(containment, static_modularity=None)
          assert len(results) == 1
          assert results[0]["finding"] == "bleeding_module"
          assert results[0]["path"] == "pkg/a"
      
      
      def test_low_containment_dir_absent_from_static_dict_is_bleeding_module():
          """Static graph present but no evidence for THIS dir -> per-dir fallback."""
          containment = {"pkg/a": 0.15}
          static = {"pkg/other": {"modularity_q": 0.6, "front_door_ratio": 0.9}}
          results = detect_hidden_coupling(containment, static_modularity=static)
          assert _finding_for(results, "pkg/a") == "bleeding_module"
      
      
      def test_low_static_and_high_containment_is_suppressed():
          """Looks coupled statically + never co-changes (high containment) -> suppressed.
      
          The static graph already surfaces the coupling; with no behavioural bleed
          there is nothing new to flag, so the directory is simply omitted (it does
          not bleed, so it is not this function's concern).
          """
          containment = {"pkg/a": 0.95}  # high: edits stay contained
          static = {"pkg/a": {"modularity_q": -0.1, "front_door_ratio": 0.2}}
          results = detect_hidden_coupling(containment, static_modularity=static)
          assert results == []
      
      
      def test_low_static_and_low_containment_agree_coupled_finding_none():
          """Static AND history agree it's coupled -> reported but finding=None (not hidden)."""
          containment = {"pkg/a": 0.1}  # bleeds
          static = {"pkg/a": {"modularity_q": -0.2, "front_door_ratio": 0.1}}  # looks coupled
          results = detect_hidden_coupling(containment, static_modularity=static)
          assert len(results) == 1
          assert results[0]["finding"] is None
          assert results[0]["path"] == "pkg/a"
      
      
      def test_non_bleeding_dirs_are_omitted():
          """Directories at or above the low threshold are not hidden-coupling concerns."""
          containment = {"safe": 0.8, "edge": DEFAULT_LOW_CONTAINMENT, "bleeds": 0.1}
          results = detect_hidden_coupling(containment, static_modularity=None)
          paths = {r["path"] for r in results}
          assert paths == {"bleeds"}  # 0.8 and the 0.3 edge are excluded
      
      
      def test_low_containment_threshold_is_strict_lower_bound():
          """containment == threshold is NOT low (boundary not flagged); just below IS."""
          containment = {"at": DEFAULT_LOW_CONTAINMENT, "below": DEFAULT_LOW_CONTAINMENT - 0.01}
          results = detect_hidden_coupling(containment, static_modularity=None)
          assert _finding_for(results, "at") == "ABSENT"
          assert _finding_for(results, "below") == "bleeding_module"
      
      
      def test_custom_low_threshold_is_honoured():
          containment = {"pkg/a": 0.45}
          # default 0.3 would not flag 0.45; raise the bar to 0.5 and it bleeds.
          assert detect_hidden_coupling(containment, threshold_low_containment=0.3) == []
          results = detect_hidden_coupling(containment, threshold_low_containment=0.5)
          assert _finding_for(results, "pkg/a") == "bleeding_module"
      
      
      def test_hidden_coupling_results_sorted_worst_bleed_first():
          containment = {"a": 0.05, "b": 0.25, "c": 0.15}
          results = detect_hidden_coupling(containment, static_modularity=None)
          assert [r["path"] for r in results] == ["a", "c", "b"]
          assert [r["containment_ratio"] for r in results] == [0.05, 0.15, 0.25]
      
      
      def test_partial_static_metrics_only_modularity():
          """A static dict carrying only modularity_q (no front_door) still classifies."""
          containment = {"pkg/a": 0.2}
          static = {"pkg/a": {"modularity_q": 0.5}}  # front_door_ratio missing
          assert _finding_for(
              detect_hidden_coupling(containment, static_modularity=static), "pkg/a"
          ) == "hidden_coupling"
      
      
      def test_empty_containment_returns_empty():
          assert detect_hidden_coupling({}, static_modularity=None) == []
          assert detect_hidden_coupling({}, static_modularity={}) == []
      
      
      # --------------------------------------------------------------------------
      # find_refactor_boundaries
      # --------------------------------------------------------------------------
      
      def test_high_containment_is_refactor_boundary():
          """High containment (> 0.7) -> safe zone, even with no static graph."""
          containment = {"pkg/island": 0.9}
          results = find_refactor_boundaries(containment)
          assert len(results) == 1
          entry = results[0]
          assert entry["finding"] == "refactor_boundary"
          assert entry["path"] == "pkg/island"
          assert entry["containment_ratio"] == 0.9
          assert "isolation" in entry["recommendation"].lower()
      
      
      def test_high_containment_threshold_is_strict_upper_bound():
          """containment == threshold is NOT high enough; just above IS."""
          containment = {"at": DEFAULT_HIGH_CONTAINMENT, "above": DEFAULT_HIGH_CONTAINMENT + 0.01}
          results = find_refactor_boundaries(containment)
          paths = {r["path"] for r in results}
          assert paths == {"above"}
      
      
      def test_low_containment_is_not_a_refactor_boundary():
          containment = {"pkg/bleeds": 0.2}
          assert find_refactor_boundaries(containment) == []
      
      
      def test_refactor_boundary_static_agreement_enriches_recommendation():
          """A modular static boundary that agrees gets the 'lenses agree' note."""
          containment = {"pkg/clean": 0.9, "pkg/quiet": 0.85}
          static = {
              "pkg/clean": {"modularity_q": 0.6, "front_door_ratio": 0.95},  # looks modular
              "pkg/quiet": {"modularity_q": -0.1, "front_door_ratio": 0.2},  # looks coupled
          }
          results = find_refactor_boundaries(containment, static_modularity=static)
          by_path = {r["path"]: r for r in results}
          # Both qualify on containment; static only changes the wording.
          assert by_path["pkg/clean"]["finding"] == "refactor_boundary"
          assert by_path["pkg/quiet"]["finding"] == "refactor_boundary"
          assert "agree" in by_path["pkg/clean"]["recommendation"].lower()
          assert "agree" not in by_path["pkg/quiet"]["recommendation"].lower()
      
      
      def test_refactor_boundaries_sorted_safest_first():
          containment = {"a": 0.75, "b": 0.99, "c": 0.85}
          results = find_refactor_boundaries(containment)
          assert [r["path"] for r in results] == ["b", "c", "a"]
      
      
      def test_custom_high_threshold_is_honoured():
          containment = {"pkg/a": 0.6}
          assert find_refactor_boundaries(containment, threshold_high_containment=0.7) == []
          results = find_refactor_boundaries(containment, threshold_high_containment=0.5)
          assert _finding_for(results, "pkg/a") == "refactor_boundary"
      
      
      def test_refactor_empty_containment_returns_empty():
          assert find_refactor_boundaries({}) == []
      
      
      # --------------------------------------------------------------------------
      # The two functions partition the bleed/island space cleanly
      # --------------------------------------------------------------------------
      
      def test_hidden_coupling_and_refactor_boundaries_are_disjoint():
          """No directory is both a bleed concern and a safe refactor boundary."""
          containment = {"bleeds": 0.1, "island": 0.95, "middle": 0.5}
          static = {"bleeds": {"modularity_q": 0.6, "front_door_ratio": 0.9}}
          hidden = {r["path"] for r in detect_hidden_coupling(containment, static_modularity=static)}
          safe = {r["path"] for r in find_refactor_boundaries(containment, static_modularity=static)}
          assert hidden == {"bleeds"}
          assert safe == {"island"}
          assert hidden.isdisjoint(safe)
          # "middle" (0.3 <= c <= 0.7) is neither flagged nor declared safe.
      
    • test_coverage_report.py 7.4 KB
      """Contract suite for the coverage-report parser.
      
      The parser reads an *existing* coverage report (Cobertura ``coverage.xml`` or
      ``lcov.info``) into the shape ``scan_test_pressure`` consumes:
      ``{"_overall": float, "per_file": {relpath: line_rate}}``. These tests pin the
      two parse formats, the detection search order, and the hard contract that any
      absent or malformed input degrades to ``None`` rather than raising.
      
      Expected ratios are hand-computed in the fixtures so the contract is auditable:
      - ``fixtures/coverage.xml`` - root line-rate 0.75; src/a.py 0.8, src/b.py 0.5.
      - ``fixtures/lcov.info`` - a: LH 8/LF 10 = 0.8; b: 2/10 = 0.2;
        overall (8+2)/(10+10) = 0.5.
      """
      from __future__ import annotations
      
      from pathlib import Path
      
      from lib.coverage_report import (
          _parse_cobertura,
          _parse_lcov,
          detect_coverage_report,
          load_coverage_data,
      )
      
      
      # --- Cobertura ------------------------------------------------------------
      
      def test_parse_cobertura_nested_schema(fixtures_dir: Path) -> None:
          result = _parse_cobertura(fixtures_dir / "coverage.xml")
          assert result is not None
          assert result["_overall"] == 0.75
          assert result["per_file"] == {"src/a.py": 0.8, "src/b.py": 0.5}
      
      
      def test_parse_cobertura_flat_schema(tmp_path: Path) -> None:
          """A flat ``<coverage><classes><class>`` report (no ``<packages>``) parses
          via the same tree walk."""
          xml = (
              '<?xml version="1.0" ?>\n'
              '<coverage line-rate="0.6">\n'
              '  <classes>\n'
              '    <class filename="x.py" line-rate="0.6"/>\n'
              '  </classes>\n'
              '</coverage>\n'
          )
          path = tmp_path / "coverage.xml"
          path.write_text(xml)
          result = _parse_cobertura(path)
          assert result == {"_overall": 0.6, "per_file": {"x.py": 0.6}}
      
      
      def test_parse_cobertura_malformed_returns_none(tmp_path: Path) -> None:
          path = tmp_path / "coverage.xml"
          path.write_text("<coverage line-rate=\"0.5\"><classes><not-closed")
          assert _parse_cobertura(path) is None
      
      
      def test_parse_cobertura_absent_returns_none(tmp_path: Path) -> None:
          assert _parse_cobertura(tmp_path / "nope.xml") is None
      
      
      # --- lcov -----------------------------------------------------------------
      
      def test_parse_lcov(fixtures_dir: Path) -> None:
          result = _parse_lcov(fixtures_dir / "lcov.info")
          assert result is not None
          assert result["per_file"] == {"src/a.py": 0.8, "src/b.py": 0.2}
          assert result["_overall"] == 0.5
      
      
      def test_parse_lcov_no_terminator_still_flushes(tmp_path: Path) -> None:
          """A record without ``end_of_record`` is flushed at EOF."""
          path = tmp_path / "lcov.info"
          path.write_text("SF:a.py\nLF:4\nLH:2\n")
          result = _parse_lcov(path)
          assert result == {"_overall": 0.5, "per_file": {"a.py": 0.5}}
      
      
      def test_parse_lcov_zero_lines_returns_none(tmp_path: Path) -> None:
          """A record with LF:0 contributes nothing; an all-zero report degrades."""
          path = tmp_path / "lcov.info"
          path.write_text("SF:a.py\nLF:0\nLH:0\nend_of_record\n")
          assert _parse_lcov(path) is None
      
      
      def test_parse_lcov_absent_returns_none(tmp_path: Path) -> None:
          assert _parse_lcov(tmp_path / "nope.info") is None
      
      
      # --- detection ------------------------------------------------------------
      
      def test_detect_prefers_cobertura_at_root(tmp_path: Path) -> None:
          (tmp_path / "coverage.xml").write_text("<coverage line-rate=\"0.5\"/>")
          (tmp_path / "lcov.info").write_text("SF:a\nLF:1\nLH:1\nend_of_record\n")
          assert detect_coverage_report(tmp_path) == {
              "source": "coverage.xml", "format": "cobertura"}
      
      
      def test_detect_lcov_in_coverage_subdir(tmp_path: Path) -> None:
          (tmp_path / "coverage").mkdir()
          (tmp_path / "coverage" / "lcov.info").write_text(
              "SF:a\nLF:1\nLH:1\nend_of_record\n")
          assert detect_coverage_report(tmp_path) == {
              "source": "coverage/lcov.info", "format": "lcov"}
      
      
      def test_detect_dot_coverage_directory(tmp_path: Path) -> None:
          (tmp_path / ".coverage").mkdir()
          (tmp_path / ".coverage" / "coverage.xml").write_text(
              "<coverage line-rate=\"0.5\"/>")
          assert detect_coverage_report(tmp_path) == {
              "source": ".coverage/coverage.xml", "format": "cobertura"}
      
      
      def test_detect_dot_coverage_sqlite_file_out_of_scope(tmp_path: Path) -> None:
          """A bare ``.coverage`` SQLite *file* (not a directory) is never matched -
          reading it needs the coverage.py library, which is out of scope."""
          (tmp_path / ".coverage").write_text("SQLite format 3\x00binary junk")
          assert detect_coverage_report(tmp_path) is None
      
      
      def test_detect_none_when_absent(tmp_path: Path) -> None:
          assert detect_coverage_report(tmp_path) is None
      
      
      # --- load (end to end) ----------------------------------------------------
      
      def test_load_cobertura_end_to_end(tmp_path: Path) -> None:
          (tmp_path / "coverage.xml").write_text(
              '<coverage line-rate="0.9"><classes>'
              '<class filename="m.py" line-rate="0.9"/></classes></coverage>')
          assert load_coverage_data(tmp_path) == {
              "_overall": 0.9, "per_file": {"m.py": 0.9}}
      
      
      def test_load_returns_none_when_no_report(tmp_path: Path) -> None:
          assert load_coverage_data(tmp_path) is None
      
      
      def test_load_malformed_report_degrades_to_none(tmp_path: Path) -> None:
          """Detected but unparseable -> None, never an exception."""
          (tmp_path / "coverage.xml").write_text("not xml at all <<<")
          assert load_coverage_data(tmp_path) is None
      
      
      def test_lcov_path_normalised(tmp_path: Path) -> None:
          """lcov SF: paths are emitted as the runner saw them - absolute, or
          ./-prefixed. load_coverage_data normalises both to repo-relative POSIX keys
          so test_focus's exact-key lookup matches (#317). Resolving both sides keeps
          macOS /var vs /private/var from breaking the relative_to."""
          root = tmp_path / "repo"
          (root / "src").mkdir(parents=True)
          abs_a = (root / "src" / "a.ts").resolve()
          (root / "lcov.info").write_text(
              f"SF:{abs_a}\nLF:10\nLH:10\nend_of_record\n"
              "SF:./src/b.ts\nLF:10\nLH:9\nend_of_record\n"
              "SF:src/c.ts\nLF:4\nLH:1\nend_of_record\n",
              encoding="utf-8",
          )
          data = load_coverage_data(root)
          assert data is not None
          assert data["per_file"] == {"src/a.ts": 1.0, "src/b.ts": 0.9, "src/c.ts": 0.25}
      
      
      def test_lcov_path_normalised_unresolved_root_and_outside_path(tmp_path: Path) -> None:
          """An unresolved root spelling still matches a resolved SF: path, and an
          absolute path outside the repo root is kept verbatim (no crash)."""
          root = tmp_path / "repo"
          (root / "coverage").mkdir(parents=True)
          abs_a = (root / "src" / "a.ts").resolve()
          (root / "coverage" / "lcov.info").write_text(
              f"SF:{abs_a}\nLF:2\nLH:1\nend_of_record\n"
              "SF:/elsewhere/x.ts\nLF:2\nLH:2\nend_of_record\n",
              encoding="utf-8",
          )
          data = load_coverage_data(tmp_path / "repo" / ".." / "repo")
          assert data is not None
          assert data["per_file"] == {"src/a.ts": 0.5, "/elsewhere/x.ts": 1.0}
      
      
      def test_lcov_path_normalised_windows_relative(tmp_path: Path) -> None:
          """A Windows runner writes relative SF: paths with backslashes, with or
          without a .\\ prefix: both normalise to the repo-relative POSIX key."""
          (tmp_path / "lcov.info").write_text(
              "SF:.\\src\\a.ts\nLF:10\nLH:10\nend_of_record\n"
              "SF:src\\b.ts\nLF:10\nLH:9\nend_of_record\n",
              encoding="utf-8",
          )
          data = load_coverage_data(tmp_path)
          assert data is not None
          assert data["per_file"] == {"src/a.ts": 1.0, "src/b.ts": 0.9}
      
    • test_dart_capabilities.py 10.2 KB
      """Dart capability entries (#352): linting and liveness for a Dart/Flutter repo.
      
      A repository is Dart when it holds a ``pubspec.yaml`` outside excluded paths.
      Linting is credited to the analyzer when the nearest ``analysis_options.yaml``
      enables lint rules and honest-degrades naming ``dart analyze`` otherwise. Liveness always
      honest-degrades naming the analyzer's built-in ``unused_*`` diagnostics, never a
      third-party package, and ``dead_code.tools`` carries a matching Dart entry.
      """
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      from assess_core import build_run_context
      from lib.dart_capabilities import scan_dart_capabilities
      from lib.liveness_scan import scan_liveness
      
      
      def _write(root: Path, rel: str, text: str = "x") -> None:
          p = root / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      def _flutter_app(root: Path, *, analysis_options: bool) -> Path:
          """A Flutter app with its generated Gradle wrapper, as `flutter create` lays it out."""
          _write(root, "pubspec.yaml", "name: demo\n")
          _write(root, "lib/main.dart", "void main() {}\n")
          _write(root, "android/build.gradle.kts", "plugins {}\n")
          _write(root, "android/app/build.gradle.kts", "plugins {}\n")
          if analysis_options:
              _write(root, "analysis_options.yaml",
                     "include: package:flutter_lints/flutter.yaml\n")
          return root
      
      
      def _dart_tools(dead_code: dict) -> list[dict]:
          return [t for t in dead_code.get("tools", []) if t.get("language") == "dart"]
      
      
      # ── detection ──────────────────────────────────────────────────────────────
      
      def test_no_pubspec_is_not_dart(tmp_path: Path) -> None:
          _write(tmp_path, "a.py", "x = 1\n")
          _write(tmp_path, "analysis_options.yaml", "linter: {}\n")
          assert scan_dart_capabilities(tmp_path) == {"available": False, "pubspec_files": []}
      
      
      def test_pubspec_under_excluded_path_is_not_dart(tmp_path: Path) -> None:
          _write(tmp_path, "node_modules/pkg/pubspec.yaml", "name: vendored\n")
          _write(tmp_path, "tests/fixtures/app/pubspec.yaml", "name: fixture\n")
          assert scan_dart_capabilities(tmp_path)["available"] is False
      
      
      def test_pubspec_under_user_exclude_is_not_dart(tmp_path: Path) -> None:
          _write(tmp_path, "vendor_sdk/pubspec.yaml", "name: sdk\n")
          result = scan_dart_capabilities(tmp_path, extra_exclude_dirs={"vendor_sdk"})
          assert result["available"] is False
      
      
      # ── linting ────────────────────────────────────────────────────────────────
      
      def test_linting_credited_when_analysis_options_present(tmp_path: Path) -> None:
          result = scan_dart_capabilities(_flutter_app(tmp_path, analysis_options=True))
          linting = result["capabilities"]["linting"]
          assert linting["state"] == "credited"
          assert linting["served_by"] == ["dart analyze"]
          assert "analysis_options.yaml" in linting["note"]
          assert {"candidate_tool", "gloss", "note"} <= linting.keys()
      
      
      def test_linting_credits_flutter_analyze_for_a_flutter_package(tmp_path: Path) -> None:
          _flutter_app(tmp_path, analysis_options=True)
          _write(tmp_path, "pubspec.yaml",
                 "name: demo\ndependencies:\n  flutter:\n    sdk: flutter\n")
          linting = scan_dart_capabilities(tmp_path)["capabilities"]["linting"]
          assert linting["served_by"] == ["flutter analyze"]
      
      
      def test_analysis_options_in_an_ancestor_directory_credits_linting(tmp_path: Path) -> None:
          # The analyzer resolves analysis_options.yaml by walking up from each file,
          # so a monorepo root config serves a nested package.
          _write(tmp_path, "analysis_options.yaml", "include: package:lints/core.yaml\n")
          _write(tmp_path, "packages/core/pubspec.yaml", "name: core\n")
          linting = scan_dart_capabilities(tmp_path)["capabilities"]["linting"]
          assert linting["state"] == "credited"
      
      
      def test_analysis_options_outside_every_package_does_not_credit(tmp_path: Path) -> None:
          _write(tmp_path, "pubspec.yaml", "name: demo\n")
          _write(tmp_path, "tool/analysis_options.yaml", "include: package:lints/core.yaml\n")
          linting = scan_dart_capabilities(tmp_path)["capabilities"]["linting"]
          assert linting["state"] == "honest_degrade"
      
      
      def test_analysis_options_that_enables_no_rules_does_not_credit(tmp_path: Path) -> None:
          # Dart lints are opt-in: an exclude-only file (the usual codegen workaround)
          # enables no rule, so it must not read as served linting.
          _write(tmp_path, "pubspec.yaml", "name: demo\n")
          _write(tmp_path, "analysis_options.yaml",
                 "# include: package:lints/recommended.yaml\n"
                 "analyzer:\n  exclude:\n    - \"**/*.g.dart\"\n")
          linting = scan_dart_capabilities(tmp_path)["capabilities"]["linting"]
          assert linting["state"] == "honest_degrade"
          assert "enables no lint rules" in linting["note"]
      
      
      def test_analysis_options_with_linter_rules_credits(tmp_path: Path) -> None:
          _write(tmp_path, "pubspec.yaml", "name: demo\n")
          _write(tmp_path, "analysis_options.yaml",
                 "analyzer:\n  exclude: []\nlinter:\n  rules:\n    - avoid_print\n")
          linting = scan_dart_capabilities(tmp_path)["capabilities"]["linting"]
          assert linting["state"] == "credited"
      
      
      def test_linter_rules_after_a_comment_and_a_blank_line_credit(tmp_path: Path) -> None:
          _write(tmp_path, "pubspec.yaml", "name: demo\n")
          _write(tmp_path, "analysis_options.yaml",
                 "linter:\n  # house rules\n\n  rules:\n    - avoid_print\n")
          linting = scan_dart_capabilities(tmp_path)["capabilities"]["linting"]
          assert linting["state"] == "credited"
      
      
      def test_rules_under_another_top_level_key_does_not_credit(tmp_path: Path) -> None:
          _write(tmp_path, "pubspec.yaml", "name: demo\n")
          _write(tmp_path, "analysis_options.yaml",
                 "linter:\n  enabled: true\nformatter:\n  rules: []\n")
          linting = scan_dart_capabilities(tmp_path)["capabilities"]["linting"]
          assert linting["state"] == "honest_degrade"
      
      
      def test_long_linter_block_without_rules_is_linear(tmp_path: Path) -> None:
          # A linter: block of many indented lines and no rules: child used to
          # backtrack exponentially in a regex; the line scan must stay linear.
          import time
          _write(tmp_path, "pubspec.yaml", "name: demo\n")
          body = "".join(f"    key{i}: value {i}\n" for i in range(30))
          _write(tmp_path, "analysis_options.yaml", "linter:\n" + body + "  \n")
          start = time.monotonic()
          linting = scan_dart_capabilities(tmp_path)["capabilities"]["linting"]
          assert time.monotonic() - start < 2.0
          assert linting["state"] == "honest_degrade"
      
      
      def test_nearest_analysis_options_decides(tmp_path: Path) -> None:
          # The analyzer uses the nearest file only: a package-level file that enables
          # nothing shadows a root file that does.
          _write(tmp_path, "analysis_options.yaml", "include: package:lints/core.yaml\n")
          _write(tmp_path, "packages/core/pubspec.yaml", "name: core\n")
          _write(tmp_path, "packages/core/analysis_options.yaml",
                 "analyzer:\n  exclude: []\n")
          linting = scan_dart_capabilities(tmp_path)["capabilities"]["linting"]
          assert linting["state"] == "honest_degrade"
      
      
      def test_linting_honest_degrades_without_analysis_options(tmp_path: Path) -> None:
          result = scan_dart_capabilities(_flutter_app(tmp_path, analysis_options=False))
          linting = result["capabilities"]["linting"]
          assert linting["state"] == "honest_degrade"
          assert "dart analyze" in linting["candidate_tool"]
          assert "served_by" not in linting
      
      
      # ── liveness ───────────────────────────────────────────────────────────────
      
      def test_liveness_names_the_analyzer_unused_lints(tmp_path: Path) -> None:
          for with_options in (True, False):
              root = tmp_path / str(with_options)
              liveness = scan_dart_capabilities(
                  _flutter_app(root, analysis_options=with_options))["capabilities"]["liveness"]
              assert liveness["state"] == "honest_degrade"
              assert "unused_" in liveness["candidate_tool"]
              assert "dart_code_metrics" not in json.dumps(liveness)
      
      
      def test_dead_code_tools_carry_one_dart_honest_degrade_entry(tmp_path: Path) -> None:
          out = scan_liveness(_flutter_app(tmp_path, analysis_options=True), run_dead_code=False)
          dart = _dart_tools(out["dead_code"])
          assert len(dart) == 1
          assert dart[0]["status"] == "honest_degrade"
          assert "unused_" in dart[0]["tool"]
          assert dart[0]["reason"]
          assert out["dead_code"]["available"] is False
      
      
      def test_non_dart_repo_gets_no_dart_tool_entry(tmp_path: Path) -> None:
          _write(tmp_path, "a.py", "x = 1\n")
          out = scan_liveness(tmp_path, run_dead_code=False)
          assert _dart_tools(out["dead_code"]) == []
          assert "dart_capabilities" not in out
      
      
      # ── run-context wiring ─────────────────────────────────────────────────────
      
      def test_run_context_carries_language_capabilities_dart(tmp_path: Path) -> None:
          ctx = build_run_context(repo_root=_flutter_app(tmp_path, analysis_options=True),
                                  run_date="2026-09-18", non_interactive=True)
          dart = ctx["language_capabilities"]["dart"]
          assert set(dart) == {"linting", "liveness"}
          assert dart["linting"]["state"] == "credited"
          assert dart["liveness"]["state"] == "honest_degrade"
          # The Gradle wrapper under android/ is not a JVM codebase: no JVM offer, no
          # JVM tool named anywhere.
          blob = json.dumps([ctx.get("capability_offers"), ctx["language_capabilities"],
                             ctx["dead_code"]])
          for jvm_name in ("mvn", "jdeps", "Checkstyle", "OpenRewrite"):
              assert jvm_name not in blob
          assert "dart_code_metrics" not in blob
      
      
      def test_run_context_omits_language_capabilities_without_dart(tmp_path: Path) -> None:
          _write(tmp_path, "a.py", "x = 1\n")
          ctx = build_run_context(repo_root=tmp_path, run_date="2026-09-18",
                                  non_interactive=True)
          assert "dart" not in (ctx.get("language_capabilities") or {})
      
    • test_dart_complexity.py 4.5 KB
      """Tests for lib.dart_complexity, the approximate Dart per-function scanner
      (issue #364)."""
      from __future__ import annotations
      
      import time
      from pathlib import Path
      
      import pytest
      
      from lib import dart_complexity
      from lib.dart_complexity import dart_function_scores, scan_dart_functions
      
      # The acceptance fixture: routeOrder has 10 decision points in its own body and
      # 1 in a nested closure (ccn 12 with the closure folded in), behind about 20
      # decoy keywords and unbalanced braces in comments and strings.
      ROUTE_ORDER = """\
      // Decoys: if (a && b) { while (x) { for (;;) {} } }
      /* block comment: if else if while for && || { */
      int helper(int v) {
        return v + 1;
      }
      
      int routeOrder(int a, int b, List<int> items) {
        var label = "if (a && b) { while (true) { ";
        var banner = \"\"\"
          if (x) { for (;;) { while (y || z) {
        \"\"\";
        // if (total > 5 && a < b) { while (true) {
        var total = 0;
        if (a > 0 && b > 0) {
          total += 1;
        } else if (a < 0 || b < 0) {
          total -= 1;
        }
        for (var i = 0; i < a; i++) {
          if (i == b) {
            total += i;
          }
        }
        while (total > 100) {
          total -= 10;
        }
        if (items.isEmpty) {
          return total;
        }
        for (final item in items) {
          if (item > total) {
            total = item;
          }
        }
        final bump = (int v) {
          if (v > 3) {
            return v + helper(v);
          }
          return v;
        };
        return bump(total) + label.length + banner.length;
      }
      """
      
      
      def test_dart_scanner_scores_route_order_fixture(tmp_path: Path) -> None:
          assert scan_dart_functions(ROUTE_ORDER) == [
              ("helper", 1.0), ("routeOrder", 12.0)]
          f = tmp_path / "order.dart"
          f.write_text(ROUTE_ORDER)
          assert dart_function_scores(f) == ([1.0, 12.0], "routeOrder")
      
      
      def test_dart_scanner_skips_every_string_and_comment_form() -> None:
          """Raw, escaped, triple-quoted and nested-comment decoys add nothing;
          ${...} interpolation is code and counts."""
          src = r"""
      int f(bool c, String x) {
        var a = r'if (x) { \' ;
        var b = 'it\'s if && { ';
        var d = r'''if { while ''';
        var e = '''if {
        for ( ''';
        /* outer /* inner if { */ still comment while { */
        var g = "${c ? 'if {' : "while"} $x if {";
        return 0;
      }
      """
          assert scan_dart_functions(src) == [("f", 2.0)]
      
      
      def test_dart_scanner_folds_closures_and_scores_named_locals_apart() -> None:
          src = """
      void main() {
        items.forEach((i) { if (i > 0) {} });
        final xs = items.where((i) => i > 0 && i < 9);
        bool inner(int k) { while (k > 0) { k--; } return true; }
      }
      """
          assert scan_dart_functions(src) == [("main", 3.0), ("inner", 2.0)]
      
      
      def test_dart_scanner_scores_top_level_closures_as_anonymous() -> None:
          src = """
      final isReady = (x) => x > 0 && ready;
      final handler = (req) { if (req.ok) {} };
      """
          assert scan_dart_functions(src) == [
              ("<anonymous>", 2.0), ("<anonymous>", 2.0)]
      
      
      def test_dart_scanner_names_getters_generics_and_arrow_bodies() -> None:
          src = """
      class A<T> extends B<T> {
        int get size => n > 0 ? n : 0;
        set size(int v) { if (v < 0) throw 1; }
        Future<void> load<R>(R r) async { try { await f(); } on E catch (e) {} }
        String? label(int? n) => n?.toString() ?? 'none';
      }
      """
          assert scan_dart_functions(src) == [
              ("size", 2.0), ("size", 2.0), ("load", 2.0), ("label", 2.0)]
      
      
      @pytest.mark.parametrize("text", [
          "void f() {" + "{" * 200_000,
          "void f() {" * 100_000,
          "void f() {" * 50_000 + "}" * 50_000,
          "void f() { var s = '" + "if (x) " * 50_000,
          "void f() { var s = '''" + "${" * 50_000,
          "/*" * 100_000,
          "void f() { if (a && b || c) {} } " * 10_000,
          "x" * 1_000_000,
      ], ids=["deep-nesting", "unclosed-functions", "nested-functions", "unterminated-string", "unterminated-interpolation",
              "nested-comment", "long-line", "one-token"])
      def test_dart_scanner_pathological_input_is_fast(text: str) -> None:
          start = time.perf_counter()
          scan_dart_functions(text)
          assert time.perf_counter() - start < 1.0
      
      
      def test_dart_scanner_reads_a_bounded_prefix(tmp_path: Path,
                                                   monkeypatch) -> None:
          monkeypatch.setattr(dart_complexity, "_READ_BYTES", 64)
          f = tmp_path / "big.dart"
          f.write_text("int a() { return 1; }\n" + " " * 100
                       + "int b(x) { if (x) {} }\n")
          assert dart_function_scores(f) == ([1.0], "a")
      
      
      def test_dart_scanner_unreadable_or_empty_file_has_no_functions(
              tmp_path: Path) -> None:
          assert dart_function_scores(tmp_path / "missing.dart") == ([], None)
          empty = tmp_path / "empty.dart"
          empty.write_text("// only a comment\n")
          assert dart_function_scores(empty) == ([], None)
      
    • test_decline_markers.py 6.5 KB
      """Tests for decline-marker provenance + re-offer-on-major-bump (Task 12)."""
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      from lib.decline_markers import (
          build_decline_block,
          read_decline_markers,
      )
      
      
      def _assess(tmp_path: Path) -> Path:
          d = tmp_path / ".assess"
          d.mkdir(parents=True, exist_ok=True)
          return d
      
      
      def _write_marker(assess_dir: Path, tool: str, payload: object) -> None:
          p = assess_dir / f".no-{tool}"
          if isinstance(payload, str):
              p.write_text(payload, encoding="utf-8")
          else:
              p.write_text(json.dumps(payload), encoding="utf-8")
      
      
      # ── provenance recording ────────────────────────────────────────────────────
      
      def test_provenance_recorded(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "mutmut", {
              "declined_by": "ben",
              "declined_at": "2026-07-07",
              "plugin_version": "1.54.4",
              "reason": "pure-docs repo",
          })
          markers = read_decline_markers(d, "1.54.4")
          assert len(markers) == 1
          m = markers[0]
          assert m.tool == "mutmut"
          assert m.path == ".no-mutmut"
          assert m.declined_by == "ben"
          assert m.declined_at == "2026-07-07"
          assert m.version == "1.54.4"
          assert m.reason == "pure-docs repo"
          assert m.reoffer is False
      
      
      def test_block_shape(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "scc", {
              "declined_by": "ben", "declined_at": "2026-07-07",
              "plugin_version": "1.54.4",
          })
          block = build_decline_block(d, "1.54.4")
          assert set(block) == {"markers", "reoffer_mutation", "disclosures"}
          entry = block["markers"][0]
          assert set(entry) >= {
              "path", "tool", "declined_by", "declined_at", "version", "reason",
              "reoffer",
          }
      
      
      # ── disclosure in report ────────────────────────────────────────────────────
      
      def test_disclosure_names_user_and_date(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "mutmut", {
              "declined_by": "ben", "declined_at": "2026-07-07",
              "plugin_version": "1.54.4",
          })
          block = build_decline_block(d, "1.54.4")
          disclosure = block["disclosures"][0]
          assert "Mutation testing permanently declined by ben on 2026-07-07" in disclosure
      
      
      def test_disclosure_legacy_unknown(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "scc", "")  # legacy empty touch file
          block = build_decline_block(d, "1.54.4")
          disclosure = block["disclosures"][0]
          assert "an unknown user" in disclosure
          assert "an unknown date" in disclosure
      
      
      # ── re-offer on major bump ──────────────────────────────────────────────────
      
      def test_major_bump_sets_reoffer(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "mutmut", {
              "declined_by": "ben", "declined_at": "2025-01-01",
              "plugin_version": "1.9.0",
          })
          block = build_decline_block(d, "2.0.0")
          assert block["reoffer_mutation"] is True
          assert block["markers"][0]["reoffer"] is True
          # A mutation tool IS re-offered (Step 2d), so its disclosure says so.
          assert "re-offer eligible" in block["disclosures"][0]
      
      
      def test_minor_bump_no_reoffer(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "mutmut", {
              "declined_by": "ben", "declined_at": "2026-06-01",
              "plugin_version": "1.50.0",
          })
          block = build_decline_block(d, "1.54.4")
          assert block["reoffer_mutation"] is False
          assert block["markers"][0]["reoffer"] is False
      
      
      def test_patch_bump_no_reoffer(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "stryker", {
              "declined_by": "ben", "declined_at": "2026-07-01",
              "plugin_version": "1.54.3",
          })
          block = build_decline_block(d, "1.54.4")
          assert block["reoffer_mutation"] is False
      
      
      def test_reoffer_only_for_mutation_tools(tmp_path: Path) -> None:
          # An old-major dead-code linter decline must not trip reoffer_mutation.
          d = _assess(tmp_path)
          _write_marker(d, "vulture", {
              "declined_by": "ben", "declined_at": "2025-01-01",
              "plugin_version": "1.0.0",
          })
          block = build_decline_block(d, "2.0.0")
          assert block["reoffer_mutation"] is False
          # ...but the marker itself is still flagged reoffer-eligible for its own line.
          assert block["markers"][0]["reoffer"] is True
          # The disclosure must NOT claim "re-offer eligible": only mutation tools are
          # re-offered (Step 2d); Step 2b never re-asks a linter decline, so promising
          # a re-offer here would be a lying map.
          assert "re-offer eligible" not in block["disclosures"][0]
      
      
      # ── legacy / malformed markers degrade gracefully ───────────────────────────
      
      def test_legacy_empty_marker_no_crash(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "mutmut", "")
          markers = read_decline_markers(d, "2.0.0")
          assert len(markers) == 1
          m = markers[0]
          assert m.version is None
          assert m.declined_by is None
          # Legacy markers have no major to compare, so they are never auto-re-offered.
          assert m.reoffer is False
      
      
      def test_non_json_marker_no_crash(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "scc", "declined by hand\n")
          markers = read_decline_markers(d, "1.54.4")
          assert markers[0].version is None
          assert markers[0].reason is None
      
      
      def test_no_assess_dir(tmp_path: Path) -> None:
          assert read_decline_markers(tmp_path / ".assess", "1.54.4") == []
          block = build_decline_block(tmp_path / ".assess", "1.54.4")
          assert block["markers"] == []
          assert block["reoffer_mutation"] is False
      
      
      def test_markers_sorted_stable(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "scc", "")
          _write_marker(d, "mutmut", "")
          _write_marker(d, "vulture", "")
          tools = [m.tool for m in read_decline_markers(d, "1.54.4")]
          assert tools == sorted(tools)
      
      
      def test_malformed_version_no_reoffer(tmp_path: Path) -> None:
          d = _assess(tmp_path)
          _write_marker(d, "mutmut", {
              "declined_by": "ben", "declined_at": "2025-01-01",
              "plugin_version": "not-a-version",
          })
          block = build_decline_block(d, "2.0.0")
          assert block["reoffer_mutation"] is False
      
    • test_decomposition_parity.py 5 KB
      """Parity harness for the Part 3 SKILL.md decomposition.
      
      The decomposition (orchestrator + layer-scorer agent + findings-writer sub-skill
      + pr-and-issues sub-skill) must be *behaviour-preserving*: it only reorganizes
      Markdown and touches zero Python, so the deterministic pipeline
      (``assess_core.build_run_context`` -> ``assess_report.render_report``) must
      produce the byte-for-byte identical report it did before.
      
      This module pins that invariant two ways:
      
      1. **Report parity.** Build a fixed first-run fixture, render the deterministic
         report, normalize it (golden.normalize_report masks the version/commit
         provenance lines), and assert it equals the committed golden. A first-run
         fixture is used so the diff section and measured-commit provenance are
         deterministic (no prior sidecar, no git work-tree).
      2. **Structural contract.** The decomposed units exist on disk and the
         orchestrator SKILL.md delegates to each of them, so a future edit that
         re-inlines or drops a unit fails loudly.
      """
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      from assess_core import build_run_context
      from assess_report import render_report
      from golden import normalize_report
      
      # skills/assess/tests/ -> repo root is three parents up from this file's dir.
      REPO_ROOT = Path(__file__).resolve().parents[3]
      ASSESS_SKILL = REPO_ROOT / "skills" / "assess" / "SKILL.md"
      GOLDEN = Path(__file__).parent / "fixtures" / "golden" / "decomposition-parity-report.md"
      
      
      def _build_parity_fixture(repo: Path) -> Path:
          """Create the deterministic first-run fixture the golden was captured from.
      
          Plain dir (no git) + a fixed complexity-stats sidecar + a CLAUDE.md, so the
          pipeline output depends only on these inputs and the passed run_date.
          """
          repo.mkdir()
          assess = repo / ".assess"
          assess.mkdir()
          stats = {
              "files_scored": 3,
              "loc": {"p50": 30.0, "p95": 200.0, "max": 500.0, "total": 600},
              "ccn": {"p50": 2.0, "p95": 8.0, "max": 20.0, "basis": "file-aggregate"},
              "top_hotspots": [
                  {"path": "src/a.py", "loc": 500, "ccn": 20.0, "commits": 5},
                  {"path": "src/b.py", "loc": 80, "ccn": 6.0, "commits": 2},
              ],
              "top_complex": [{"path": "src/a.py", "ccn": 20}],
              "top_large": [{"path": "src/a.py", "loc": 500}],
          }
          (assess / "complexity-stats.json").write_text(json.dumps(stats))
          (repo / "CLAUDE.md").write_text("# Project\n\nDo X. Always Y. Never Z.\n")
          return repo
      
      
      def _render_parity_report(repo: Path) -> str:
          ctx = build_run_context(repo_root=repo, run_date="2026-01-01")
          return normalize_report(render_report(ctx, "parity-fixture"))
      
      
      def test_report_parity_matches_golden(tmp_path):
          """The deterministic report must reproduce the committed golden byte-for-byte."""
          repo = _build_parity_fixture(tmp_path / "repo")
          assert _render_parity_report(repo) == GOLDEN.read_text(encoding="utf-8")
      
      
      def test_report_render_is_deterministic(tmp_path):
          """Two independent builds of the same fixture render identical reports."""
          a = _render_parity_report(_build_parity_fixture(tmp_path / "a"))
          b = _render_parity_report(_build_parity_fixture(tmp_path / "b"))
          assert a == b
      
      
      # --- structural contract: the decomposed units exist and are wired up --------
      
      def test_layer_scorer_agent_exists():
          agent = REPO_ROOT / "agents" / "assess-layer-scorer.md"
          assert agent.is_file()
          assert agent.read_text(encoding="utf-8").startswith("---")
      
      
      def test_findings_and_pr_subskills_exist():
          for name in ("assess-findings", "assess-pr"):
              skill = REPO_ROOT / "skills" / name / "SKILL.md"
              assert skill.is_file(), f"missing decomposed sub-skill: {name}"
              assert "TRIGGER" in skill.read_text(encoding="utf-8"), f"{name}: needs TRIGGER clause"
      
      
      def test_orchestrator_delegates_to_each_unit():
          body = ASSESS_SKILL.read_text(encoding="utf-8")
          assert "assess-layer-scorer" in body, "orchestrator must delegate layer scoring"
          assert "assess-findings" in body, "orchestrator must delegate report writing"
          assert "assess-pr" in body, "orchestrator must delegate end-of-run offers"
      
      
      def test_orchestrator_is_thin():
          """The monolith was ~1290 lines; the thin orchestrator must stay well under."""
          lines = ASSESS_SKILL.read_text(encoding="utf-8").splitlines()
          assert len(lines) < 500, f"orchestrator grew to {len(lines)} lines - re-check the seams"
      
      
      def test_attention_low_signal_is_read_before_the_rule_that_uses_it():
          """The findings sub-skill's run-context read loads every key its Top 3 rule branches on."""
          body = (REPO_ROOT / "skills" / "assess-findings" / "SKILL.md").read_text()
          read = next(line for line in body.splitlines() if line.startswith("jq ") and ".prescribed_actions" in line)
          rule = next(line for line in body.splitlines() if line.startswith("**Mandatory attention rule"))
          for key in ("attention", "attention_low_signal", "prescribed_actions", "gap_actions"):
              assert f"`{key}`" in rule
              assert f".{key}," in read or read.rstrip().split("'")[1].endswith(f".{key}")
      
      
    • test_doc_complexity_join.py 11.6 KB
      """Tests for Signal C: the complexity x doc-state join.
      
      Inputs are MOCKED dicts shaped like the real artifacts (``complexity-stats.json``
      and ``analyze_doc_staleness``'s return) -- no git, no filesystem -- so every
      ``doc_value`` below is hand-computable from the documented formula:
      
          freshness  = clamp((T - ratio) / T, -1, +1),   T = STALENESS_RATIO_THRESHOLD = 2.0
          doc_value  = complexity_summarised x freshness
          threshold  = max(ccn.p95, MIN_HIGH_CCN=10)
      """
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      from lib.doc_complexity_join import (
          MIN_HIGH_CCN,
          STALENESS_RATIO_THRESHOLD,
          analyze_doc_complexity_join,
      )
      
      # A repo whose 95th-percentile CCN (8) sits below the McCabe floor, so the
      # high-complexity gate is the floor, 10. core.py (CCN 30) is "complex";
      # helper.py (CCN 2) is trivial.
      COMPLEXITY_STATS = {
          "ccn": {"p50": 3.0, "p95": 8.0, "max": 30.0},
          "files_scored": 2,
          "top_complex": [
              {"path": "pkg/engine/core.py", "loc": 400, "ccn": 30.0, "commits": 50},
              {"path": "pkg/util/helper.py", "loc": 20, "ccn": 2.0, "commits": 1},
          ],
          "top_hotspots": [],
          "top_large": [],
      }
      
      
      def _staleness(docs: list[dict]) -> dict:
          return {"available": True, "churn_window": "12mo", "docs": docs}
      
      
      def _doc(path: str, ratio: float, confidence: str = "high") -> dict:
          return {
              "path": path,
              "ratio": ratio,
              "last_commit_days": 10,
              "doc_churn_in_window": 4,
              "code_churn_in_window": int(ratio * 4),
              "subject_code_count": 1,
              "subject_method": "nearest-ancestor",
              "confidence": confidence,
          }
      
      
      def _by_path(result: dict) -> dict[str, dict]:
          return {u["path"]: u for u in result["docs"]}
      
      
      def test_complex_plus_fresh_is_good_contract() -> None:
          """Fresh doc (ratio 0.5) over CCN-30 code -> good_contract, positive value."""
          staleness = _staleness([_doc("pkg/engine/README.md", ratio=0.5)])
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("/repo"))
      
          doc = _by_path(result)["pkg/engine/README.md"]
          # freshness = (2.0 - 0.5) / 2.0 = 0.75 ; doc_value = 30 * 0.75 = 22.5
          assert doc["complexity_summarised"] == 30.0
          assert doc["freshness"] == 0.75
          assert doc["doc_value"] == 22.5
          assert doc["doc_value"] > 0
          assert doc["finding"] == "good_contract"
          assert [u["path"] for u in result["findings"]["good_contracts"]] == [
              "pkg/engine/README.md"
          ]
      
      
      def test_complex_plus_stale_is_lying_map_negative_value() -> None:
          """Stale doc (ratio 6.0) over CCN-30 code -> lying_map, NEGATIVE value."""
          staleness = _staleness([_doc("pkg/engine/README.md", ratio=6.0)])
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("/repo"))
      
          doc = _by_path(result)["pkg/engine/README.md"]
          # freshness = clamp((2 - 6) / 2, -1, 1) = -1.0 ; doc_value = 30 * -1 = -30
          assert doc["freshness"] == -1.0
          assert doc["doc_value"] == -30.0
          assert doc["doc_value"] < 0
          assert doc["finding"] == "lying_map"
          # Slop-doc guard: the recommendation never says "auto-generate".
          rec = doc["recommendation"].lower()
          assert "delete" in rec
          assert "auto-generate" in rec  # ...prefixed by "do not"
          assert "do not auto-generate" in rec
      
      
      def test_degenerate_churn_caps_confidence_and_suppresses_lying_map() -> None:
          """Issue #172: a precise (nearest-ancestor, high-confidence) association over
          a DEGENERATE churn history must not stamp a lying_map. The churn count means
          nothing - confidence encodes association precision, not measurement
          reliability - so the join caps confidence to 'low' (the existing guard then
          suppresses the finding). Same stale ratio and high confidence as the
          lying_map case; only ``churn_degenerate`` differs."""
          staleness = _staleness([_doc("pkg/engine/README.md", ratio=6.0)])
          staleness["churn_degenerate"] = True
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("/repo"))
      
          doc = _by_path(result)["pkg/engine/README.md"]
          # The ratio still computes negative freshness from the inflated churn...
          assert doc["freshness"] == -1.0
          # ...but the measurement is unreliable: confidence is capped and no lie called.
          assert doc["confidence"] == "low"
          assert doc["finding"] is None
          assert result["findings"]["lying_maps"] == []
      
      
      def test_non_degenerate_churn_preserves_high_confidence_lying_map() -> None:
          """Regression guard: with genuine churn variance (churn_degenerate False, the
          default) the same precise association still produces a high-confidence
          lying_map - the fix must not blunt real findings."""
          staleness = _staleness([_doc("pkg/engine/README.md", ratio=6.0)])
          staleness["churn_degenerate"] = False
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("/repo"))
      
          doc = _by_path(result)["pkg/engine/README.md"]
          assert doc["confidence"] == "high"
          assert doc["finding"] == "lying_map"
          assert [u["path"] for u in result["findings"]["lying_maps"]] == [
              "pkg/engine/README.md"
          ]
      
      
      def test_low_confidence_stale_doc_is_not_a_lying_map() -> None:
          """A stale-by-ratio doc whose staleness is low-confidence (subject_method ==
          'repo-baseline') must NOT be classified a lying_map: the ratio is measured
          against repo-wide churn, not the code the doc describes, so a doc edited
          today reads as 'stale' purely because the repo is busy. Mirrors the Layer 0
          stale-hub confidence guard. Same ratio as the lying_map case above, only the
          confidence differs."""
          staleness = _staleness(
              [_doc("pkg/engine/README.md", ratio=6.0, confidence="low")])
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("/repo"))
      
          doc = _by_path(result)["pkg/engine/README.md"]
          # freshness still computes negative from the coarse ratio...
          assert doc["freshness"] == -1.0
          # ...but the low-confidence signal is too coarse to call a lie.
          assert doc["finding"] is None
          assert result["findings"]["lying_maps"] == []
      
      
      def test_complex_plus_no_doc_is_unexplained_complexity() -> None:
          """CCN-30 code with no doc covering it -> unexplained_complexity, value 0."""
          # Only a doc far away that covers nothing complex.
          staleness = _staleness([_doc("docs/unrelated/notes.md", ratio=0.5)])
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("/repo"))
      
          core = _by_path(result)["pkg/engine/core.py"]
          assert core["finding"] == "unexplained_complexity"
          assert core["freshness"] == 0.0
          assert core["doc_value"] == 0.0  # missing -> 0
          assert [u["path"] for u in result["findings"]["unexplained_complexity"]] == [
              "pkg/engine/core.py"
          ]
          # The recommendation forbids auto-generation (slop-doc guard).
          assert "do not auto-generate" in core["recommendation"].lower()
      
      
      def test_trivial_file_has_near_zero_doc_value_and_no_finding() -> None:
          """A doc over CCN-2 code scores ~0 and raises no finding, fresh or stale."""
          staleness = _staleness([_doc("pkg/util/README.md", ratio=0.5)])
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("/repo"))
      
          doc = _by_path(result)["pkg/util/README.md"]
          # complexity_summarised = 2 (helper.py) ; freshness 0.75 -> doc_value 1.5
          assert doc["complexity_summarised"] == 2.0
          assert doc["doc_value"] == 1.5
          assert abs(doc["doc_value"]) < MIN_HIGH_CCN  # negligible vs a real hotspot
          assert doc["finding"] is None
          # The trivial doc appears in no findings bucket. (core.py, which this doc
          # does not cover, is correctly surfaced as unexplained_complexity elsewhere.)
          flagged = {
              u["path"]
              for bucket in result["findings"].values()
              for u in bucket
          }
          assert "pkg/util/README.md" not in flagged
          assert result["findings"]["lying_maps"] == []
          assert result["findings"]["good_contracts"] == []
      
      
      def test_slop_doc_guard_honest_gap_beats_lying_map() -> None:
          """An undocumented unit must score strictly safer than a hollow stale doc."""
          lying = analyze_doc_complexity_join(
              COMPLEXITY_STATS, _staleness([_doc("pkg/engine/README.md", ratio=6.0)]),
              Path("/repo"),
          )
          honest = analyze_doc_complexity_join(
              COMPLEXITY_STATS, _staleness([_doc("docs/unrelated/notes.md", ratio=0.5)]),
              Path("/repo"),
          )
          lying_value = lying["findings"]["lying_maps"][0]["doc_value"]
          honest_value = honest["findings"]["unexplained_complexity"][0]["doc_value"]
          assert honest_value > lying_value  # 0 > -30
      
      
      def test_result_is_json_serialisable() -> None:
          staleness = _staleness([
              _doc("pkg/engine/README.md", ratio=6.0),
              _doc("pkg/util/README.md", ratio=0.5),
          ])
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("/repo"))
          # Round-trips without error and preserves the threshold contract.
          reloaded = json.loads(json.dumps(result))
          assert reloaded["available"] is True
          assert reloaded["high_ccn_threshold"] == max(8.0, MIN_HIGH_CCN)
          assert STALENESS_RATIO_THRESHOLD == 2.0
      
      
      # --- Provenance-aware freshness for generated docs (issue #178) -----------
      
      def _gen_doc(path: str, source_newer, ratio: float = 0.0,
                   confidence: str = "low") -> dict:
          """A generated doc carrying a provenance verdict. ratio is deliberately set
          low/zero (the churn signal is irrelevant once provenance decides freshness)
          and confidence "low" to prove the provenance verdict bypasses the guard."""
          d = _doc(path, ratio, confidence=confidence)
          d["subject_method"] = "repo-baseline"
          d["provenance"] = {
              "method": "frontmatter",
              "sources": ["data/jira.tsv"],
              "generated_by": None,
              "source_newer": source_newer,
          }
          return d
      
      
      def test_generated_doc_source_not_moved_is_not_lying_map() -> None:
          """source_newer False -> freshness +1 -> good_contract, never lying_map,
          even though the churn ratio is low-confidence (repo-baseline). This is the
          issue #178 hard requirement: an accurate generated doc is not a lying map."""
          staleness = _staleness([_gen_doc("pkg/engine/api.md", source_newer=False)])
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("."))
          api = _by_path(result)["pkg/engine/api.md"]
          assert api["freshness"] == 1.0
          assert api["finding"] == "good_contract"
          assert result["findings"]["lying_maps"] == []
      
      
      def test_generated_doc_source_moved_on_is_lying_map() -> None:
          """source_newer True -> freshness -1 -> lying_map over complex code, and the
          repo-baseline low-confidence guard does NOT suppress it (the provenance
          verdict is a direct, high-confidence source comparison)."""
          staleness = _staleness([_gen_doc("pkg/engine/api.md", source_newer=True)])
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("."))
          api = _by_path(result)["pkg/engine/api.md"]
          assert api["freshness"] == -1.0
          assert api["finding"] == "lying_map"
          assert api["confidence"] == "high"
          assert [u["path"] for u in result["findings"]["lying_maps"]] == ["pkg/engine/api.md"]
      
      
      def test_provenance_indeterminate_falls_back_to_ratio() -> None:
          """source_newer None (no usable timestamps) -> the churn ratio decides, so a
          busy-ratio repo-baseline doc stays unclassified under the low-confidence
          guard exactly as before provenance existed."""
          staleness = _staleness([_gen_doc("pkg/engine/api.md", source_newer=None,
                                            ratio=5.0)])
          result = analyze_doc_complexity_join(COMPLEXITY_STATS, staleness, Path("."))
          api = _by_path(result)["pkg/engine/api.md"]
          assert api["freshness"] < 0  # ratio 5 > threshold -> negative
          assert api["finding"] is None  # low-confidence repo-baseline guard holds
      
    • test_doc_graph.py 44.4 KB
      """Tests for the doc link-graph (Layer 0 navigability)."""
      from __future__ import annotations
      
      from pathlib import Path
      
      
      import lib.doc_graph as doc_graph
      from lib.doc_graph import build_doc_graph, group_broken_links
      
      
      def _write(root: Path, rel: str, text: str) -> None:
          p = root / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      def test_empty_repo_is_available_but_zero(tmp_path: Path) -> None:
          r = build_doc_graph(tmp_path)
          assert r.available is True
          assert r.doc_count == 0
      
      
      def test_linked_wiki_builds_edges_and_reachability(tmp_path: Path) -> None:
          _write(tmp_path, "index.md", "# Index\n[[setup]] [a](api.md) [[guide#intro]]")
          _write(tmp_path, "setup.md", "see [[guide]]")
          _write(tmp_path, "guide.md", "back to [[index]] and [src](app.py)")
          _write(tmp_path, "api.md", "[[setup]]")
          _write(tmp_path, "app.py", "print(1)")
          _write(tmp_path, "lonely.md", "I link to nobody")
      
          r = build_doc_graph(tmp_path)
          assert r.doc_count == 5  # app.py is code, not a doc node
          assert r.edge_count >= 5
          # index is a declared MOC and a structural hub (out-degree >= 3)
          assert any(m["path"] == "index.md" and m["is_structural_hub"] for m in r.declared_mocs)
          assert r.moc_named_but_not_wired == []
          # lonely.md has no inbound links and is not an entry -> orphan
          assert "lonely.md" in r.orphans
          # two islands: the linked cluster + lonely
          assert r.island_count == 2
          # reachable from entry (index): everything except lonely
          assert 0.7 <= r.reachability_pct <= 0.85
          assert "lonely.md" in r.unreachable
          # guide.md is reachable, proving the [[guide#intro]] anchor was stripped and
          # still resolved to guide.md (a dangling link would have left it unreachable)
          assert "guide.md" not in r.unreachable
      
      
      def test_doc_to_code_edges_detected(tmp_path: Path) -> None:
          _write(tmp_path, "guide.md", "code is [here](src/app.py)")
          _write(tmp_path, "src/app.py", "x = 1")
          r = build_doc_graph(tmp_path)
          assert {"doc": "guide.md", "code": "src/app.py"} in r.doc_to_code_edges
      
      
      def test_declared_moc_not_wired_is_flagged(tmp_path: Path) -> None:
          # index.md is named like a MOC but links to nothing -> named but not wired.
          _write(tmp_path, "index.md", "# Index\nNo links here.")
          _write(tmp_path, "a.md", "content")
          _write(tmp_path, "b.md", "content")
          r = build_doc_graph(tmp_path)
          assert "index.md" in r.moc_named_but_not_wired
          moc = next(m for m in r.declared_mocs if m["path"] == "index.md")
          assert moc["is_structural_hub"] is False
      
      
      def test_hubs_ranked_by_centrality(tmp_path: Path) -> None:
          # hub.md is pointed to by many docs -> highest PageRank.
          _write(tmp_path, "hub.md", "I am the hub")
          for i in range(4):
              _write(tmp_path, f"leaf{i}.md", "see [hub](hub.md)")
          r = build_doc_graph(tmp_path)
          assert r.hubs[0]["path"] == "hub.md"
          assert r.hubs[0]["in_degree"] == 4
          # full pagerank map exposed for the heatmap, kept off as_dict()
          assert "hub.md" in r.pagerank
          assert "pagerank" not in r.as_dict()
      
      
      def test_wikilink_collision_is_counted_ambiguous(tmp_path: Path) -> None:
          _write(tmp_path, "one/setup.md", "a")
          _write(tmp_path, "two/setup.md", "b")
          _write(tmp_path, "home.md", "[[setup]]")
          r = build_doc_graph(tmp_path)
          assert r.ambiguous_wikilinks >= 1
      
      
      def test_dangling_wikilink_counted(tmp_path: Path) -> None:
          _write(tmp_path, "a.md", "[[does-not-exist]]")
          r = build_doc_graph(tmp_path)
          assert r.dangling_links >= 1
      
      
      def test_vault_detected_at_repo_root(tmp_path: Path) -> None:
          """`.obsidian/` at the scan target -> the repo is the vault root."""
          (tmp_path / ".obsidian").mkdir()
          _write(tmp_path, "note.md", "content")
          r = build_doc_graph(tmp_path)
          assert r.vault_detected is True
      
      
      def test_vault_detected_when_nested_below_repo_root(tmp_path: Path) -> None:
          """A vault kept as a subdirectory of a git repo (`repo/notes/.obsidian/`)
          puts `.obsidian/` below the scan target. The flag must still read true -
          the false negative this guards against silently disabled every downstream
          vault accommodation (#179)."""
          (tmp_path / "notes" / ".obsidian").mkdir(parents=True)
          _write(tmp_path, "notes/note.md", "content")
          r = build_doc_graph(tmp_path)
          assert r.vault_detected is True
      
      
      def test_vault_not_detected_on_plain_repo(tmp_path: Path) -> None:
          """A repo with no `.obsidian/` anywhere reports false."""
          _write(tmp_path, "README.md", "no vault here")
          r = build_doc_graph(tmp_path)
          assert r.vault_detected is False
      
      
      def test_vault_not_detected_for_obsidian_under_excluded_dir(tmp_path: Path) -> None:
          """A `.obsidian/` vendored under a pruned tree (e.g. `node_modules/`) is a
          build/dependency artifact, not this repo's vault - it must not trip the
          flag."""
          (tmp_path / "node_modules" / "pkg" / ".obsidian").mkdir(parents=True)
          _write(tmp_path, "README.md", "real repo, not a vault")
          r = build_doc_graph(tmp_path)
          assert r.vault_detected is False
      
      
      def test_wikilink_inside_fenced_code_block_is_not_counted(tmp_path: Path) -> None:
          """A FORMAT spec or Obsidian-syntax tutorial that *shows* `[[foo]]` as a
          sample inside a fenced code block must not contribute a phantom edge or
          a dangling link. The writer formatted it as code on purpose.
          """
          _write(
              tmp_path,
              "obsidian-skill.md",
              "How to write wikilinks:\n\n```markdown\n[[Note Title]]\n[[wikilinks]]\n```\n",
          )
          r = build_doc_graph(tmp_path)
          assert r.dangling_links == 0
          targets = {bl["target"] for bl in r.broken_links}
          assert "Note Title" not in targets
          assert "wikilinks" not in targets
      
      
      def test_mdlink_inside_fenced_code_block_is_not_counted(tmp_path: Path) -> None:
          """FORMAT specs commonly show `[Ordering](./src/ordering/CONTEXT.md)` as a
          sample of the format they teach. Inside a fence, that's documentation
          syntax, not navigation - it must not show up in broken_links.
          """
          _write(
              tmp_path,
              "context-FORMAT.md",
              "Example layout:\n\n```markdown\n[Ordering](./src/ordering/CONTEXT.md)\n```\n",
          )
          r = build_doc_graph(tmp_path)
          assert r.dangling_links == 0
          targets = {bl["target"] for bl in r.broken_links}
          assert "./src/ordering/CONTEXT.md" not in targets
      
      
      def test_link_inside_inline_code_span_is_not_counted(tmp_path: Path) -> None:
          """Inline-code spans (single backticks) are equally code: `[[wikilinks]]`
          in a sentence is teaching syntax, not navigating.
          """
          _write(
              tmp_path,
              "guide.md",
              "Use the `[[Note Title]]` syntax to link notes. "
              "Markdown form looks like `[label](./file.md)`.\n",
          )
          r = build_doc_graph(tmp_path)
          assert r.dangling_links == 0
      
      
      def test_real_links_outside_code_still_extracted(tmp_path: Path) -> None:
          """Prose-form links to real docs must still build edges. Stripping code
          spans is meant to reduce false positives, not break navigation."""
          _write(tmp_path, "README.md", "see [the guide](./guide.md)\n")
          _write(tmp_path, "guide.md", "real doc")
          r = build_doc_graph(tmp_path)
          assert r.dangling_links == 0
          # Edge must be present.
          edges = {(h["path"], h.get("pagerank", 0)) for h in r.hubs}
          assert any(p == "guide.md" for p, _ in edges) or r.edge_count >= 1
      
      
      def test_doc_graph_honors_user_exclude_dirs(tmp_path: Path) -> None:
          """A user-supplied exclude (from `.assess/config.toml` or CLI) keeps
          docs inside that directory out of the graph entirely - the same
          semantics every other /assess scan applies. See test_assess_core for
          the orchestrator-level integration that loads excludes once and
          threads them everywhere."""
          _write(tmp_path, "README.md", "see [vetted](./regulatory-raw/notes.md)\n")
          _write(tmp_path, "regulatory-raw/notes.md", "ref data note")
      
          # Baseline: both docs are counted.
          assert build_doc_graph(tmp_path).doc_count == 2
      
          # With the exclude: regulatory-raw/notes.md vanishes from the graph.
          r = build_doc_graph(tmp_path, extra_exclude_dirs={"regulatory-raw"})
          assert r.doc_count == 1
      
      
      def test_doc_graph_honors_user_exclude_patterns(tmp_path: Path) -> None:
          """A glob pattern in the user excludes filters by basename. Same
          fnmatch semantics as `EXCLUDE_FILE_PATTERNS`."""
          _write(tmp_path, "README.md", "real")
          _write(tmp_path, "SCRATCH-NOTES.md", "scratch")
      
          assert build_doc_graph(tmp_path).doc_count == 2
          r = build_doc_graph(tmp_path, extra_exclude_patterns=["SCRATCH-*.md"])
          assert r.doc_count == 1
      
      
      def test_excludes_assess_and_vendor_dirs(tmp_path: Path) -> None:
          _write(tmp_path, "README.md", "real doc")
          _write(tmp_path, ".assess/log.md", "our own output")
          _write(tmp_path, "node_modules/pkg/readme.md", "vendored")
          r = build_doc_graph(tmp_path)
          assert r.doc_count == 1
      
      
      def test_excludes_test_fixtures_and_orphan_rate_reflects_it(tmp_path: Path) -> None:
          """Markdown under `**/tests/fixtures/**` is a scanner input, not a repo
          doc, so it must not count toward the doc graph or inflate the orphan rate
          (issue #83). One linked entry doc -> 0% orphans; without the exclusion the
          fixture files would be unreachable orphans and the rate would spike."""
          _write(tmp_path, "README.md", "see [guide](./guide.md)\n")
          _write(tmp_path, "guide.md", "the guide\n")
          # Fixtures that exist only to exercise the detectors - never navigation.
          _write(tmp_path, "skills/assess/tests/fixtures/lean/CLAUDE.md", "fixture")
          _write(tmp_path, "tests/fixtures/monolithic_instructions.md", "fixture")
      
          r = build_doc_graph(tmp_path)
          assert r.doc_count == 2
          assert not any("fixtures" in o for o in r.orphans)
          assert r.orphan_rate == 0.0
      
      
      def test_unrelated_top_level_fixtures_dir_not_excluded(tmp_path: Path) -> None:
          """Only the `tests/fixtures` *sequence* is excluded - a top-level
          `fixtures/` of real content (not preceded by `tests`) still counts."""
          _write(tmp_path, "README.md", "real doc")
          _write(tmp_path, "fixtures/data-model.md", "real architecture doc")
          r = build_doc_graph(tmp_path)
          assert r.doc_count == 2
      
      
      def test_is_excluded_path_helper() -> None:
          from lib.doc_graph import is_excluded_path
      
          assert is_excluded_path(Path("a/tests/fixtures/x.md"))
          assert is_excluded_path(Path("tests/fixtures/x.md"))
          assert is_excluded_path(Path(".assess/log.md"))
          # `fixtures` not preceded by `tests`, and `tests` not followed by `fixtures`.
          assert not is_excluded_path(Path("src/fixtures/x.md"))
          assert not is_excluded_path(Path("tests/unit/x.md"))
          assert not is_excluded_path(Path("tests/x/fixtures/y.md"))
      
      
      def test_graph_object_exposed_for_renderer(tmp_path: Path) -> None:
          """DocGraphResult.graph carries the networkx graph (the SVG renderer needs
          the full edge list, which as_dict doesn't serialise)."""
          _write(tmp_path, "a.md", "[[b]]")
          _write(tmp_path, "b.md", "x")
          r = build_doc_graph(tmp_path)
          assert r.graph is not None
          assert set(r.graph.nodes()) == {"a.md", "b.md"}
          assert "graph" not in r.as_dict()
      
      
      def test_is_repo_file_rejects_symlink_escape(tmp_path: Path) -> None:
          import os
          from lib.doc_graph import is_repo_file
          repo = tmp_path / "repo"
          repo.mkdir()
          outside = tmp_path / "outside.md"
          outside.write_text("not ours", encoding="utf-8")
          (repo / "real.md").write_text("ours", encoding="utf-8")
          os.symlink(outside, repo / "link.md")  # symlink inside repo -> outside
          rr = repo.resolve()
          assert is_repo_file(repo / "real.md", rr, None) is True
          assert is_repo_file(repo / "link.md", rr, None) is False  # resolves outside repo
      
      
      def test_untracked_files_excluded_in_git_repo(git_repo) -> None:
          """A contributor's untracked personal doc is not part of the repo and must
          not be scanned (the external-CLAUDE.md class of false positive)."""
          repo, commit = git_repo
          (repo / "README.md").write_text("# Home\n[[guide]]", encoding="utf-8")
          (repo / "guide.md").write_text("tracked", encoding="utf-8")
          commit("docs")
          (repo / "personal.md").write_text("my private notes", encoding="utf-8")  # untracked
      
          r = build_doc_graph(repo)
          nodes = set(r.graph.nodes())
          assert {"README.md", "guide.md"} <= nodes
          assert "personal.md" not in nodes
      
      
      def test_radial_shells_and_classify(tmp_path: Path) -> None:
          """The headline claim — reachable = central, unreachable = banished to the
          rim — is the BFS/shell logic; lock it in deterministically (no rendering)."""
          from lib.doc_graph import classify_node, radial_shells
          _write(tmp_path, "index.md", "# Index\n[[a]]")
          _write(tmp_path, "a.md", "[[b]]")
          _write(tmp_path, "b.md", "leaf")
          _write(tmp_path, "lonely.md", "nobody links here")
          r = build_doc_graph(tmp_path)
          assert r.entry_points == ["index.md"]
      
          shells = radial_shells(r.graph, set(r.entry_points))
          assert shells[0] == ["index.md"]          # entry at the centre
          assert "a.md" in shells[1]                 # 1 hop out
          assert "b.md" in shells[2]                 # 2 hops out
          assert "lonely.md" in shells[-1]           # unreachable -> outer rim
          lonely_ring = next(i for i, s in enumerate(shells) if "lonely.md" in s)
          assert lonely_ring > 2                      # past every reachable shell
      
          entries, unreachable, orphans = set(r.entry_points), set(r.unreachable), set(r.orphans)
          assert classify_node("index.md", entries, unreachable, orphans) == "entry"
          assert classify_node("a.md", entries, unreachable, orphans) == "reachable"
          assert classify_node("lonely.md", entries, unreachable, orphans) == "orphan"
      
      
      def test_broken_links_recorded_as_ghosts(tmp_path: Path) -> None:
          """Links to files that don't exist are captured (wikilink + CommonMark) so
          the renderer can draw them as ghost nodes."""
          _write(tmp_path, "a.md", "[[ghost-note]] and [also](./missing.md) and [ok](b.md)")
          _write(tmp_path, "b.md", "real")
          r = build_doc_graph(tmp_path).as_dict()
          targets = {bl["target"] for bl in r["broken_links"]}
          assert "ghost-note" in targets          # dangling wikilink
          assert "./missing.md" in targets        # broken CommonMark link
          assert r["dangling_links"] == len(r["broken_links"])
          # the valid link to b.md is not a ghost
          assert not any(bl["target"] == "b.md" for bl in r["broken_links"])
      
      
      def test_directory_link_not_flagged_broken(tmp_path: Path) -> None:
          """A link to an existing folder is valid navigation, not a broken link."""
          (tmp_path / "guides").mkdir()
          (tmp_path / "guides" / "x.md").write_text("hi", encoding="utf-8")
          _write(tmp_path, "a.md", "see [folder](guides/) and [ghost](nope.md)")
          r = build_doc_graph(tmp_path).as_dict()
          targets = {bl["target"] for bl in r["broken_links"]}
          assert "guides/" not in targets   # existing directory -> not broken
          assert "nope.md" in targets       # genuinely missing -> ghost
      
      
      def test_missing_xrefs_named_not_linked(tmp_path: Path) -> None:
          """A doc that names another doc's filename in prose but never links it."""
          _write(tmp_path, "overview.md", "The payments.md flow is described elsewhere.")
          _write(tmp_path, "payments.md", "payments")
          _write(tmp_path, "linked.md", "see [payments](payments.md)")  # already linked
          r = build_doc_graph(tmp_path).as_dict()
          pairs = {(x["from"], x["to"]) for x in r["missing_xrefs"]}
          assert ("overview.md", "payments.md") in pairs       # named, not linked
          assert ("linked.md", "payments.md") not in pairs     # already linked -> not missing
      
      
      def test_group_broken_links_merges_same_missing_file() -> None:
          """Several links to the same missing file collapse to one ghost they share."""
          broken = [
              {"from": "README.md", "target": "CLAUDE.md", "kind": "mdlink"},
              {"from": "CONTRIBUTING.md", "target": "CLAUDE.md", "kind": "mdlink"},
          ]
          groups = group_broken_links(broken)
          assert len(groups) == 1
          assert groups[0]["target"] == "CLAUDE.md"
          assert sorted(groups[0]["sources"]) == ["CONTRIBUTING.md", "README.md"]
      
      
      def test_group_broken_links_resolves_relative_targets() -> None:
          """Targets written differently but pointing at distinct paths stay separate;
          the same resolved path merges even when the link text differs."""
          broken = [
              {"from": "README.md", "target": "CLAUDE.md", "kind": "mdlink"},
              # resolves to docs/CLAUDE.md, not the root CLAUDE.md -> separate ghost
              {"from": "docs/guide.md", "target": "CLAUDE.md", "kind": "mdlink"},
              # ../CLAUDE.md from docs/ resolves back to root CLAUDE.md -> merges with README
              {"from": "docs/other.md", "target": "../CLAUDE.md", "kind": "mdlink"},
          ]
          groups = {g["target"]: sorted(g["sources"]) for g in group_broken_links(broken)}
          assert groups["CLAUDE.md"] == ["README.md", "docs/other.md"]
          assert groups["docs/CLAUDE.md"] == ["docs/guide.md"]
      
      
      def test_group_broken_links_merges_root_absolute_spelling() -> None:
          """A root-absolute link (/CLAUDE.md) and a plain one (CLAUDE.md) at the same
          missing root file must merge — they only differ in spelling. Regression for
          the leading-slash key mismatch."""
          broken = [
              {"from": "README.md", "target": "CLAUDE.md", "kind": "mdlink"},
              {"from": "docs/guide.md", "target": "/CLAUDE.md", "kind": "mdlink"},
          ]
          groups = group_broken_links(broken)
          assert len(groups) == 1
          assert groups[0]["target"] == "CLAUDE.md"
          assert sorted(groups[0]["sources"]) == ["README.md", "docs/guide.md"]
      
      
      def test_group_broken_links_wikilink_and_mdlink_do_not_merge() -> None:
          """Documented limit: a wikilink ([[CLAUDE]]) and a markdown link (CLAUDE.md)
          to the same missing file live in different resolution domains and stay
          separate. Pinned so the behaviour is intentional, not accidental."""
          broken = [
              {"from": "a.md", "target": "CLAUDE", "kind": "wikilink"},
              {"from": "b.md", "target": "CLAUDE.md", "kind": "mdlink"},
          ]
          keys = {g["target"] for g in group_broken_links(broken)}
          assert keys == {"CLAUDE", "CLAUDE.md"}
      
      
      def test_group_broken_links_wikilinks_key_by_name() -> None:
          """Wikilinks resolve by note name globally, so they key on the bare name
          regardless of the source directory."""
          broken = [
              {"from": "a.md", "target": "ghost-note", "kind": "wikilink"},
              {"from": "deep/b.md", "target": "ghost-note", "kind": "wikilink"},
          ]
          groups = group_broken_links(broken)
          assert len(groups) == 1
          assert groups[0]["target"] == "ghost-note"
          assert sorted(groups[0]["sources"]) == ["a.md", "deep/b.md"]
      
      
      def test_group_broken_links_orders_by_source_count() -> None:
          """The most-referenced missing file comes first so it renders first."""
          broken = [
              {"from": "x.md", "target": "rare.md", "kind": "mdlink"},
              {"from": "a.md", "target": "popular.md", "kind": "mdlink"},
              {"from": "b.md", "target": "popular.md", "kind": "mdlink"},
          ]
          groups = group_broken_links(broken)
          assert [g["target"] for g in groups] == ["popular.md", "rare.md"]
      
      
      def test_degrades_when_networkx_unavailable(tmp_path: Path, monkeypatch) -> None:
          _write(tmp_path, "README.md", "[[a]]")
          _write(tmp_path, "a.md", "x")
          monkeypatch.setattr(doc_graph, "_NETWORKX_AVAILABLE", False)
          r = build_doc_graph(tmp_path)
          assert r.available is False
          assert "networkx" in r.reason
          # must not crash; as_dict is still serialisable
          assert r.as_dict()["available"] is False
      
      
      # ---- vault-native navigation (issue #176) ---------------------------------
      
      def test_base_hub_links_folder_notes_no_longer_orphaned(tmp_path: Path) -> None:
          # A `.base` viewing `_jira` is the only navigation surface: no static links
          # anywhere. Before #176 every note scored as an orphan / unreachable.
          _write(tmp_path, "tasks.base",
                 'filters:\n  and:\n    - file.inFolder("_jira")\n    - file.ext == "md"\n'
                 'views:\n  - type: table\n    name: All\n')
          for i in range(5):
              _write(tmp_path, f"_jira/ABC-{i}.md", f"# Ticket {i}\nstatus: open\n")
      
          r = build_doc_graph(tmp_path)
          # The .base hub is a node + entry point; the 5 notes are its descendants.
          assert "tasks.base" in r.entry_points
          assert r.edge_count == 5
          assert r.orphans == []           # every note has the hub as an inbound link
          assert r.orphan_rate == 0.0
          assert r.reachability_pct == 1.0  # all reachable from the hub entry
      
      
      def test_base_that_selects_nothing_is_not_added(tmp_path: Path) -> None:
          # A `.base` whose query matches no note must not appear as an orphan hub node.
          _write(tmp_path, "empty.base", '- file.inFolder("does-not-exist")\n')
          _write(tmp_path, "README.md", "# Home\n")
          r = build_doc_graph(tmp_path)
          assert "empty.base" not in r.entry_points
          assert r.doc_count == 1  # only README.md; the empty base is not a node
      
      
      def test_dataview_hub_links_notes_from_its_note(tmp_path: Path) -> None:
          # A `dataview` block inside a hub note (itself reachable from the README)
          # surfaces the `_archive` folder; those notes become reachable, not orphans.
          _write(tmp_path, "README.md", "Start at the [hub](hub.md)")
          _write(tmp_path, "hub.md",
                 "# Hub\n\n```dataview\nLIST\nFROM \"_archive\"\n```\n")
          for i in range(3):
              _write(tmp_path, f"_archive/note-{i}.md", f"old note {i}")
      
          r = build_doc_graph(tmp_path)
          # hub.md -> each archive note (3) plus README -> hub (1)
          assert r.edge_count == 4
          assert r.orphans == []
          assert r.reachability_pct == 1.0
      
      
      def test_dataview_tag_hub_uses_frontmatter(tmp_path: Path) -> None:
          _write(tmp_path, "README.md", "see [hub](hub.md)")
          _write(tmp_path, "hub.md", "```dataview\nLIST FROM #project\n```")
          _write(tmp_path, "p1.md", "---\ntags: [project]\n---\nbody")
          _write(tmp_path, "p2.md", "---\ntags: [other]\n---\nbody")
      
          r = build_doc_graph(tmp_path)
          # hub -> p1 (tagged project) only; p2 is not tagged so stays an orphan.
          assert "p1.md" not in r.orphans
          assert "p2.md" in r.orphans
      
      
      # ---- non-navigational URI scheme exclusions (issue #227) -------------------
      
      def test_tel_mdlink_not_counted_broken(tmp_path: Path) -> None:
          """[text](tel:+1-555-1234) is a phone-dialer link. It is not a broken
          navigation edge -- the file `tel:+1-555-1234` does not exist, and that
          is expected. The broken-link counter must not count it."""
          _write(tmp_path, "contact.md", "Call us at [phone](tel:+1-555-1234)")
          r = build_doc_graph(tmp_path)
          assert r.dangling_links == 0
          targets = {bl["target"] for bl in r.broken_links}
          assert not any("tel:" in t for t in targets)
      
      
      def test_mailto_mdlink_not_counted_broken(tmp_path: Path) -> None:
          """[text](mailto:hello@example.com) is an email link, not a broken file
          reference. The broken-link counter must not count it."""
          _write(tmp_path, "contact.md", "Email us at [email](mailto:hello@example.com)")
          r = build_doc_graph(tmp_path)
          assert r.dangling_links == 0
          targets = {bl["target"] for bl in r.broken_links}
          assert not any("mailto:" in t for t in targets)
      
      
      def test_other_non_http_scheme_mdlink_not_counted_broken(tmp_path: Path) -> None:
          """Non-navigational URI schemes beyond tel:/mailto: (sms:, callto:, etc.)
          are not file references and must not contribute broken links."""
          _write(
              tmp_path,
              "contact.md",
              "Text us at [sms](sms:+1-555-1234) or via [Skype](skype:username)",
          )
          r = build_doc_graph(tmp_path)
          assert r.dangling_links == 0
          targets = {bl["target"] for bl in r.broken_links}
          assert not any("sms:" in t or "skype:" in t for t in targets)
      
      
      def test_tel_wikilink_not_counted_broken(tmp_path: Path) -> None:
          """[[tel:+1-555-1234]] is a non-navigational URI in wikilink form. It
          must not be counted as a broken wikilink to a missing note."""
          _write(tmp_path, "contact.md", "Dial [[tel:+1-555-1234]] for support")
          r = build_doc_graph(tmp_path)
          assert r.dangling_links == 0
          targets = {bl["target"] for bl in r.broken_links}
          assert not any("tel:" in t for t in targets)
      
      
      def test_mailto_wikilink_not_counted_broken(tmp_path: Path) -> None:
          """[[mailto:user@example.com]] in wikilink form must not count as a broken
          note reference."""
          _write(tmp_path, "contact.md", "Write to [[mailto:user@example.com]]")
          r = build_doc_graph(tmp_path)
          assert r.dangling_links == 0
          targets = {bl["target"] for bl in r.broken_links}
          assert not any("mailto:" in t for t in targets)
      
      
      def test_uri_scheme_inside_code_fence_not_counted(tmp_path: Path) -> None:
          """A tel: or mailto: link shown as an example inside a fenced code block
          (e.g. in a FORMAT spec or tutorial) must not count -- it is documentation
          syntax, not a navigation edge."""
          _write(
              tmp_path,
              "guide.md",
              "Contact links look like:\n\n```markdown\n"
              "[phone](tel:+1-555-1234)\n"
              "[email](mailto:hello@example.com)\n"
              "```\n",
          )
          r = build_doc_graph(tmp_path)
          assert r.dangling_links == 0
          targets = {bl["target"] for bl in r.broken_links}
          assert not any("tel:" in t or "mailto:" in t for t in targets)
      
      
      def test_real_file_links_still_flagged_after_scheme_exclusions(tmp_path: Path) -> None:
          """URI-scheme exclusions must not accidentally suppress genuine broken
          relative-path links. A link to a missing file must still be flagged."""
          _write(tmp_path, "a.md", "[gone](missing-file.md) and [phone](tel:555-1234)")
          r = build_doc_graph(tmp_path)
          targets = {bl["target"] for bl in r.broken_links}
          assert "missing-file.md" in targets
          assert not any("tel:" in t for t in targets)
      
      
      # --- Raw-source-tree exclusion (issue #225) -------------------------------
      
      def _curated_wiki(root: Path) -> None:
          """A small, well-linked curated wiki: index hub + three linked notes."""
          _write(root, "index.md", "# Index\n[[setup]] [[guide]] [[api]]")
          _write(root, "setup.md", "see [[guide]]")
          _write(root, "guide.md", "back to [[index]]")
          _write(root, "api.md", "[[setup]]")
      
      
      def _raw_export(root: Path, subdir: str, n: int) -> None:
          """A raw-source dump: ``n`` link-isolated docs each carrying a
          machine-extracted (mailto:/tel:) link, the SAR-export fingerprint."""
          for i in range(n):
              _write(
                  root, f"{subdir}/msg-{i:03d}.md",
                  f"From: sender{i}@example.com\n"
                  f"Contact [email](mailto:user{i}@example.com) or [call](tel:+1-555-{i:04d}).\n"
                  "Body text extracted from the original message.\n",
              )
      
      
      def test_raw_source_tree_excluded_from_metrics(tmp_path: Path) -> None:
          _curated_wiki(tmp_path)
          _raw_export(tmp_path, "sar-export", 14)
          r = build_doc_graph(tmp_path)
      
          # The raw subtree is named with its file count.
          assert r.excluded_raw_trees == [{"path": "sar-export", "file_count": 14}]
          assert r.raw_source_doc_count == 14
      
          # Curated metrics exclude the raw docs: none of the raw files appear as
          # orphans, and the curated layer is small + well-connected.
          assert not any(o.startswith("sar-export/") for o in r.orphans)
          assert r.curated_doc_count == 4
          assert r.doc_count == 4  # headline doc_count is the curated layer
          # Orphan rate over the curated wiki is low, not the ~78% the raw dump
          # would have produced if counted.
          assert r.orphan_rate <= 0.25
          # The raw layer's own orphan rate is reported separately and is high.
          assert r.raw_source_orphan_rate >= 0.9
      
      
      def test_no_raw_tree_is_unaffected(tmp_path: Path) -> None:
          _curated_wiki(tmp_path)
          _write(tmp_path, "lonely.md", "I link to nobody")
          r = build_doc_graph(tmp_path)
          assert r.excluded_raw_trees == []
          assert r.raw_source_doc_count == 0
          assert r.raw_source_broken_links == 0
          assert r.curated_doc_count == r.doc_count == 5
          # lonely.md is still a genuine orphan - not swept up by raw detection.
          assert "lonely.md" in r.orphans
      
      
      def test_raw_broken_links_excluded_from_headline(tmp_path: Path) -> None:
          _curated_wiki(tmp_path)
          # Raw docs that also carry a broken relative link: the broken link must be
          # attributed to the raw layer, not the curated headline count.
          for i in range(14):
              _write(
                  tmp_path, f"dump/msg-{i:03d}.md",
                  f"[email](mailto:user{i}@example.com) and [missing](./ghost-{i}.md)\n",
              )
          r = build_doc_graph(tmp_path)
          assert r.excluded_raw_trees == [{"path": "dump", "file_count": 14}]
          # No curated broken link points at a raw ghost target.
          assert not any(bl["from"].startswith("dump/") for bl in r.broken_links)
          assert r.raw_source_broken_links >= 14
      
      
      def test_isolated_curated_folder_not_excluded(tmp_path: Path) -> None:
          # A folder of hand-written standalone notes (link-isolated but NO machine
          # fingerprint) must not be mistaken for a raw dump.
          _curated_wiki(tmp_path)
          for i in range(14):
              _write(tmp_path, f"notes/note-{i:03d}.md", "A standalone hand-written note.\n")
          r = build_doc_graph(tmp_path)
          assert r.excluded_raw_trees == []
          assert any(o.startswith("notes/") for o in r.orphans)
      
      
      def _notes_and_wiki(root: Path) -> None:
          """50 plan notes under one backlog index, beside a nine-page wiki the
          README links seven of."""
          for i in range(1, 51):
              _write(root, f"notes/plan_{i:02d}.md", f"# plan {i:02d}\n")
          _write(root, "notes/backlog.md", "".join(
              f"- [plan {i:02d}](plan_{i:02d}.md)\n" for i in range(1, 51)
          ))
          pages = "architecture deploy testing security glossary onboarding releases attic scratch".split()
          for w in pages:
              _write(root, f"wiki/{w}.md", f"# {w}\n")
          _write(root, "README.md", "# Home\n[backlog](notes/backlog.md)\n" + "".join(
              f"[{w}](wiki/{w}.md)\n" for w in pages[:7]
          ))
      
      
      def test_working_notes_tree_excluded_from_headline(tmp_path: Path) -> None:
          _notes_and_wiki(tmp_path)
          _write(tmp_path, "notes/plan_03.md", "[ghost](./missing.md)\n")
          d = build_doc_graph(tmp_path).as_dict()
          assert d["excluded_working_notes_trees"] == [{"path": "notes", "file_count": 51}]
          assert d["working_notes_doc_count"] == 51
          assert (d["doc_count"], d["orphan_rate"], d["reachability_pct"]) == (10, 0.2, 0.8)
          assert not any(h["path"].startswith("notes/") for h in d["hubs"])
          # The notes layer's own figures are reported beside the headline.
          assert d["working_notes_orphan_rate"] == 0.0
          assert d["working_notes_broken_links"] == 1
          assert d["dangling_links"] == 0
          assert d["excluded_raw_trees"] == []
      
      
      def test_base_hub_beside_notes_is_not_a_notes_member(tmp_path: Path) -> None:
          # A vault-wide .base stored in notes/ selects the wiki pages. It is not a
          # doc, so it neither joins the tree's count nor leaves the headline graph,
          # and the wiki pages it surfaces keep their inbound edge.
          _notes_and_wiki(tmp_path)
          _write(tmp_path, "notes/pages.base",
                 'filters:\n  and:\n    - file.inFolder("wiki")\n    - file.ext == "md"\n'
                 'views:\n  - type: table\n    name: All\n')
          d = build_doc_graph(tmp_path).as_dict()
          assert d["excluded_working_notes_trees"] == [{"path": "notes", "file_count": 51}]
          assert d["working_notes_doc_count"] == 51
          assert not any(o.startswith("wiki/") for o in d["orphans"])
      
      
      def test_no_working_notes_tree_keys_present_and_empty(tmp_path: Path) -> None:
          _curated_wiki(tmp_path)
          d = build_doc_graph(tmp_path).as_dict()
          assert d["excluded_working_notes_trees"] == []
          assert d["working_notes_doc_count"] == 0
          assert d["working_notes_orphan_rate"] == 0.0
          assert d["working_notes_broken_links"] == 0
      
      
      def test_raw_tree_is_not_also_a_working_notes_tree(tmp_path: Path) -> None:
          _curated_wiki(tmp_path)
          _raw_export(tmp_path, "sar-export", 30)
          r = build_doc_graph(tmp_path)
          assert r.excluded_raw_trees == [{"path": "sar-export", "file_count": 30}]
          assert r.excluded_working_notes_trees == []
      
      
      # --- Reference edges (backticked doc paths) --------------------------------
      
      
      def _edges(r) -> list[list]:
          return sorted([u, v, d.get("kind")] for u, v, d in r.graph.edges(data=True))
      
      
      def test_reference_edge_from_backticked_existing_path(tmp_path: Path) -> None:
          """A backticked token that resolves to an existing doc is a reference edge;
          one that resolves to nothing adds no edge and no node. Markdown links keep
          kind link."""
          _write(
              tmp_path, "CLAUDE.md",
              "# Entry\nSee `docs/arch.md` and `docs/missing.md` and `not-a-path`, "
              "then [guide](guide.md).\n",
          )
          _write(tmp_path, "docs/arch.md", "# Arch")
          _write(tmp_path, "guide.md", "# Guide")
          r = build_doc_graph(tmp_path)
          assert _edges(r) == [
              ["CLAUDE.md", "docs/arch.md", "reference"],
              ["CLAUDE.md", "guide.md", "link"],
          ]
          assert "docs/missing.md" not in r.graph
      
      
      def test_reference_edge_resolves_relative_to_the_citing_doc(tmp_path: Path) -> None:
          _write(tmp_path, "README.md", "[docs](docs/index.md)")
          _write(tmp_path, "docs/index.md", "Read `setup.md` next.")
          _write(tmp_path, "docs/setup.md", "# Setup")
          r = build_doc_graph(tmp_path)
          assert ["docs/index.md", "docs/setup.md", "reference"] in _edges(r)
      
      
      def test_reference_inside_fence_adds_no_edge(tmp_path: Path) -> None:
          _write(tmp_path, "README.md", "```\n`guide.md`\n```\n")
          _write(tmp_path, "guide.md", "# Guide")
          r = build_doc_graph(tmp_path)
          assert _edges(r) == []
      
      
      def test_link_kind_wins_when_a_doc_both_links_and_cites(tmp_path: Path) -> None:
          _write(tmp_path, "README.md", "Read `guide.md`, or [the guide](guide.md).")
          _write(tmp_path, "guide.md", "# Guide")
          r = build_doc_graph(tmp_path)
          assert _edges(r) == [["README.md", "guide.md", "link"]]
      
      
      def test_cited_claude_file_becomes_a_reachable_node(tmp_path: Path) -> None:
          """A `.claude/` doc that a reference names is a node and reachable; an
          uncited one stays excluded. The headline figures count reference edges;
          the link-only figures sit beside them."""
          _write(tmp_path, "CLAUDE.md", "# Entry\nOpen `.claude/skills/x/SKILL.md` first.\n")
          _write(tmp_path, ".claude/skills/x/SKILL.md", "# x")
          _write(tmp_path, ".claude/skills/y/SKILL.md", "# y")
          _write(tmp_path, "docs/lonely.md", "# lonely")
          r = build_doc_graph(tmp_path)
          d = r.as_dict()
          assert sorted(r.graph.nodes()) == [
              ".claude/skills/x/SKILL.md", "CLAUDE.md", "docs/lonely.md",
          ]
          assert d["unreachable"] == ["docs/lonely.md"]
          assert d["doc_count"] == 3
          assert d["orphan_rate"] == 0.333
          assert d["reachability_pct"] == 0.667
          assert d["link_only_reachability_pct"] < d["reachability_pct"]
          assert d["link_only_orphan_rate"] > d["orphan_rate"]
      
      
      def test_cited_claude_doc_is_parsed_for_its_own_edges(tmp_path: Path) -> None:
          _write(tmp_path, "CLAUDE.md", "Open `.claude/skills/x/SKILL.md`.")
          _write(tmp_path, ".claude/skills/x/SKILL.md", "See [ref](../../../docs/ref.md).")
          _write(tmp_path, "docs/ref.md", "# Ref")
          r = build_doc_graph(tmp_path)
          assert [".claude/skills/x/SKILL.md", "docs/ref.md", "link"] in _edges(r)
          assert r.unreachable == []
      
      
      def test_reference_edge_clears_missing_xref(tmp_path: Path) -> None:
          _write(tmp_path, "CLAUDE.md", "# Entry\nRead `guide.md` before editing.\n")
          _write(tmp_path, "guide.md", "# Guide")
          r = build_doc_graph(tmp_path)
          assert _edges(r) == [["CLAUDE.md", "guide.md", "reference"]]
          assert r.as_dict()["missing_xrefs"] == []
      
      
      def test_link_only_figures_equal_headline_without_references(tmp_path: Path) -> None:
          _write(tmp_path, "README.md", "[a](a.md)")
          _write(tmp_path, "a.md", "# A")
          _write(tmp_path, "b.md", "# B")
          d = build_doc_graph(tmp_path).as_dict()
          assert d["link_only_orphan_rate"] == d["orphan_rate"]
          assert d["link_only_reachability_pct"] == d["reachability_pct"]
      
      
      def test_reference_inside_tilde_or_long_fence_adds_no_edge(tmp_path: Path) -> None:
          """CommonMark fences: a tilde fence, and a four-backtick fence holding a
          shorter backtick run, both hide their content from the reference pass."""
          _write(
              tmp_path, "README.md",
              "~~~\n`a.md`\n~~~\n\n````\n```\n`b.md`\n```\n````\n",
          )
          _write(tmp_path, "a.md", "# A")
          _write(tmp_path, "b.md", "# B")
          r = build_doc_graph(tmp_path)
          assert _edges(r) == []
      
      
      def test_bare_basename_resolves_across_the_tree_when_unique(tmp_path: Path) -> None:
          """A bare basename with no doc-relative match falls back to the one doc in
          the tree with that name."""
          _write(tmp_path, "CLAUDE.md", "# Entry\nSee `setup.md`.\n")
          _write(tmp_path, "docs/guides/setup.md", "# Setup")
          r = build_doc_graph(tmp_path)
          assert _edges(r) == [["CLAUDE.md", "docs/guides/setup.md", "reference"]]
      
      
      def test_ambiguous_bare_basename_adds_no_edge(tmp_path: Path) -> None:
          """Two docs share the cited basename: the citation names neither, so the
          basename fallback adds nothing rather than spraying edges."""
          _write(tmp_path, "README.md", "Read `SKILL.md`.")
          _write(tmp_path, "skills/a/SKILL.md", "# a")
          _write(tmp_path, "skills/b/SKILL.md", "# b")
          r = build_doc_graph(tmp_path)
          assert _edges(r) == []
      
      
      def test_cited_excluded_doc_guards(tmp_path: Path) -> None:
          """A cited `.claude/` doc joins the graph only when every other exclusion
          lets it: no parent-dir escape, no user exclude, tracked, inside scope."""
          _write(tmp_path, ".claude/skills/x/SKILL.md", "# x")
          _write(tmp_path, "docs/a.md", "# a")
          target = (tmp_path / ".claude/skills/x/SKILL.md").resolve()
      
          def cite(rel_path, tracked=None, scope=None, dirs=None, pats=None):
              return doc_graph._cited_excluded_doc(
                  rel_path, tmp_path, tracked, scope, dirs or set(), pats or [],
              )
      
          assert cite(".claude/skills/x/SKILL.md") == target
          assert cite(".claude/../.claude/skills/x/SKILL.md") is None
          assert cite(".claude/skills/x/SKILL.md", dirs={"x"}) is None
          assert cite(".claude/skills/x/SKILL.md", pats=["SKILL.md"]) is None
          assert cite(".claude/skills/x/SKILL.md", tracked=frozenset()) is None
          assert cite(".claude/skills/x/SKILL.md", scope=tmp_path / "docs") is None
          assert cite("docs/a.md") is None  # not under .claude: the walk owns it
      
      
      def test_link_reaches_cited_claude_doc_from_a_doc_read_earlier(tmp_path: Path) -> None:
          """References settle before the link pass, so a doc walked before the
          citing doc still links to (and wikilinks to) the cited `.claude/` doc."""
          _write(tmp_path, "AAA.md", "[x](.claude/skills/x/SKILL.md) and [[notes]]")
          _write(tmp_path, "CLAUDE.md", "Open `.claude/skills/x/SKILL.md` and `.claude/notes.md`.")
          _write(tmp_path, ".claude/skills/x/SKILL.md", "# x")
          _write(tmp_path, ".claude/notes.md", "# notes")
          r = build_doc_graph(tmp_path)
          edges = _edges(r)
          assert ["AAA.md", ".claude/skills/x/SKILL.md", "link"] in edges
          assert ["AAA.md", ".claude/notes.md", "link"] in edges
          assert r.as_dict()["dangling_links"] == 0
      
      
      def test_tilde_fenced_link_sample_is_not_a_broken_link(tmp_path: Path) -> None:
          """The link harvest uses the same CommonMark fence parser as references: a
          wikilink sample in a tilde fence is neither an edge nor a ghost."""
          _write(tmp_path, "README.md", "~~~\n[[nowhere]] and [x](gone.md)\n~~~\n")
          r = build_doc_graph(tmp_path)
          assert r.as_dict()["dangling_links"] == 0
          assert _edges(r) == []
      
      
      def test_reference_inside_indented_fence_adds_no_edge(tmp_path: Path) -> None:
          """A fence nested under a list item sits four or more spaces in; its
          content is still a sample, not a citation."""
          _write(tmp_path, "README.md", "- step\n\n      ```\n      `docs/arch.md`\n      ```\n")
          _write(tmp_path, "docs/arch.md", "# Arch")
          r = build_doc_graph(tmp_path)
          assert _edges(r) == []
      
      
      def test_root_level_mdx_citation_is_a_reference_edge(tmp_path: Path) -> None:
          """Every doc extension the graph walks is citable, with or without a slash."""
          _write(tmp_path, "README.md", "See `guide.mdx` and `docs/intro.markdown`.")
          _write(tmp_path, "guide.mdx", "# Guide")
          _write(tmp_path, "docs/intro.markdown", "# Intro")
          r = build_doc_graph(tmp_path)
          assert _edges(r) == [
              ["README.md", "docs/intro.markdown", "reference"],
              ["README.md", "guide.mdx", "reference"],
          ]
      
      
      def test_reference_inside_container_prefixed_fence_adds_no_edge(tmp_path: Path) -> None:
          """A fence behind a blockquote or list-item marker hides its body, and its
          indented closer does not reopen a fence that swallows the rest of the doc."""
          _write(
              tmp_path, "README.md",
              "> ```\n> `a.md` [[nowhere]]\n> ```\n\n"
              "- ~~~\n  `b.md`\n  ~~~\n\n"
              "Then [c](c.md).\n",
          )
          for name in ("a.md", "b.md", "c.md"):
              _write(tmp_path, name, "# x")
          r = build_doc_graph(tmp_path)
          assert _edges(r) == [["README.md", "c.md", "link"]]
          assert r.as_dict()["dangling_links"] == 0
      
      
      def test_root_level_exact_name_beats_a_same_named_doc_elsewhere(tmp_path: Path) -> None:
          """A bare name that is a root-level doc resolves there, even when a doc of
          the same name sits deeper in the tree."""
          _write(tmp_path, "docs/x/index.md", "See `CHANGELOG.md`.")
          _write(tmp_path, "CHANGELOG.md", "# root")
          _write(tmp_path, "pkg/CHANGELOG.md", "# pkg")
          r = build_doc_graph(tmp_path)
          assert ["docs/x/index.md", "CHANGELOG.md", "reference"] in _edges(r)
          assert not any(e[1] == "pkg/CHANGELOG.md" for e in _edges(r))
      
      
      def test_link_only_figures_share_the_headline_entry_points(tmp_path: Path, monkeypatch) -> None:
          """The link-only pass is handed the headline's entry points instead of
          re-picking them from link-only PageRank, so the two figures differ only in
          their edge set. Without it, a repo with no conventional entry doc could
          measure the two from different roots."""
          calls: list = []
          real = doc_graph._derive_signals
      
          def spy(**kw):
              out = real(**kw)
              calls.append((kw.get("entries"), out.entry_points))
              return out
      
          monkeypatch.setattr(doc_graph, "_derive_signals", spy)
          _write(tmp_path, "hub-a.md", "`n1.md` `n2.md`")
          _write(tmp_path, "hub-b.md", "[1](m1.md)")
          for name in ("n1.md", "n2.md", "m1.md"):
              _write(tmp_path, name, "# x")
          build_doc_graph(tmp_path)
          (headline_in, headline_entries), (link_only_in, link_only_entries) = calls
          assert headline_in is None
          assert link_only_in == headline_entries == link_only_entries
      
      
      # --- directory_breakdown (issue #365) ----------------------------------------
      
      
      def _two_doc_dirs(root: Path) -> None:
          _write(root, "README.md", "# Home\n[a](docs/a.md) [g1](guides/g1.md)\n")
          _write(root, "docs/a.md", "# a\n")
          _write(root, "docs/b.md", "# b\n[gone](missing.md)\n")
          for n in (1, 2, 3):
              _write(root, f"guides/g{n}.md", f"# g{n}\n")
      
      
      def test_directory_breakdown_counts_per_top_level_directory(tmp_path: Path) -> None:
          _two_doc_dirs(tmp_path)
          d = build_doc_graph(tmp_path).as_dict()
          rows = {r["path"]: r for r in d["directory_breakdown"]}
          assert rows["docs"] == {"path": "docs", "doc_count": 2,
                                  "unreachable_count": 1, "broken_link_count": 1}
          assert rows["guides"] == {"path": "guides", "doc_count": 3,
                                    "unreachable_count": 2, "broken_link_count": 0}
          # Root-level docs group under ".".
          assert rows["."] == {"path": ".", "doc_count": 1,
                               "unreachable_count": 0, "broken_link_count": 0}
          assert d["directory_count"] == 3
      
      
      def test_directory_breakdown_reconciles_with_headline(tmp_path: Path) -> None:
          # A working-notes tree leaves the headline, so it leaves the breakdown too:
          # the rows sum to the headline doc_count, unreachable list and dangling_links.
          _notes_and_wiki(tmp_path)
          _write(tmp_path, "notes/plan_03.md", "[ghost](./missing.md)\n")
          _write(tmp_path, "wiki/scratch.md", "[ghost](./nowhere.md)\n")
          d = build_doc_graph(tmp_path).as_dict()
          rows = d["directory_breakdown"]
          assert "notes" not in {r["path"] for r in rows}
          assert sum(r["doc_count"] for r in rows) == d["doc_count"]
          assert sum(r["unreachable_count"] for r in rows) == len(d["unreachable"])
          assert sum(r["broken_link_count"] for r in rows) == d["dangling_links"] == 1
      
      
      def test_directory_breakdown_is_capped_gap_first(tmp_path: Path, monkeypatch) -> None:
          monkeypatch.setattr(doc_graph, "MAX_DIRECTORY_BREAKDOWN", 2)
          _write(tmp_path, "README.md", "# Home\n[a](a/x.md) [b](b/x.md)\n")
          _write(tmp_path, "a/x.md", "# a\n")
          _write(tmp_path, "b/x.md", "# b\n")
          _write(tmp_path, "c/x.md", "# c orphan\n")
          d = build_doc_graph(tmp_path).as_dict()
          assert d["directory_count"] == 4
          # The directory holding the unreachable doc outranks the healthy ones.
          assert [r["path"] for r in d["directory_breakdown"]] == ["c", "."]
      
      
      def test_directory_breakdown_empty_repo(tmp_path: Path) -> None:
          d = build_doc_graph(tmp_path).as_dict()
          assert d["directory_breakdown"] == []
          assert d["directory_count"] == 0
      
    • test_doc_graph_svg.py 8.5 KB
      """The doc-graph SVG honours the same excludes as the scorer (issue #177).
      
      `doc-graph-svg.py` is a CLI wrapper that imports matplotlib/numpy at load, so
      those are stubbed before import (same approach as test_complexity_treemap). We
      then drive `main()` with `build_doc_graph` / `analyze_doc_staleness` / `render`
      patched to capture the excludes they receive, proving the SVG path resolves
      `.assess/config.toml` + `--exclude` and threads the union into both scans - so
      the SVG and `lib.doc_graph` compute over the identical doc set.
      """
      from __future__ import annotations
      
      import importlib.util
      import sys
      import types
      from pathlib import Path
      
      import pytest
      
      _SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "doc-graph-svg.py"
      
      
      def _load_svg():
          for name in ("matplotlib", "matplotlib.pyplot", "numpy"):
              sys.modules.setdefault(name, types.ModuleType(name))
          sys.modules["matplotlib"].pyplot = sys.modules["matplotlib.pyplot"]
          spec = importlib.util.spec_from_file_location("doc_graph_svg", _SCRIPT)
          mod = importlib.util.module_from_spec(spec)
          assert spec and spec.loader
          spec.loader.exec_module(mod)
          return mod
      
      
      @pytest.fixture(scope="module")
      def svg():
          return _load_svg()
      
      
      class _FakeResult:
          available = True
          doc_count = 1
          graph = object()
          doc_to_code_edges: list = []
          entry_points: list = []
          unreachable: list = []
          orphans: list = []
          pagerank: dict = {}
          broken_links: list = []
          island_count = 0
          orphan_rate = 0.0
          reachability_pct = 0.0
      
      
      def test_svg_threads_config_and_cli_excludes(svg, tmp_path, monkeypatch, capsys):
          # A repo with a durable config exclude plus an ad-hoc CLI exclude.
          (tmp_path / ".assess").mkdir()
          (tmp_path / ".assess" / "config.toml").write_text(
              'exclude_dirs = ["_archive"]\nexclude_patterns = ["*.csv"]\n'
              'working_notes_dirs = ["journal"]\nworking_notes_ignore = ["docs/chapters"]\n',
              encoding="utf-8",
          )
      
          captured: dict = {}
      
          def fake_build(root, *, extra_exclude_dirs=None, extra_exclude_patterns=None,
                         working_notes_dirs=None, working_notes_ignore=None):
              captured["graph_dirs"] = extra_exclude_dirs
              captured["graph_patterns"] = extra_exclude_patterns
              captured["notes"] = (working_notes_dirs, working_notes_ignore)
              return _FakeResult()
      
          def fake_staleness(root, *, doc_to_code_edges=None,
                             extra_exclude_dirs=None, extra_exclude_patterns=None):
              captured["stale_dirs"] = extra_exclude_dirs
              captured["stale_patterns"] = extra_exclude_patterns
              return {"docs": []}
      
          monkeypatch.setattr(svg, "build_doc_graph", fake_build)
          monkeypatch.setattr(svg, "analyze_doc_staleness", fake_staleness)
          monkeypatch.setattr(svg, "render", lambda *a, **k: None)
          monkeypatch.setattr(
              sys, "argv",
              ["doc-graph-svg.py", str(tmp_path), "-o", str(tmp_path / "out.svg"),
               "--exclude", "_jira", "--exclude", "*.tmp"],
          )
      
          assert svg.main() == 0
      
          # config dir + CLI dir merged; config glob + CLI glob merged.
          assert captured["graph_dirs"] == {"_archive", "_jira"}
          assert captured["graph_patterns"] == ["*.csv", "*.tmp"]
          # The working-notes overrides reach the graph, as in assess_core.
          assert captured["notes"] == (["journal"], ["docs/chapters"])
          # The staleness scan (drives the SVG's colour) gets the identical excludes,
          # so colour and structure speak about the same doc set.
          assert captured["stale_dirs"] == captured["graph_dirs"]
          assert captured["stale_patterns"] == captured["graph_patterns"]
      
      
      def _render_two_kinds(svg, tmp_path, monkeypatch, colour: str) -> list:
          """Render one link edge and one reference edge; return the parsed elements.
      
          numpy and matplotlib are stubbed here, so the colour map and the radial
          layout (``nx.shell_layout`` needs numpy) are replaced with fixed stand-ins;
          the edge and legend markup under test is the real code."""
          import xml.etree.ElementTree as ET
      
          import networkx as nx
      
          graph = nx.DiGraph()
          graph.add_edge("CLAUDE.md", "docs/linked.md", kind="link")
          graph.add_edge("CLAUDE.md", "docs/ref.md", kind="reference")
          result = _FakeResult()
          result.graph = graph
          result.entry_points = ["CLAUDE.md"]
          result.pagerank = {}
          fixed = {"CLAUDE.md": (500.0, 500.0), "docs/linked.md": (300.0, 300.0),
                   "docs/ref.md": (700.0, 300.0)}
          monkeypatch.setattr(svg, "_radial_positions", lambda *a, **k: dict(fixed))
          monkeypatch.setattr(svg.plt, "get_cmap", lambda _name: (lambda _v: (1.0, 1.0, 1.0, 1.0)),
                              raising=False)
          out = tmp_path / "out.svg"
          svg.render(result, out, tmp_path, colour=colour)
          return list(ET.parse(out).iter())
      
      
      _STYLE_KEYS = ("stroke", "stroke-dasharray", "stroke-width", "opacity")
      
      
      @pytest.mark.parametrize("colour", ["staleness", "status"])
      def test_reference_edge_drawn_distinct_from_link_with_legend(svg, tmp_path, monkeypatch, colour):
          """A reference edge (a backticked doc path) and a link edge render in two
          styles told apart by presentation attributes, and the legend names both.
          The reference dash must not reuse the ghost tether (4,3) or the orphan and
          ghost rings (3,2), which already carry meaning."""
          els = _render_two_kinds(svg, tmp_path, monkeypatch, colour)
          styles = {
              kind: [[e.get(k) for k in _STYLE_KEYS] for e in els if e.get("data-edge-kind") == kind]
              for kind in ("link", "reference")
          }
          assert len(styles["link"]) == 1
          assert len(styles["reference"]) == 1
          assert styles["link"][0] != styles["reference"][0]
          assert styles["reference"][0][1] not in ("4,3", "3,2")
          # Round caps add half the stroke width to both ends of a dash, so the painted
          # bead is dash + width and the painted gap is gap - width. That ratio is
          # scale-invariant, so this assertion proves the caps can never swallow the
          # gap at any scale - not that the dots stay visually distinct once rendered
          # small (at README embed scale both fall under a pixel and only the weight
          # and opacity difference actually survives).
          dash, gap = (float(v) for v in styles["reference"][0][1].split(","))
          width = float(styles["reference"][0][2])
          assert gap - width >= dash + width
          legend = sorted({e.get("data-legend-kind") for e in els if e.get("data-legend-kind")})
          assert legend == ["link", "reference"]
          # Legend samples are drawn in the same style as the edges they explain.
          for kind in ("link", "reference"):
              sample = next(e for e in els if e.get("data-legend-kind") == kind)
              assert [sample.get(k) for k in _STYLE_KEYS] == styles[kind][0]
          texts = [" ".join(e.itertext()).lower() for e in els if e.tag.endswith("text")]
          assert any("reference" in t for t in texts)
          assert any("link" in t for t in texts)
      
      
      @pytest.mark.parametrize("colour", ["staleness", "status"])
      def test_missing_or_unknown_edge_kind_draws_as_link(svg, tmp_path, monkeypatch, colour):
          """An edge with no `kind`, or one that names a kind the SVG doesn't know,
          normalizes to a link - both in styling and in the `data-edge-kind`
          attribute. `_edge_attrs` and the `data-edge-kind` markup both delegate to
          `_normalize_edge_kind`, so the two sites cannot diverge on this rule."""
          import xml.etree.ElementTree as ET
      
          import networkx as nx
      
          graph = nx.DiGraph()
          graph.add_edge("CLAUDE.md", "docs/nokind.md")
          graph.add_edge("CLAUDE.md", "docs/weird.md", kind="footnote")
          result = _FakeResult()
          result.graph = graph
          result.entry_points = ["CLAUDE.md"]
          result.pagerank = {}
          fixed = {"CLAUDE.md": (500.0, 500.0), "docs/nokind.md": (300.0, 300.0),
                   "docs/weird.md": (700.0, 300.0)}
          monkeypatch.setattr(svg, "_radial_positions", lambda *a, **k: dict(fixed))
          monkeypatch.setattr(svg.plt, "get_cmap", lambda _name: (lambda _v: (1.0, 1.0, 1.0, 1.0)),
                              raising=False)
          out = tmp_path / "out.svg"
          svg.render(result, out, tmp_path, colour=colour)
          els = list(ET.parse(out).iter())
          edges = [e for e in els if e.get("data-edge-kind")]
          assert len(edges) == 2
          link_style = [svg._EDGE_STYLE["link"][k] for k in _STYLE_KEYS]
          for edge in edges:
              assert edge.get("data-edge-kind") == "link"
              assert [edge.get(k) for k in _STYLE_KEYS] == link_style
      
      
      def test_normalize_edge_kind(svg):
          assert svg._normalize_edge_kind("link") == "link"
          assert svg._normalize_edge_kind("reference") == "reference"
          assert svg._normalize_edge_kind("footnote") == "link"
          assert svg._normalize_edge_kind("") == "link"
      
    • test_doc_provenance.py 4.8 KB
      """Tests for provenance-aware staleness of generated docs (issue #178)."""
      from __future__ import annotations
      
      import os
      from pathlib import Path
      
      from lib.doc_provenance import (
          parse_frontmatter_provenance,
          resolve_doc_sources,
          source_is_newer,
      )
      
      
      def _write(root: Path, rel: str, text: str) -> Path:
          p = root / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
          return p
      
      
      def _set_mtime(path: Path, when: float) -> None:
          os.utime(path, (when, when))
      
      
      # --- frontmatter parsing ---------------------------------------------------
      
      def test_frontmatter_scalar_source(tmp_path: Path) -> None:
          doc = _write(tmp_path, "notes.md", "---\nsource: data/jira.tsv\n---\nbody")
          sources, generated_by = parse_frontmatter_provenance(doc)
          assert sources == ["data/jira.tsv"]
          assert generated_by is None
      
      
      def test_frontmatter_flow_list_and_generated_by(tmp_path: Path) -> None:
          doc = _write(
              tmp_path, "ref.md",
              "---\nsource: [a.tsv, b.tsv]\ngenerated_by: scripts/gen.py\n---\nx",
          )
          sources, generated_by = parse_frontmatter_provenance(doc)
          assert sources == ["a.tsv", "b.tsv"]
          assert generated_by == "scripts/gen.py"
      
      
      def test_frontmatter_block_list(tmp_path: Path) -> None:
          doc = _write(
              tmp_path, "ref.md",
              "---\nsource:\n  - a.tsv\n  - b.tsv\n---\nx",
          )
          sources, _ = parse_frontmatter_provenance(doc)
          assert sources == ["a.tsv", "b.tsv"]
      
      
      def test_frontmatter_quotes_and_inline_comment(tmp_path: Path) -> None:
          doc = _write(tmp_path, "n.md", "---\nsource: \"data/x.tsv\"  # the dump\n---\n")
          sources, _ = parse_frontmatter_provenance(doc)
          assert sources == ["data/x.tsv"]
      
      
      def test_no_frontmatter_returns_empty(tmp_path: Path) -> None:
          doc = _write(tmp_path, "plain.md", "# Just a heading\nbody")
          assert parse_frontmatter_provenance(doc) == ([], None)
      
      
      def test_unterminated_frontmatter_degrades(tmp_path: Path) -> None:
          doc = _write(tmp_path, "bad.md", "---\nsource: x.tsv\nno closing fence")
          assert parse_frontmatter_provenance(doc) == ([], None)
      
      
      # --- resolution ------------------------------------------------------------
      
      def test_resolve_frontmatter_repo_relative(tmp_path: Path) -> None:
          _write(tmp_path, "data/jira.tsv", "data")
          doc = _write(tmp_path, "notes/dump.md", "---\nsource: data/jira.tsv\n---\n")
          resolved, _, method = resolve_doc_sources(doc, tmp_path, [])
          assert method == "frontmatter"
          assert [p.name for p in resolved] == ["jira.tsv"]
      
      
      def test_resolve_skips_missing_source(tmp_path: Path) -> None:
          doc = _write(tmp_path, "notes/dump.md", "---\nsource: data/gone.tsv\n---\n")
          resolved, _, method = resolve_doc_sources(doc, tmp_path, [])
          assert resolved == []
          assert method == ""
      
      
      def test_resolve_via_config_mapping(tmp_path: Path) -> None:
          _write(tmp_path, "data/jira.tsv", "data")
          doc = _write(tmp_path, "notes/123.md", "no frontmatter")
          config = [("notes", ["data/jira.tsv"])]
          resolved, _, method = resolve_doc_sources(doc, tmp_path, config)
          assert method == "config"
          assert [p.name for p in resolved] == ["jira.tsv"]
      
      
      def test_frontmatter_wins_over_config(tmp_path: Path) -> None:
          _write(tmp_path, "data/fm.tsv", "fm")
          _write(tmp_path, "data/cfg.tsv", "cfg")
          doc = _write(tmp_path, "notes/x.md", "---\nsource: data/fm.tsv\n---\n")
          config = [("notes", ["data/cfg.tsv"])]
          resolved, _, method = resolve_doc_sources(doc, tmp_path, config)
          assert method == "frontmatter"
          assert [p.name for p in resolved] == ["fm.tsv"]
      
      
      def test_config_does_not_match_sibling_prefix(tmp_path: Path) -> None:
          """`path = "notes"` must not match `notes-archive/` (prefix-on-segment)."""
          _write(tmp_path, "data/jira.tsv", "data")
          doc = _write(tmp_path, "notes-archive/x.md", "no fm")
          config = [("notes", ["data/jira.tsv"])]
          _, _, method = resolve_doc_sources(doc, tmp_path, config)
          assert method == ""
      
      
      # --- source_is_newer (mtime fallback when not in git) ----------------------
      
      def test_source_newer_true(tmp_path: Path) -> None:
          src = _write(tmp_path, "data/jira.tsv", "data")
          doc = _write(tmp_path, "notes/dump.md", "---\nsource: data/jira.tsv\n---\n")
          _set_mtime(doc, 1_000_000)
          _set_mtime(src, 2_000_000)  # source changed after the doc
          assert source_is_newer(doc, [src]) is True
      
      
      def test_source_newer_false(tmp_path: Path) -> None:
          src = _write(tmp_path, "data/jira.tsv", "data")
          doc = _write(tmp_path, "notes/dump.md", "---\nsource: data/jira.tsv\n---\n")
          _set_mtime(src, 1_000_000)
          _set_mtime(doc, 2_000_000)  # doc regenerated after the source
          assert source_is_newer(doc, [src]) is False
      
      
      def test_source_newer_none_without_sources(tmp_path: Path) -> None:
          doc = _write(tmp_path, "notes/dump.md", "body")
          assert source_is_newer(doc, []) is None
      
    • test_doc_staleness.py 24.7 KB
      """Tests for the doc-staleness metric and doc->code association."""
      from __future__ import annotations
      
      from pathlib import Path
      
      from lib.doc_staleness import LARGE_REPO_CODE_FILES, analyze_doc_staleness
      
      
      def _write(root: Path, rel: str, text: str) -> None:
          p = root / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      def test_nearest_ancestor_respects_inner_base_doc(tmp_path: Path) -> None:
          """A base doc owns its subtree down to the next base doc, not past it."""
          _write(tmp_path, "src/payments/README.md", "payments")
          _write(tmp_path, "src/payments/pay.py", "x")
          _write(tmp_path, "src/payments/refund.py", "y")
          _write(tmp_path, "src/payments/ledger/ledger.md", "ledger")  # <dir>.md convention
          _write(tmp_path, "src/payments/ledger/l.py", "z")
      
          r = analyze_doc_staleness(tmp_path)
          by_path = {d["path"]: d for d in r["docs"]}
          # README owns pay.py + refund.py (2), but NOT ledger/l.py (claimed by ledger.md)
          assert by_path["src/payments/README.md"]["subject_code_count"] == 2
          assert by_path["src/payments/README.md"]["subject_method"] == "nearest-ancestor"
          assert by_path["src/payments/ledger/ledger.md"]["subject_code_count"] == 1
      
      
      def test_parallel_docs_tree_fallback(tmp_path: Path) -> None:
          _write(tmp_path, "docs/auth.md", "auth docs")
          _write(tmp_path, "src/auth/a.py", "x")
          r = analyze_doc_staleness(tmp_path)
          auth = next(d for d in r["docs"] if d["path"] == "docs/auth.md")
          assert auth["subject_method"] == "parallel-docs-tree"
          assert auth["subject_code_count"] == 1
      
      
      def test_explicit_links_fallback(tmp_path: Path) -> None:
          _write(tmp_path, "notes.md", "see [code](src/x.py)")
          _write(tmp_path, "src/x.py", "x")
          r = analyze_doc_staleness(
              tmp_path, doc_to_code_edges=[{"doc": "notes.md", "code": "src/x.py"}]
          )
          notes = next(d for d in r["docs"] if d["path"] == "notes.md")
          assert notes["subject_method"] == "explicit-links"
      
      
      def test_repo_baseline_when_no_association(tmp_path: Path) -> None:
          _write(tmp_path, "floating.md", "no links, no co-location")
          _write(tmp_path, "src/x.py", "x")
          r = analyze_doc_staleness(tmp_path)
          floating = next(d for d in r["docs"] if d["path"] == "floating.md")
          assert floating["subject_method"] == "repo-baseline"
      
      
      def test_boilerplate_is_not_a_base_doc(tmp_path: Path) -> None:
          _write(tmp_path, "src/LICENSE.md", "MIT")
          _write(tmp_path, "src/x.py", "x")
          r = analyze_doc_staleness(tmp_path)
          # LICENSE must not claim ownership of src/x.py as a base doc
          assert r["association"]["code_under_base_doc"] == 0
      
      
      def test_modularity_large_repo_flag(tmp_path: Path) -> None:
          for i in range(LARGE_REPO_CODE_FILES + 1):
              _write(tmp_path, f"mod{i}/f.py", "x")
          r = analyze_doc_staleness(tmp_path)
          assert r["modularity"]["large_repo"] is True
          assert r["modularity"]["base_doc_coverage_when_present"] == 0.0  # no base docs anywhere
          assert r["modularity"]["base_doc_dir_ratio"] == 0.0
      
      
      def test_base_doc_coverage_is_size_weighted(tmp_path: Path) -> None:
          """One 30-file service with a base doc should not be drowned by ten
          1-file utility dirs without one. Size-weighting reflects what an agent
          actually needs to navigate; the un-weighted dir ratio is reported alongside
          for transparency.
          """
          # A large module (30 files) with a base doc...
          _write(tmp_path, "services/payments/README.md", "payments")
          for i in range(30):
              _write(tmp_path, f"services/payments/f{i}.py", "x")
          # ...and ten utility/leaf dirs (1 file each) with no doc.
          for i in range(10):
              _write(tmp_path, f"internal/util{i}/u.py", "x")
      
          r = analyze_doc_staleness(tmp_path)
          # Un-weighted dir ratio is low (1 doc'd dir out of 11), but the size-weighted
          # coverage reflects that ~75% of code sits under a maintained base doc.
          assert r["modularity"]["base_doc_dir_ratio"] < 0.2
          assert r["modularity"]["base_doc_coverage_when_present"] >= 0.7
          # Sanity: the when-present number matches what the association block reports.
          assert r["modularity"]["base_doc_coverage_when_present"] == r["association"]["pct_code_under_base_doc"]
      
      
      def test_no_git_degrades_to_zero_churn(tmp_path: Path) -> None:
          """Without git history, churn is zero and last_commit_days is None - no crash."""
          _write(tmp_path, "README.md", "doc")
          _write(tmp_path, "app.py", "x")
          r = analyze_doc_staleness(tmp_path)
          assert r["available"] is True
          readme = next(d for d in r["docs"] if d["path"] == "README.md")
          assert readme["last_commit_days"] is None
          assert readme["code_churn_in_window"] == 0
      
      
      def test_stale_doc_beside_churny_code_has_high_ratio(git_repo) -> None:
          """The decaying-map signal: old doc + actively churning subject = high ratio."""
          repo, commit = git_repo
          (repo / "README.md").write_text("module map", encoding="utf-8")
          (repo / "app.py").write_text("v = 0", encoding="utf-8")
          # Doc committed long ago, outside the 12-month window.
          commit("initial docs+code", days_ago=500)
          # Code churns repeatedly and recently; the doc never moves again.
          for i in range(5):
              (repo / "app.py").write_text(f"v = {i + 1}", encoding="utf-8")
              commit(f"change {i}", days_ago=20 - i * 2)
      
          r = analyze_doc_staleness(repo)
          readme = next(d for d in r["docs"] if d["path"] == "README.md")
          assert readme["last_commit_days"] is not None and readme["last_commit_days"] >= 400
          assert readme["code_churn_in_window"] >= 5
          assert readme["doc_churn_in_window"] == 0  # didn't move inside the window
          assert readme["ratio"] >= 5  # frozen map of a churning module
      
      
      def test_staleness_uses_author_time_not_committer_time(git_repo) -> None:
          """A rebase must not certify a stale doc as fresh.
      
          A doc authored 90 days ago but rebased/cherry-picked yesterday (fresh
          committer time, original author time) must still read ~90 days stale. Author
          time (`%at`) reflects when the change was originally made; committer time
          (`%ct`) would reset the clock on every rebase and hide the decay.
          """
          repo, commit = git_repo
          (repo / "README.md").write_text("module map", encoding="utf-8")
          (repo / "app.py").write_text("v = 0", encoding="utf-8")
          # Authored 90 days ago, but committer time is yesterday (the rebase artifact).
          commit("docs authored long ago, rebased yesterday", days_ago=90, committer_days_ago=1)
      
          r = analyze_doc_staleness(repo)
          readme = next(d for d in r["docs"] if d["path"] == "README.md")
          # Author time wins: ~90 days, not ~1. A committer-time read would report <=2.
          assert readme["last_commit_days"] is not None
          assert readme["last_commit_days"] >= 85
      
      
      def test_churn_degenerate_flag_on_single_commit_per_file_repo(git_repo) -> None:
          """Issue #172: a history where every file shows one commit (a bulk import /
          shallow clone / squashed tree) is degenerate - the churn count is an
          extraction artifact, not activity. The doc-staleness block surfaces that as
          ``churn_degenerate: True`` so downstream consumers discount churn findings."""
          repo, commit = git_repo
          (repo / "README.md").write_text("module map", encoding="utf-8")
          for i in range(6):
              (repo / f"mod{i}.py").write_text(f"x = {i}", encoding="utf-8")
          commit("bulk import - one commit touches every file", days_ago=10)
      
          r = analyze_doc_staleness(repo)
          assert r["churn_degenerate"] is True
      
      
      def test_churn_not_degenerate_with_genuine_variance(git_repo) -> None:
          """Regression guard: a repo with a real spread of commits-per-file is NOT
          degenerate, so its high-confidence churn findings are untouched."""
          repo, commit = git_repo
          (repo / "README.md").write_text("module map", encoding="utf-8")
          for i in range(6):
              (repo / f"mod{i}.py").write_text("start", encoding="utf-8")
          commit("init", days_ago=30)
          # mod0 churns repeatedly; mod1 once more; mod2..5 stay frozen -> a spread.
          for j in range(8):
              (repo / "mod0.py").write_text(f"v = {j}", encoding="utf-8")
              commit(f"churn {j}", days_ago=20 - j)
          (repo / "mod1.py").write_text("v = 99", encoding="utf-8")
          commit("touch mod1", days_ago=5)
      
          r = analyze_doc_staleness(repo)
          assert r["churn_degenerate"] is False
      
      
      def test_fresh_doc_beside_fresh_code_has_low_ratio(git_repo) -> None:
          repo, commit = git_repo
          (repo / "README.md").write_text("module map", encoding="utf-8")
          (repo / "app.py").write_text("v = 1", encoding="utf-8")
          commit("recent docs+code", days_ago=5)
      
          r = analyze_doc_staleness(repo)
          readme = next(d for d in r["docs"] if d["path"] == "README.md")
          assert readme["last_commit_days"] is not None and readme["last_commit_days"] <= 30
          # doc and code moved together -> ratio stays near 1 (not a decaying map)
          assert readme["ratio"] <= 2
      
      
      def test_untracked_files_excluded_from_staleness(git_repo) -> None:
          """Untracked personal docs/code are not part of the repo and aren't scored."""
          repo, commit = git_repo
          (repo / "README.md").write_text("doc", encoding="utf-8")
          (repo / "app.py").write_text("x = 1", encoding="utf-8")
          commit("init", days_ago=3)
          (repo / "personal.md").write_text("private", encoding="utf-8")  # untracked
      
          r = analyze_doc_staleness(repo)
          doc_paths = {d["path"] for d in r["docs"]}
          assert "README.md" in doc_paths
          assert "personal.md" not in doc_paths
          assert r["association"]["doc_count"] == 1  # only the tracked doc counted
      
      
      def test_doc_staleness_honors_user_excludes(tmp_path: Path) -> None:
          """User-supplied excludes drop docs AND code under the named dir from
          every staleness calculation - so a `regulatory-raw/` directory full of
          CSV-driven Python loaders doesn't inflate the code-churn denominator
          or surface as a stale-hub candidate."""
          _write(tmp_path, "README.md", "main doc")
          _write(tmp_path, "src/app.py", "x = 1")
          _write(tmp_path, "regulatory-raw/loader.py", "y = 2")
          _write(tmp_path, "regulatory-raw/notes.md", "ref data")
      
          # Baseline: regulatory-raw files are counted.
          r = analyze_doc_staleness(tmp_path)
          assert r["association"]["doc_count"] == 2
          assert r["association"]["code_file_count"] == 2
      
          # With the exclude: regulatory-raw drops out of both code and docs.
          r = analyze_doc_staleness(
              tmp_path, extra_exclude_dirs={"regulatory-raw"},
          )
          assert r["association"]["doc_count"] == 1
          assert r["association"]["code_file_count"] == 1
          assert all("regulatory-raw" not in d["path"] for d in r["docs"])
      
      
      def test_doc_staleness_excludes_test_fixtures(tmp_path: Path) -> None:
          """Markdown and code under `**/tests/fixtures/**` are scanner inputs, not
          repo content, so they must not count toward the staleness association
          (issue #83)."""
          _write(tmp_path, "README.md", "main doc")
          _write(tmp_path, "src/app.py", "x = 1")
          _write(tmp_path, "tests/fixtures/sample/CLAUDE.md", "fixture")
          _write(tmp_path, "tests/fixtures/sample/loader.py", "y = 2")
      
          r = analyze_doc_staleness(tmp_path)
          assert r["association"]["doc_count"] == 1
          assert r["association"]["code_file_count"] == 1
          assert all("fixtures" not in d["path"] for d in r["docs"])
      
      
      # --- Provenance-aware staleness for generated docs (issue #178) -----------
      
      def test_generated_doc_source_newer_is_flagged(git_repo) -> None:
          """A generated doc whose declared source committed AFTER it -> source_newer
          True. Staleness is measured against the source, not the doc's own age."""
          repo, commit = git_repo
          (repo / "data").mkdir()
          (repo / "data" / "jira.tsv").write_text("rows v1", encoding="utf-8")
          (repo / "notes").mkdir()
          (repo / "notes" / "dump.md").write_text(
              "---\nsource: data/jira.tsv\ngenerated_by: scripts/gen.py\n---\nnotes",
              encoding="utf-8",
          )
          commit("generate notes from source", days_ago=24)
          # The source moves on; the generated dump is never regenerated.
          (repo / "data" / "jira.tsv").write_text("rows v2", encoding="utf-8")
          commit("source data updated", days_ago=1)
      
          r = analyze_doc_staleness(repo)
          dump = next(d for d in r["docs"] if d["path"] == "notes/dump.md")
          assert dump["provenance"]["method"] == "frontmatter"
          assert dump["provenance"]["sources"] == ["data/jira.tsv"]
          assert dump["provenance"]["generated_by"] == "scripts/gen.py"
          assert dump["provenance"]["source_newer"] is True
      
      
      def test_generated_doc_source_not_newer_is_fresh(git_repo) -> None:
          """An old-but-accurate generated doc (regenerated AFTER its source last
          moved) is fresh: source_newer False and ratio forced to 0 so no downstream
          consumer reads it as a decaying/lying map."""
          repo, commit = git_repo
          (repo / "data").mkdir()
          (repo / "data" / "jira.tsv").write_text("rows", encoding="utf-8")
          commit("source data", days_ago=30)
          # Regenerate the dump well after the source last changed.
          (repo / "notes").mkdir()
          (repo / "notes" / "dump.md").write_text(
              "---\nsource: data/jira.tsv\n---\nnotes", encoding="utf-8",
          )
          commit("regenerate notes", days_ago=2)
      
          r = analyze_doc_staleness(repo)
          dump = next(d for d in r["docs"] if d["path"] == "notes/dump.md")
          assert dump["provenance"]["source_newer"] is False
          assert dump["ratio"] == 0.0  # provably matches source -> not a decaying map
      
      
      def test_generated_doc_via_config_mapping(tmp_path: Path) -> None:
          """A bulk-generated tree declares provenance via .assess/config.toml rather
          than per-file frontmatter; staleness still measured against the source."""
          import os
          (tmp_path / "data").mkdir()
          src = tmp_path / "data" / "jira.tsv"
          src.write_text("rows", encoding="utf-8")
          (tmp_path / "notes").mkdir()
          doc = tmp_path / "notes" / "123.md"
          doc.write_text("no frontmatter here", encoding="utf-8")
          os.utime(doc, (1_000_000, 1_000_000))
          os.utime(src, (2_000_000, 2_000_000))  # source newer than the doc
      
          r = analyze_doc_staleness(
              tmp_path, generated_sources=[("notes", ["data/jira.tsv"])]
          )
          doc_row = next(d for d in r["docs"] if d["path"] == "notes/123.md")
          assert doc_row["provenance"]["method"] == "config"
          assert doc_row["provenance"]["source_newer"] is True
      
      
      def test_ordinary_doc_has_no_provenance_block(tmp_path: Path) -> None:
          _write(tmp_path, "README.md", "plain doc, no source declared")
          _write(tmp_path, "app.py", "x")
          r = analyze_doc_staleness(tmp_path)
          readme = next(d for d in r["docs"] if d["path"] == "README.md")
          assert "provenance" not in readme
      
      
      # --- Bulk mechanical commits (issue #333) -----------------------------------
      
      _BULK_DOCS = [
          "CLAUDE.md", "docs/architecture.md", "docs/billing.md", "docs/caching.md",
          "docs/deploy.md", "docs/events.md", "docs/glossary.md", "docs/logging.md",
          "docs/metrics.md", "docs/onboarding.md", "docs/releases.md",
          "docs/security.md",
      ]
      
      
      def _bulk_fixture(repo: Path, commit) -> None:
          """Twelve docs written 300..190 days ago, one licence-header commit 8 days
          ago touching all twelve, then a genuine one-doc edit 3 days ago."""
          days = 300
          for rel in _BULK_DOCS:
              _write(repo, rel, f"Original text of {rel}.\n")
              commit(f"docs: write {rel}", days_ago=days)
              days -= 10
          for rel in _BULK_DOCS:
              p = repo / rel
              p.write_text("<!-- SPDX-License-Identifier: MIT -->\n" + p.read_text(), encoding="utf-8")
          commit("chore: licence headers", days_ago=8)
          p = repo / "docs/caching.md"
          p.write_text(p.read_text() + "A genuine closing section.\n", encoding="utf-8")
          commit("docs: caching invalidation", days_ago=3)
      
      
      def _head_sha(repo: Path, rev: str) -> str:
          import subprocess
          return subprocess.run(["git", "-C", str(repo), "rev-parse", rev],
                                capture_output=True, text=True, check=True).stdout.strip()
      
      
      def test_bulk_commit_does_not_reset_doc_staleness(git_repo) -> None:
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          r = analyze_doc_staleness(repo)
          days = {d["path"]: d["last_commit_days"] for d in r["docs"]}
          expected = {rel: 300 - 10 * i for i, rel in enumerate(_BULK_DOCS)}
          expected["docs/caching.md"] = 3
          # One day of slack: the fixture stamps naive local time, so a DST change
          # between then and now moves a 300-day-old commit by an hour.
          assert set(days) == set(expected)
          assert all(abs(days[k] - v) <= 1 for k, v in expected.items()), days
          assert days["docs/caching.md"] == 3
      
      
      def test_bulk_commit_is_recorded_with_full_sha(git_repo) -> None:
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          r = analyze_doc_staleness(repo)
          bulk_sha = _head_sha(repo, "HEAD~1")
          skipped = r["bulk_commits_skipped"]
          assert [s["sha"] for s in skipped] == [bulk_sha]
          assert len(skipped[0]["sha"]) == 40
          assert skipped[0]["docs_touched"] == 12
          assert skipped[0]["doc_count"] == 12
          assert r["bulk_commits_skipped_total"] == 1
          assert r["bulk_commit_scan_complete"] is True
      
      
      def test_single_doc_commit_is_not_bulk(git_repo) -> None:
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          r = analyze_doc_staleness(repo)
          caching_edit = _head_sha(repo, "HEAD")
          assert caching_edit not in {s["sha"] for s in r["bulk_commits_skipped"]}
      
      
      def test_doc_created_in_bulk_commit_keeps_its_creation_date(git_repo) -> None:
          """A bulk import that adds every doc is the docs' content creation: a later
          bulk sweep falls back to it, not to the sweep and not to None."""
          repo, commit = git_repo
          for rel in _BULK_DOCS:
              _write(repo, rel, f"Imported {rel}.\n")
          commit("import docs", days_ago=100)
          for rel in _BULK_DOCS:
              p = repo / rel
              p.write_text("<!-- header -->\n" + p.read_text(), encoding="utf-8")
          commit("chore: headers", days_ago=5)
          r = analyze_doc_staleness(repo)
          assert {d["last_commit_days"] for d in r["docs"]} == {100}
          assert [s["sha"] for s in r["bulk_commits_skipped"]] == [_head_sha(repo, "HEAD")]
      
      
      def test_small_repo_all_docs_commit_is_not_bulk(git_repo) -> None:
          """Below the minimum-docs floor, editing every doc at once is ordinary work."""
          repo, commit = git_repo
          for rel in ("README.md", "docs/a.md", "docs/b.md"):
              _write(repo, rel, "v1\n")
          commit("docs", days_ago=50)
          for rel in ("README.md", "docs/a.md", "docs/b.md"):
              _write(repo, rel, "v2\n")
          commit("docs: rewrite", days_ago=4)
          r = analyze_doc_staleness(repo)
          assert {d["last_commit_days"] for d in r["docs"]} == {4}
          assert r["bulk_commits_skipped"] == []
      
      
      def test_no_git_reports_no_bulk_commits(tmp_path: Path) -> None:
          _write(tmp_path, "README.md", "doc")
          r = analyze_doc_staleness(tmp_path)
          assert r["bulk_commits_skipped"] == []
          assert r["bulk_commits_skipped_total"] == 0
      
      
      def test_instruction_freshness_skips_bulk_commit(git_repo) -> None:
          """The instruction grader's clock shares the bulk-commit skip."""
          from assess_core import _grade_instruction_files
      
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          files = _grade_instruction_files(repo)[0]
          assert files["CLAUDE.md"]["freshness_days"] in (299, 300)  # DST slack
      
      
      def _sweep(repo: Path, commit, tag: str, days_ago: int, extra=()) -> None:
          for rel in list(_BULK_DOCS) + list(extra):
              p = repo / rel
              p.write_text(f"<!-- {tag} -->\n" + p.read_text(), encoding="utf-8")
          commit(f"chore: {tag}", days_ago=days_ago)
      
      
      def test_doc_regenerated_only_in_bulk_is_flagged_as_creation_date(git_repo) -> None:
          """Docs regenerated in bulk every release have no content commit: the
          creation-date fallback is disclosed and discounted, not passed off as age."""
          repo, commit = git_repo
          for rel in _BULK_DOCS:
              _write(repo, rel, f"Generated {rel}.\n")
          commit("docs: generate", days_ago=400)
          for n, days in enumerate((200, 30, 2)):
              _sweep(repo, commit, f"regen {n}", days)
          r = analyze_doc_staleness(repo)
          assert r["creation_date_fallback_count"] == 12
          for d in r["docs"]:
              assert d["last_change_basis"] == "creation"
              assert d["confidence"] == "low"
      
      
      def test_content_dated_doc_has_no_basis_key(git_repo) -> None:
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          r = analyze_doc_staleness(repo)
          assert r["creation_date_fallback_count"] == 0
          assert all("last_change_basis" not in d for d in r["docs"])
      
      
      def test_skipped_list_is_capped_and_total_is_kept(git_repo) -> None:
          from lib.git_churn import BULK_COMMITS_SKIPPED_CAP
      
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          sweeps = BULK_COMMITS_SKIPPED_CAP + 1
          for n in range(sweeps):
              _sweep(repo, commit, f"sweep {n}", days_ago=2)
          r = analyze_doc_staleness(repo)
          assert len(r["bulk_commits_skipped"]) == BULK_COMMITS_SKIPPED_CAP
          assert r["bulk_commits_skipped_total"] == sweeps + 1
          assert r["bulk_commits_skipped"][0]["sha"] == _head_sha(repo, "HEAD")
      
      
      def test_mass_rename_keeps_pre_rename_content_dates(git_repo) -> None:
          import subprocess
      
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          subprocess.run(["git", "-C", str(repo), "mv", "docs", "guide"], check=True)
          commit("chore: move docs to guide", days_ago=1)
          r = analyze_doc_staleness(repo)
          days = {d["path"]: d["last_commit_days"] for d in r["docs"]}
          assert days["guide/caching.md"] == 3
          assert days["guide/security.md"] in (189, 190)  # DST slack
      
      
      def test_scan_incomplete_when_git_log_times_out(git_repo, monkeypatch) -> None:
          """A timed-out history read degrades to the plain newest-commit clock and
          says so, rather than reading as "no bulk commits"."""
          import subprocess
      
          import lib.git_churn as gc
      
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          real_run = subprocess.run
      
          def fake_run(cmd, *a, **k):
              if "--no-renames" in cmd:
                  raise subprocess.TimeoutExpired(cmd, 1)
              return real_run(cmd, *a, **k)
      
          gc.content_commit_clock.cache_clear()
          monkeypatch.setattr(gc.subprocess, "run", fake_run)
          r = analyze_doc_staleness(repo)
          gc.content_commit_clock.cache_clear()
          assert r["bulk_commit_scan_complete"] is False
          assert r["bulk_commits_skipped"] == []
          days = {d["path"]: d["last_commit_days"] for d in r["docs"]}
          assert days["CLAUDE.md"] == 8  # plain newest-commit read
      
      
      def test_shallow_clone_marks_scan_incomplete(git_repo, tmp_path: Path) -> None:
          import subprocess
      
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          clone = tmp_path / "shallow"
          subprocess.run(["git", "clone", "-q", "--depth", "2", f"file://{repo}", str(clone)],
                         check=True)
          r = analyze_doc_staleness(clone)
          assert r["bulk_commit_scan_complete"] is False
      
      
      def test_non_doc_instruction_file_skips_bulk_commit(git_repo) -> None:
          """`.cursorrules` is outside the docs' pathspec; the per-file route applies
          the same bulk skip."""
          from assess_core import _grade_instruction_files
      
          repo, commit = git_repo
          _write(repo, ".cursorrules", "Prefer small functions.\n")
          commit("add cursor rules", days_ago=400)
          _bulk_fixture(repo, commit)
          rules = repo / ".cursorrules"
          rules.write_text("# header\n" + rules.read_text(), encoding="utf-8")
          for rel in _BULK_DOCS:
              p = repo / rel
              p.write_text("<!-- again -->\n" + p.read_text(), encoding="utf-8")
          commit("chore: headers everywhere", days_ago=5)
          files = _grade_instruction_files(repo)[0]
          assert files[".cursorrules"]["freshness_days"] in (399, 400)  # DST slack
      
      
      def test_shallow_probe_timeout_marks_scan_incomplete(git_repo, monkeypatch) -> None:
          """A timed-out shallow probe must not escape the clock or vouch for it."""
          import subprocess
      
          import lib.git_churn as gc
      
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          real_run = subprocess.run
      
          def fake_run(cmd, *a, **k):
              if "--is-shallow-repository" in cmd:
                  raise subprocess.TimeoutExpired(cmd, 1)
              return real_run(cmd, *a, **k)
      
          gc.content_commit_clock.cache_clear()
          monkeypatch.setattr(gc.subprocess, "run", fake_run)
          r = analyze_doc_staleness(repo)
          gc.content_commit_clock.cache_clear()
          assert r["bulk_commit_scan_complete"] is False
          days = {d["path"]: d["last_commit_days"] for d in r["docs"]}
          assert days["docs/caching.md"] == 3
      
      
      def test_rename_scan_failure_marks_scan_incomplete(git_repo, monkeypatch) -> None:
          """A rename map that could not be read leaves renames unfollowed, so the
          completeness flag must not vouch for the scan."""
          import lib.change_coupling as cc
          import lib.doc_staleness as ds
          import lib.git_churn as gc
      
          repo, commit = git_repo
          _bulk_fixture(repo, commit)
          monkeypatch.setattr(cc, "build_rename_map",
                              lambda *a, **k: cc.RenameMap({}, complete=False))
          ds._clock_at.cache_clear()
          gc.content_commit_clock.cache_clear()
          r = analyze_doc_staleness(repo)
          ds._clock_at.cache_clear()
          assert r["bulk_commit_scan_complete"] is False
          days = {d["path"]: d["last_commit_days"] for d in r["docs"]}
          assert days["docs/caching.md"] == 3
      
    • test_emit_workflow.py 13.6 KB
      """Tests for the emit-workflow CLI wrapper (assess_emit_workflow.py).
      
      The wrapper derives sensible defaults (version from the running plugin, checked
      against the published tags; branch from git; tools from PATH) so the
      orchestrator can emit the frozen-harness workflow with a single argument. These
      tests pin the default derivation, the tag check and arg parsing. Every network
      call goes through ``assess_emit_workflow._run``, stubbed here with a fake
      ``gh`` / ``git ls-remote`` so no test touches the network.
      """
      from __future__ import annotations
      
      import json
      import re
      from pathlib import Path
      
      import pytest
      
      import assess_emit_workflow as emit
      from assess_emit_workflow import main
      
      RUNNING = "1.60.2"
      
      
      def _write_ctx(tmp_path: Path, version: str | None) -> None:
          (tmp_path / ".assess").mkdir(parents=True, exist_ok=True)
          ctx = {"plugin_version": version} if version is not None else {}
          (tmp_path / ".assess" / "run-context.json").write_text(json.dumps(ctx))
      
      
      def _workflow(tmp_path: Path) -> str:
          return (tmp_path / ".github" / "workflows" / "assess-gate.yml").read_text()
      
      
      def _pins(text: str) -> set[str]:
          """Every toolkit version the workflow names (header comment and uses: line)."""
          return set(re.findall(r"ai-native-toolkit[ @]v([0-9][0-9.]*)", text))
      
      
      def _fake_remote(tags: list[str] | None, with_action: set[str], *, gh_up: bool = True):
          """A stand-in for ``_run``: ``git ls-remote`` lists ``tags`` (``None`` = git
          offline); ``gh api .../contents/action.yml?ref=<tag>`` succeeds for tags in
          ``with_action`` and answers HTTP 404 otherwise (``gh_up=False`` = gh offline)."""
      
          def run(cmd: list[str]) -> tuple[int, str, str]:
              if cmd[0] == "git":
                  if tags is None:
                      return 128, "", "fatal: Could not resolve host: github.com"
                  out = "".join(f"{i:040x}\trefs/tags/{t}\n" for i, t in enumerate(tags))
                  return 0, out, ""
              if cmd[0] == "gh":
                  if not gh_up:
                      return 1, "", "error connecting to api.github.com"
                  ref = cmd[-1].rsplit("ref=", 1)[-1]
                  if ref in with_action:
                      return 0, '{"path": "action.yml"}', ""
                  return 1, "", f"gh: No commit found for the ref {ref} (HTTP 404)"
              raise AssertionError(f"unexpected command {cmd}")
      
          return run
      
      
      @pytest.fixture
      def running(monkeypatch):
          monkeypatch.setattr(emit, "_running_version", lambda: RUNNING)
      
      
      def test_stale_run_context_ignored(tmp_path, monkeypatch, running):
          _write_ctx(tmp_path, "1.23.2")
          monkeypatch.setattr(emit, "_run", _fake_remote(["v1.23.0", f"v{RUNNING}"], {f"v{RUNNING}"}))
          assert main([str(tmp_path), "--branch", "main", "--tools", "lizard"]) == 0
          text = _workflow(tmp_path)
          assert _pins(text) == {RUNNING}
          assert f"uses: bjcoombs/ai-native-toolkit@v{RUNNING}" in text
      
      
      def test_unpublished_version_falls_back(tmp_path, monkeypatch, running, capsys):
          # The running tag is unpublished and the newest tag ships no action.yml:
          # the generator walks down to the newest tag that does.
          tags = ["v1.23.0", "v1.41.0", "v1.57.0", "v1.58.2", "v1.59.0", "standalone-skills-v1.60.2"]
          monkeypatch.setattr(emit, "_run", _fake_remote(tags, {"v1.57.0", "v1.58.2"}))
          assert main([str(tmp_path), "--branch", "main", "--tools", "lizard"]) == 0
          assert _pins(_workflow(tmp_path)) == {"1.58.2"}
          err = capsys.readouterr().err
          assert "v1.58.2" in err and f"v{RUNNING}" in err
      
      
      def test_unpublished_version_falls_back_without_gh(tmp_path, monkeypatch, running, capsys):
          # gh unreachable but git works: the newest tag at or after the first
          # release that shipped action.yml is chosen from the ls-remote list.
          tags = ["v1.23.0", "v1.58.2", "v1.41.0", "standalone-skills-v1.60.2"]
          monkeypatch.setattr(emit, "_run", _fake_remote(tags, set(), gh_up=False))
          assert main([str(tmp_path), "--branch", "main", "--tools", "lizard"]) == 0
          assert _pins(_workflow(tmp_path)) == {"1.58.2"}
          assert "v1.58.2" in capsys.readouterr().err
      
      
      def test_published_version_pinned_without_gh(tmp_path, monkeypatch, running):
          monkeypatch.setattr(emit, "_run", _fake_remote(["v1.58.2", f"v{RUNNING}"], set(), gh_up=False))
          assert main([str(tmp_path), "--branch", "main", "--tools", "lizard"]) == 0
          assert _pins(_workflow(tmp_path)) == {RUNNING}
      
      
      def test_offline_emits_unverified(tmp_path, monkeypatch, running, capsys):
          _write_ctx(tmp_path, "1.23.2")
          monkeypatch.setattr(emit, "_run", _fake_remote(None, set(), gh_up=False))
          assert main([str(tmp_path), "--branch", "main", "--tools", "lizard"]) == 0
          assert _pins(_workflow(tmp_path)) == {RUNNING}
          err = capsys.readouterr().err
          assert any("unverified" in line.lower() and f"v{RUNNING}" in line for line in err.splitlines())
      
      
      def test_no_published_tag_with_action_refuses(tmp_path, monkeypatch, running, capsys):
          # gh 404s the running tag and no published tag qualifies: v<running> is known
          # missing, so nothing is written rather than a uses: line that cannot resolve.
          monkeypatch.setattr(emit, "_run", _fake_remote(["v1.23.0"], set()))
          assert main([str(tmp_path), "--branch", "main", "--tools", "lizard"]) == 1
          assert not (tmp_path / ".github" / "workflows" / "assess-gate.yml").exists()
          err = capsys.readouterr().err
          assert f"v{RUNNING} is not published" in err and "--version" in err
      
      
      def test_running_tag_404_and_git_offline_refuses(tmp_path, monkeypatch, running, capsys):
          # gh 404s the running tag, then git ls-remote fails: no fallback list, and
          # the running tag is known missing, so the generator refuses.
          fake = _fake_remote(None, set())
          monkeypatch.setattr(emit, "_run", fake)
          assert main([str(tmp_path), "--branch", "main", "--tools", "lizard"]) == 1
          assert not (tmp_path / ".github" / "workflows" / "assess-gate.yml").exists()
          assert "--version" in capsys.readouterr().err
      
      
      def test_happy_path_skips_tag_listing(tmp_path, monkeypatch, running):
          # The running tag ships action.yml: one gh call, no git ls-remote round trip.
          calls: list[str] = []
          fake = _fake_remote([f"v{RUNNING}"], {f"v{RUNNING}"})
      
          def run(cmd):
              calls.append(cmd[0])
              return fake(cmd)
      
          monkeypatch.setattr(emit, "_run", run)
          assert main([str(tmp_path), "--branch", "main", "--tools", "lizard"]) == 0
          assert _pins(_workflow(tmp_path)) == {RUNNING}
          assert calls == ["gh"]
      
      
      def test_gh_failing_mid_walk_pins_published_tag(tmp_path, monkeypatch, running, capsys):
          # gh answers the running tag with a definite 404, then degrades (a secondary
          # rate limit): the newest published release after action.yml shipped is
          # pinned, never the running tag gh just said does not exist.
          gh_calls = 0
          tags = ["v1.41.0", "v1.57.0", "v1.58.2"]
          fake = _fake_remote(tags, set())
      
          def run(cmd):
              nonlocal gh_calls
              if cmd[0] == "gh":
                  gh_calls += 1
                  if gh_calls > 1:
                      return 1, "", "gh: You have exceeded a secondary rate limit (HTTP 403)"
              return fake(cmd)
      
          monkeypatch.setattr(emit, "_run", run)
          assert main([str(tmp_path), "--branch", "main", "--tools", "lizard"]) == 0
          assert _pins(_workflow(tmp_path)) == {"1.58.2"}
          assert "v1.58.2" in capsys.readouterr().err
      
      
      def test_unknown_running_version_without_fallback_refuses(tmp_path, monkeypatch, capsys):
          # No readable plugin.json and no published tag: a guessed ref (vlatest) can
          # never resolve, so nothing is written and the exit code is non-zero.
          monkeypatch.setattr(emit, "_running_version", lambda: None)
          monkeypatch.setattr(emit, "_run", _fake_remote(None, set(), gh_up=False))
          assert main([str(tmp_path), "--branch", "main", "--tools", "lizard"]) == 1
          assert not (tmp_path / ".github" / "workflows" / "assess-gate.yml").exists()
          err = capsys.readouterr().err
          assert "version is unknown" in err and "--version" in err
      
      
      def test_running_version_reads_plugin_json():
          root = Path(emit.__file__).resolve().parents[3]
          expected = json.loads((root / ".claude-plugin" / "plugin.json").read_text())["version"]
          assert emit._running_version() == expected
      
      
      def test_main_emits_with_explicit_flags(tmp_path, monkeypatch):
          # An explicit --version is the user's override: emitted as given, no lookup.
          def no_network(cmd):
              raise AssertionError(f"explicit --version must not hit the network: {cmd}")
      
          monkeypatch.setattr(emit, "_run", no_network)
          rc = main([str(tmp_path), "--version", "1.23.0", "--branch", "develop", "--tools", "lizard,scc"])
          assert rc == 0
          workflow = _workflow(tmp_path)
          assert _pins(workflow) == {"1.23.0"}
          assert "branches: [develop]" in workflow
          assert "Install scc" in workflow
      
      
      def test_main_no_args_usage_error(capsys):
          assert main([]) == 2
          assert "Usage" in capsys.readouterr().err
      
      
      def _pull_request(tmp_path: Path) -> dict:
          yaml = pytest.importorskip("yaml")
          doc = yaml.safe_load(_workflow(tmp_path))
          on = doc.get("on", doc.get(True))  # PyYAML reads a bare `on:` key as True
          return on["pull_request"]
      
      
      _FLAGS = ["--version", "9.9.9", "--branch", "main", "--tools", "lizard"]
      _NOTICE = "Applied the default paths-ignore"
      
      
      def _existing_workflow(tmp_path: Path, body: str) -> None:
          wf = tmp_path / ".github" / "workflows"
          wf.mkdir(parents=True)
          (wf / "ci.yml").write_text(body)
      
      
      _FILTERED = "on:\n  pull_request:\n    paths:\n      - src/**\n"
      
      
      def test_main_paths_ignore_flag(tmp_path):
          assert main([str(tmp_path), *_FLAGS, "--paths-ignore", "**/*.md"]) == 0
          assert _pull_request(tmp_path) == {"branches": ["main"], "paths-ignore": ["**/*.md"]}
      
      
      def test_main_paths_flag_repeatable(tmp_path):
          assert main([str(tmp_path), *_FLAGS, "--paths", "src/**", "--paths", "lib/**"]) == 0
          assert _pull_request(tmp_path) == {"branches": ["main"], "paths": ["src/**", "lib/**"]}
      
      
      def test_main_paths_and_paths_ignore_together_is_usage_error(tmp_path, capsys):
          assert main([str(tmp_path), *_FLAGS, "--paths", "src/**", "--paths-ignore", "**/*.md"]) == 2
          assert "--paths" in capsys.readouterr().err
          assert not (tmp_path / ".github" / "workflows" / "assess-gate.yml").exists()
      
      
      @pytest.mark.parametrize("body", [_FILTERED, "jobs:\n  t:\n    steps:\n      - uses: dorny/paths-filter@v3\n"])
      def test_path_filter_default_applied(tmp_path, capsys, body):
          _existing_workflow(tmp_path, body)
          assert main([str(tmp_path), *_FLAGS]) == 0
          assert _pull_request(tmp_path) == {"branches": ["main"], "paths-ignore": ["**/*.md", ".assess/**"]}
          notice = [line for line in capsys.readouterr().err.splitlines() if _NOTICE in line]
          assert len(notice) == 1
          assert "**/*.md" in notice[0] and ".assess/" in notice[0] and "ci.yml" in notice[0]
          assert "lying_map" in notice[0]  # names what the default stops gating
          assert "required status check" in notice[0]  # a skipped PR reports no gate check
      
      
      def test_path_filter_default_not_applied_without_filtered_workflow(tmp_path, capsys):
          _existing_workflow(tmp_path, "on:\n  pull_request:\njobs:\n  t:\n    steps:\n      - run: true\n")
          assert main([str(tmp_path), *_FLAGS]) == 0
          assert _pull_request(tmp_path) == {"branches": ["main"]}
          assert _NOTICE not in capsys.readouterr().err
      
      
      def test_path_filter_default_not_applied_over_explicit_flag(tmp_path, capsys):
          _existing_workflow(tmp_path, _FILTERED)
          assert main([str(tmp_path), *_FLAGS, "--paths", "src/**"]) == 0
          assert _pull_request(tmp_path) == {"branches": ["main"], "paths": ["src/**"]}
          assert _NOTICE not in capsys.readouterr().err
      
      
      @pytest.mark.parametrize("flag", ["--paths", "--paths-ignore", "--branch"])
      def test_main_flag_without_value_is_usage_error(tmp_path, capsys, flag):
          _existing_workflow(tmp_path, _FILTERED)
          assert main([str(tmp_path), "--version", "9.9.9", "--tools", "lizard", flag]) == 2
          assert f"{flag} needs a value" in capsys.readouterr().err
          assert not (tmp_path / ".github" / "workflows" / "assess-gate.yml").exists()
      
      
      @pytest.mark.parametrize("flag", ["--paths", "--paths-ignore"])
      def test_main_flag_followed_by_another_flag_is_usage_error(tmp_path, capsys, flag):
          # `--paths --branch main` must not emit a `paths: ['--branch']` filter.
          assert main([str(tmp_path), "--version", "9.9.9", "--tools", "lizard", flag, "--branch", "main"]) == 2
          assert f"{flag} needs a value" in capsys.readouterr().err
          assert not (tmp_path / ".github" / "workflows" / "assess-gate.yml").exists()
      
      
      def test_main_unquoted_glob_expansion_is_usage_error(tmp_path, capsys):
          # `--paths src/*` unquoted: the shell hands over src/a and src/b.
          assert main([str(tmp_path), *_FLAGS, "--paths", "src/a", "src/b"]) == 2
          assert "src/b" in capsys.readouterr().err
          assert not (tmp_path / ".github" / "workflows" / "assess-gate.yml").exists()
      
      
      @pytest.mark.parametrize("flag", ["--paths", "--paths-ignore"])
      @pytest.mark.parametrize("value", ["", "  "])
      def test_main_empty_filter_value_is_usage_error(tmp_path, capsys, flag, value):
          # `--paths ""` must not emit a `paths:` list holding one empty scalar, a
          # filter that parses but never fires.
          assert main([str(tmp_path), *_FLAGS, flag, value]) == 2
          assert f"{flag} needs a non-empty value" in capsys.readouterr().err
          assert not (tmp_path / ".github" / "workflows" / "assess-gate.yml").exists()
      
      
      @pytest.mark.parametrize("arg", ["--paths=src/**", "--paths-ignore=**/*.md", "--bogus"])
      def test_main_unknown_option_is_usage_error(tmp_path, capsys, arg):
          # The equals form was skipped silently, so the default paths-ignore was
          # written: the inverse of `--paths=src/**`.
          _existing_workflow(tmp_path, _FILTERED)
          assert main([str(tmp_path), *_FLAGS, arg]) == 2
          assert f"Unknown option {arg}" in capsys.readouterr().err
          assert not (tmp_path / ".github" / "workflows" / "assess-gate.yml").exists()
      
    • test_evidence_check.py 18 KB
      """Tests for lib/evidence_check.py - deterministic re-check of scorer evidence.
      
      Each evidence entry is re-checked with exists() or a literal substring search.
      A false entry lands in ``evidence_rejected`` with its kind and arguments intact
      (that is what "naming the entry" means); a true one lands in ``evidence``.
      """
      from __future__ import annotations
      
      import json
      import os
      import subprocess
      import sys
      from pathlib import Path
      
      import pytest
      
      from lib.evidence_check import check_evidence, is_referenced_in
      
      SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
      
      WORKFLOW = (
          "on: push\n"
          "jobs:\n"
          "  lint:\n"
          "    runs-on: ubuntu-latest\n"
          "    steps:\n"
          "      - run: bash scripts/check-x.sh\n"
      )
      
      
      @pytest.fixture
      def repo(tmp_path: Path) -> Path:
          (tmp_path / "docs").mkdir()
          (tmp_path / "scripts").mkdir()
          (tmp_path / ".github" / "workflows").mkdir(parents=True)
          (tmp_path / "docs" / "guide.md").write_text("# guide\n")
          (tmp_path / "scripts" / "check-x.sh").write_text("echo ok\n")
          (tmp_path / ".github" / "workflows" / "ci.yml").write_text(WORKFLOW)
          return tmp_path
      
      
      def test_path_absent_for_existing_file_is_rejected_and_named(repo: Path) -> None:
          entry = {"layer": 0, "kind": "path_absent", "path": "docs/guide.md"}
          result = check_evidence(repo, [entry])
          assert result["evidence"] == []
          assert len(result["evidence_rejected"]) == 1
          rejected = result["evidence_rejected"][0]
          assert rejected["kind"] == "path_absent"
          assert rejected["path"] == "docs/guide.md"
          assert rejected["layer"] == 0
          assert rejected["reason"]
      
      
      def test_not_referenced_in_for_script_a_workflow_calls_is_rejected(repo: Path) -> None:
          entry = {
              "layer": 7,
              "kind": "not_referenced_in",
              "needle": "scripts/check-x.sh",
              "path": ".github/workflows",
          }
          result = check_evidence(repo, [entry])
          assert result["evidence"] == []
          rejected = result["evidence_rejected"]
          assert [(e["kind"], e["path"], e["needle"]) for e in rejected] == [
              ("not_referenced_in", ".github/workflows", "scripts/check-x.sh")
          ]
      
      
      def test_all_true_input_passes_with_no_rejected_entries(repo: Path) -> None:
          entries = [
              {"layer": 0, "kind": "path_exists", "path": "docs/guide.md"},
              {"layer": 0, "kind": "path_absent", "path": "docs/missing.md"},
              {"layer": 7, "kind": "referenced_in", "needle": "scripts/check-x.sh",
               "path": ".github/workflows"},
              {"layer": 7, "kind": "not_referenced_in", "needle": "scripts/other.sh",
               "path": ".github/workflows"},
              {"layer": 0, "kind": "file_contains", "path": "docs/guide.md",
               "needle": "# guide"},
          ]
          result = check_evidence(repo, entries)
          assert result["evidence_rejected"] == []
          assert result["evidence"] == entries
      
      
      def test_one_false_entry_of_each_kind_is_rejected(repo: Path) -> None:
          entries = [
              {"layer": 0, "kind": "path_exists", "path": "docs/missing.md"},
              {"layer": 0, "kind": "path_absent", "path": "docs/guide.md"},
              {"layer": 7, "kind": "referenced_in", "needle": "scripts/other.sh",
               "path": ".github/workflows"},
              {"layer": 7, "kind": "not_referenced_in", "needle": "scripts/check-x.sh",
               "path": ".github/workflows"},
              {"layer": 0, "kind": "file_contains", "path": "docs/guide.md",
               "needle": "no such text"},
          ]
          result = check_evidence(repo, entries)
          assert result["evidence"] == []
          assert [e["kind"] for e in result["evidence_rejected"]] == [
              e["kind"] for e in entries
          ]
      
      
      def test_unknown_keys_pass_through_and_input_is_not_mutated(repo: Path) -> None:
          entry = {"layer": 0, "kind": "path_exists", "path": "docs/guide.md", "note": "x"}
          result = check_evidence(repo, [entry])
          assert result["evidence"] == [entry]
          bad = {"layer": 0, "kind": "path_exists", "path": "nope", "note": "y"}
          result = check_evidence(repo, [bad])
          assert result["evidence_rejected"][0]["note"] == "y"
          assert "reason" not in bad
      
      
      @pytest.mark.parametrize(
          ("entry", "reason"),
          [
              ({"layer": 0, "kind": "no_such_kind", "path": "docs/guide.md"}, "unknown kind"),
              ({"layer": 0, "kind": "path_exists"}, "missing path"),
              ({"layer": 7, "kind": "referenced_in", "path": ".github/workflows"}, "missing needle"),
              ({"layer": 0, "kind": "file_contains", "path": "docs/guide.md", "needle": ""},
               "missing needle"),
              ({"layer": 0, "kind": "path_exists", "path": "../outside.md"},
               "outside the repository root"),
              ({"layer": 0, "kind": "path_absent", "path": "/etc/passwd"},
               "outside the repository root"),
              ({"layer": 0, "kind": "file_contains", "path": "docs", "needle": "guide"},
               "not a file"),
              ({"layer": 7, "kind": "not_referenced_in", "needle": "x.sh",
                "path": ".github/nowhere"}, "does not exist"),
              ("not an object", "not an object"),
          ],
      )
      def test_malformed_or_unverifiable_entries_are_rejected(repo: Path, entry, reason: str) -> None:
          result = check_evidence(repo, [entry])
          assert result["evidence"] == []
          assert len(result["evidence_rejected"]) == 1
          assert reason in result["evidence_rejected"][0]["reason"]
      
      
      def test_named_path_through_a_symlinked_directory_out_of_the_repo_is_rejected(
          repo: Path, tmp_path_factory
      ) -> None:
          outside = tmp_path_factory.mktemp("outside")
          (outside / "notes.md").write_text("scripts/secret.sh\n")
          (repo / "docs" / "ext").symlink_to(outside, target_is_directory=True)
          entries = [
              {"layer": 0, "kind": "path_exists", "path": "docs/ext/notes.md"},
              {"layer": 0, "kind": "file_contains", "path": "docs/ext/notes.md",
               "needle": "scripts/secret.sh"},
              {"layer": 7, "kind": "referenced_in", "needle": "scripts/secret.sh",
               "path": "docs/ext"},
          ]
          result = check_evidence(repo, entries)
          assert result["evidence"] == []
          assert all("outside the repository root" in e["reason"]
                     for e in result["evidence_rejected"])
          assert is_referenced_in(repo, "scripts/secret.sh", "docs/ext") is False
      
      
      def test_lone_surrogate_needle_is_rejected_not_raised(repo: Path) -> None:
          [needle] = json.loads('["\\ud800"]')
          entries = [
              {"layer": 7, "kind": kind, "needle": needle, "path": path}
              for kind, path in (("referenced_in", ".github/workflows"),
                                 ("not_referenced_in", ".github/workflows"),
                                 ("file_contains", "docs/guide.md"))
          ]
          result = check_evidence(repo, entries)
          assert result["evidence"] == []
          assert all("not valid text" in e["reason"] for e in result["evidence_rejected"])
          assert is_referenced_in(repo, needle, ".github/workflows") is False
      
      
      def test_is_referenced_in_searches_a_directory_or_a_single_file(repo: Path) -> None:
          assert is_referenced_in(repo, "scripts/check-x.sh", ".github/workflows") is True
          assert is_referenced_in(repo, "scripts/other.sh", ".github/workflows") is False
          assert is_referenced_in(repo, "scripts/check-x.sh", ".github/workflows/ci.yml") is True
          assert is_referenced_in(repo, "scripts/check-x.sh", ".github/missing") is False
          nested = repo / ".github" / "workflows" / "sub"
          nested.mkdir()
          (nested / "deep.yml").write_text("run: scripts/deep.sh\n")
          assert is_referenced_in(repo, "scripts/deep.sh", ".github/workflows") is True
      
      
      def test_cli_writes_both_lists_to_the_json_out_file(repo: Path, tmp_path_factory) -> None:
          out_dir = tmp_path_factory.mktemp("out")
          ev = out_dir / "ev.json"
          out = out_dir / "out.json"
          ev.write_text(json.dumps([
              {"layer": 0, "kind": "path_exists", "path": "docs/guide.md"},
              {"layer": 0, "kind": "path_absent", "path": "docs/guide.md"},
          ]))
          proc = subprocess.run(
              [sys.executable, "-m", "lib.evidence_check", str(repo), str(ev),
               "--json", str(out)],
              cwd=SCRIPTS_DIR, capture_output=True, text=True,
          )
          assert proc.returncode == 1
          assert "path_absent" in proc.stdout
          data = json.loads(out.read_text())
          assert [e["kind"] for e in data["evidence"]] == ["path_exists"]
          assert [e["kind"] for e in data["evidence_rejected"]] == ["path_absent"]
      
      
      def test_cli_refuses_input_that_is_not_a_flat_array(repo: Path, tmp_path_factory) -> None:
          out_dir = tmp_path_factory.mktemp("out")
          ev = out_dir / "ev.json"
          ev.write_text(json.dumps({"evidence": []}))
          proc = subprocess.run(
              [sys.executable, "-m", "lib.evidence_check", str(repo), str(ev),
               "--json", str(out_dir / "out.json")],
              cwd=SCRIPTS_DIR, capture_output=True, text=True,
          )
          assert proc.returncode == 2
          assert not (out_dir / "out.json").exists()
      
      
      def test_path_with_nul_is_rejected_not_raised(repo: Path) -> None:
          entries = [
              {"layer": 0, "kind": "path_exists", "path": "docs/\0guide.md"},
              {"layer": 7, "kind": "referenced_in", "needle": "x", "path": ".github\0"},
          ]
          result = check_evidence(repo, entries)
          assert result["evidence"] == []
          assert len(result["evidence_rejected"]) == 2
          assert is_referenced_in(repo, "x", ".github\0") is False
      
      
      def test_reference_search_does_not_enter_git_metadata(repo: Path) -> None:
          (repo / ".git").mkdir()
          (repo / ".git" / "config").write_text("scripts/check-x.sh\n")
          assert is_referenced_in(repo, "scripts/check-x.sh", ".git") is False
          assert is_referenced_in(repo, "scripts/check-x.sh", ".git/config") is False
          for kind in ("referenced_in", "not_referenced_in"):
              result = check_evidence(
                  repo, [{"layer": 7, "kind": kind, "needle": "x", "path": ".git/config"}]
              )
              assert result["evidence"] == [], kind
      
      
      @pytest.mark.skipif(sys.platform == "win32" or not hasattr(os, "geteuid") or os.geteuid() == 0,
                          reason="permission bits do not restrict root or Windows")
      def test_not_referenced_in_is_rejected_when_the_search_is_incomplete(repo: Path) -> None:
          hidden = repo / ".github" / "workflows" / "locked"
          hidden.mkdir()
          (hidden / "ci.yml").write_text("run: scripts/other.sh\n")
          unreadable = repo / ".github" / "workflows" / "unreadable.yml"
          unreadable.write_text("run: scripts/third.sh\n")
          hidden.chmod(0)
          unreadable.chmod(0)
          try:
              for needle in ("scripts/other.sh", "scripts/third.sh"):
                  entry = {"layer": 7, "kind": "not_referenced_in", "needle": needle,
                           "path": ".github/workflows"}
                  result = check_evidence(repo, [entry])
                  assert result["evidence"] == [], needle
                  assert "could not be searched" in result["evidence_rejected"][0]["reason"]
              # A match found elsewhere still verifies a positive claim.
              ok = {"layer": 7, "kind": "referenced_in", "needle": "scripts/check-x.sh",
                    "path": ".github/workflows"}
              assert check_evidence(repo, [ok])["evidence"] == [ok]
          finally:
              hidden.chmod(0o755)
              unreadable.chmod(0o644)
      
      
      def _not_referenced(needle: str, path: str = ".github/workflows") -> dict:
          return {"layer": 7, "kind": "not_referenced_in", "needle": needle, "path": path}
      
      
      def test_walk_skips_symlinks_out_of_the_repo(repo: Path, tmp_path_factory) -> None:
          outside = tmp_path_factory.mktemp("outside")
          (outside / "hosts").write_text("scripts/secret.sh\n")
          (repo / ".github" / "workflows" / "link.yml").symlink_to(outside / "hosts")
          (repo / ".github" / "workflows" / "linkdir").symlink_to(outside, target_is_directory=True)
          (repo / ".github" / "workflows" / "dangling.yml").symlink_to(repo / "no-such-file")
          assert is_referenced_in(repo, "scripts/secret.sh", ".github/workflows") is False
          entry = _not_referenced("scripts/secret.sh")
          # Content outside the root is not repository content: the claim holds.
          assert check_evidence(repo, [entry])["evidence"] == [entry]
      
      
      @pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="no FIFOs on this platform")
      def test_fifo_under_the_path_makes_a_negative_claim_incomplete(repo: Path) -> None:
          os.mkfifo(repo / ".github" / "workflows" / "pipe")
          result = check_evidence(repo, [_not_referenced("scripts/other.sh")])
          assert result["evidence"] == []
          assert "could not be searched" in result["evidence_rejected"][0]["reason"]
          # Named directly as the path, the FIFO is incomplete too, never an empty search.
          named = check_evidence(repo, [
              _not_referenced("scripts/other.sh", ".github/workflows/pipe"),
              {"layer": 7, "kind": "referenced_in", "needle": "scripts/other.sh",
               "path": ".github/workflows/pipe"},
          ])
          assert named["evidence"] == []
          assert all("could not be searched" in e["reason"] for e in named["evidence_rejected"])
          assert is_referenced_in(repo, "scripts/other.sh", ".github/workflows/pipe") is False
          # The FIFO is never opened, and a match elsewhere still verifies.
          ok = {"layer": 7, "kind": "referenced_in", "needle": "scripts/check-x.sh",
                "path": ".github/workflows"}
          assert check_evidence(repo, [ok])["evidence"] == [ok]
      
      
      def test_symlinked_file_inside_the_repo_is_searched_at_its_target(repo: Path) -> None:
          (repo / "ci-shared").mkdir()
          (repo / "ci-shared" / "deploy.yml").write_text("run: scripts/deploy.sh\n")
          (repo / ".github" / "workflows" / "shared.yml").symlink_to(
              repo / "ci-shared" / "deploy.yml")
          assert is_referenced_in(repo, "scripts/deploy.sh", ".github/workflows") is True
          result = check_evidence(repo, [_not_referenced("scripts/deploy.sh")])
          assert "needle found" in result["evidence_rejected"][0]["reason"]
      
      
      def test_symlinked_directory_inside_the_repo_makes_a_negative_claim_incomplete(
          repo: Path,
      ) -> None:
          (repo / "ci-shared").mkdir()
          (repo / "ci-shared" / "deploy.yml").write_text("run: scripts/deploy.sh\n")
          (repo / ".github" / "workflows" / "shared").symlink_to(
              repo / "ci-shared", target_is_directory=True)
          result = check_evidence(repo, [_not_referenced("scripts/deploy.sh")])
          assert result["evidence"] == []
          assert "could not be searched" in result["evidence_rejected"][0]["reason"]
          # A link back into the directory already being searched adds nothing unread.
          (repo / ".github" / "workflows" / "shared").unlink()
          (repo / ".github" / "workflows" / "self").symlink_to(
              repo / ".github" / "workflows", target_is_directory=True)
          entry = _not_referenced("scripts/deploy.sh")
          assert check_evidence(repo, [entry])["evidence"] == [entry]
      
      
      def test_walk_does_not_read_previous_assess_output(repo: Path) -> None:
          (repo / ".assess").mkdir()
          (repo / ".assess" / "assess-report.md").write_text("scripts/stale.sh is not wired\n")
          entry = _not_referenced("scripts/stale.sh", ".")
          assert check_evidence(repo, [entry])["evidence"] == [entry]
          # Naming .assess directly still searches it.
          assert is_referenced_in(repo, "scripts/stale.sh", ".assess") is True
      
      
      def test_needle_spanning_a_read_chunk_boundary_is_found(repo: Path) -> None:
          from lib import evidence_check
      
          needle = "scripts/boundary.sh"
          pad = "x" * (evidence_check._CHUNK - 5)
          (repo / "docs" / "big.txt").write_text(pad + needle + "\n")
          assert is_referenced_in(repo, needle, "docs") is True
          assert is_referenced_in(repo, needle, "docs/big.txt") is True
      
      
      def test_root_that_is_not_a_directory_verifies_nothing(tmp_path: Path) -> None:
          missing = tmp_path / "no-such-root"
          entry = {"layer": 0, "kind": "path_absent", "path": "docs/guide.md"}
          result = check_evidence(missing, [entry])
          assert result["evidence"] == []
          assert "not a directory" in result["evidence_rejected"][0]["reason"]
      
      
      def test_cli_refuses_a_root_that_is_not_a_directory(tmp_path: Path) -> None:
          ev = tmp_path / "ev.json"
          ev.write_text(json.dumps([{"layer": 0, "kind": "path_absent", "path": "x"}]))
          proc = subprocess.run(
              [sys.executable, "-m", "lib.evidence_check", str(tmp_path / "nope"), str(ev),
               "--json", str(tmp_path / "out.json")],
              cwd=SCRIPTS_DIR, capture_output=True, text=True, timeout=30,
          )
          assert proc.returncode == 2
          assert not (tmp_path / "out.json").exists()
      
      
      def test_cli_refuses_evidence_that_is_not_utf8(repo: Path, tmp_path_factory) -> None:
          out_dir = tmp_path_factory.mktemp("out")
          ev = out_dir / "ev.json"
          ev.write_bytes(b'[{"layer": 0, "kind": "path_exists", "path": "docs/\xff.md"}]')
          proc = subprocess.run(
              [sys.executable, "-m", "lib.evidence_check", str(repo), str(ev),
               "--json", str(out_dir / "out.json")],
              cwd=SCRIPTS_DIR, capture_output=True, text=True, timeout=30,
          )
          assert proc.returncode == 2, proc.stderr
          assert "Traceback" not in proc.stderr
          assert not (out_dir / "out.json").exists()
      
      
      def test_symlinks_into_git_metadata_are_not_searched(repo: Path) -> None:
          (repo / ".git").mkdir()
          (repo / ".git" / "config").write_text("scripts/hidden.sh\n")
          (repo / "docs" / "gitlink").symlink_to(repo / ".git", target_is_directory=True)
          (repo / "config-link").symlink_to(repo / ".git" / "config")
          (repo / ".github" / "workflows" / "cfg.yml").symlink_to(repo / ".git" / "config")
          for path in ("docs/gitlink", "config-link", ".github/workflows"):
              assert is_referenced_in(repo, "scripts/hidden.sh", path) is False, path
          named = check_evidence(repo, [
              {"layer": 7, "kind": "referenced_in", "needle": "x", "path": "docs/gitlink"},
              {"layer": 7, "kind": "not_referenced_in", "needle": "x", "path": "config-link"},
          ])
          assert named["evidence"] == []
          assert all(".git/" in e["reason"] for e in named["evidence_rejected"])
          walked = _not_referenced("scripts/hidden.sh")
          assert check_evidence(repo, [walked])["evidence"] == [walked]
      
      
      def test_cli_exits_2_when_the_json_output_cannot_be_written(repo: Path, tmp_path_factory) -> None:
          out_dir = tmp_path_factory.mktemp("out")
          ev = out_dir / "ev.json"
          ev.write_text(json.dumps([{"layer": 0, "kind": "path_absent", "path": "docs/guide.md"}]))
          proc = subprocess.run(
              [sys.executable, "-m", "lib.evidence_check", str(repo), str(ev),
               "--json", str(out_dir / "missing-dir" / "out.json")],
              cwd=SCRIPTS_DIR, capture_output=True, text=True, timeout=30,
          )
          assert proc.returncode == 2, proc.stderr
          assert "Traceback" not in proc.stderr
      
    • test_gap_actions.py 3.4 KB
      """Unit tests for lib.gap_actions: the deterministic Top 3 gap candidates."""
      from __future__ import annotations
      
      from lib.gap_actions import REACHABILITY_FLOOR, build_gap_actions
      
      SOFTWARE = {"available": True, "archetype": "software"}
      KB = {"available": True, "archetype": "knowledge-base"}
      NO_COVERAGE = {"available": False, "source": "none found"}
      COVERAGE = {"available": True, "source": "lcov.info", "format": "lcov", "parsed": True}
      HOTSPOTS = [{"path": p, "loc": 2, "ccn": 1.0, "commits": 12}
                  for p in ("src/zeta.py", "src/mid.py", "src/a.py", "src/b.py")]
      
      
      def _docs(pct: float, doc_count: int = 10, unreachable: list[str] | None = None) -> dict:
          return {"available": True, "doc_count": doc_count, "reachability_pct": pct,
                  "unreachable": unreachable or []}
      
      
      def test_gap_actions_floor_is_one_half() -> None:
          assert REACHABILITY_FLOOR == 0.5
      
      
      def test_gap_actions_coverage_entry_names_top_hotspots() -> None:
          gaps = build_gap_actions(NO_COVERAGE, _docs(1.0), HOTSPOTS, SOFTWARE)
          assert len(gaps) == 1
          assert gaps[0]["signal"] == "coverage_report"
          assert gaps[0]["action"]
          assert gaps[0]["paths"] == ["src/zeta.py", "src/mid.py", "src/a.py"]
      
      
      def test_gap_actions_empty_when_coverage_present_and_docs_reachable() -> None:
          assert build_gap_actions(COVERAGE, _docs(0.8), HOTSPOTS, SOFTWARE) == []
      
      
      def test_gap_actions_reachability_entry_below_floor() -> None:
          gaps = build_gap_actions(COVERAGE, _docs(0.3, unreachable=["docs/z.md", "docs/a.md"]), HOTSPOTS, SOFTWARE)
          assert [g["signal"] for g in gaps] == ["doc_graph"]
          assert gaps[0]["paths"] == ["docs/a.md", "docs/z.md"]
          assert "30%" in gaps[0]["action"]
      
      
      def test_gap_actions_reachability_at_floor_does_not_fire() -> None:
          assert build_gap_actions(COVERAGE, _docs(REACHABILITY_FLOOR), HOTSPOTS, SOFTWARE) == []
      
      
      def test_gap_actions_coverage_comes_before_reachability() -> None:
          gaps = build_gap_actions(NO_COVERAGE, _docs(0.1), HOTSPOTS, SOFTWARE)
          assert [g["signal"] for g in gaps] == ["coverage_report", "doc_graph"]
      
      
      def test_gap_actions_no_markdown_is_not_a_reachability_gap() -> None:
          """A repo with no docs reports reachability 0.0 with available true: nothing
          is unreachable, so no link-the-docs action fires."""
          no_docs = {"available": True, "reason": "no markdown docs found",
                     "doc_count": 0, "reachability_pct": 0.0}
          assert build_gap_actions(COVERAGE, no_docs, HOTSPOTS, SOFTWARE) == []
      
      
      def test_gap_actions_unavailable_doc_graph_is_not_a_gap() -> None:
          assert build_gap_actions(COVERAGE, {"available": False, "reachability_pct": 0.0}, HOTSPOTS, SOFTWARE) == []
      
      
      def test_gap_actions_missing_blocks_degrade_to_empty() -> None:
          assert build_gap_actions(None, None, None, None) == []
      
      
      def test_gap_actions_no_coverage_entry_on_knowledge_base() -> None:
          assert build_gap_actions(NO_COVERAGE, _docs(1.0), HOTSPOTS, KB) == []
      
      
      def test_gap_actions_no_coverage_entry_without_hotspots() -> None:
          assert build_gap_actions(NO_COVERAGE, _docs(1.0), [], SOFTWARE) == []
      
      
      def test_gap_actions_coverage_entry_skips_archive_hotspots() -> None:
          hot = [{"path": "archive/legacy_service.py"}, {"path": "attic/old.py"}, *HOTSPOTS]
          gaps = build_gap_actions(NO_COVERAGE, _docs(1.0), hot, SOFTWARE)
          assert gaps[0]["paths"] == ["src/zeta.py", "src/mid.py", "src/a.py"]
          only_archive = [{"path": "archive/legacy_service.py"}]
          assert build_gap_actions(NO_COVERAGE, _docs(1.0), only_archive, SOFTWARE) == []
      
    • test_gate_cost.py 5.9 KB
      """Tests for lib/gate_cost.py: the CI gate's Actions cost estimate.
      
      GitHub is faked with a `gh` shell script first on PATH (the pattern in
      test_config_drift.py), so the real subprocess path through lib/gh_cli.py runs.
      """
      from __future__ import annotations
      
      import json
      import os
      import shutil
      import stat
      import subprocess
      import sys
      from datetime import datetime, timedelta, timezone
      from pathlib import Path
      
      import pytest
      
      sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
      
      from lib.gate_cost import MINUTES_PER_RUN, estimate_gate_cost  # noqa: E402
      
      FAKE_GH = """#!/bin/sh
      echo "$*" >> "$FAKE_GH/log"
      serve() { [ -f "$FAKE_GH/$1" ] && cat "$FAKE_GH/$1" && exit 0; }
      case "$1:$*" in
        auth:*) [ -f "$FAKE_GH/noauth" ] || exit 0 ;;
        repo:*) serve repo.json ;;
        pr:*) serve prs.json ;;
      esac
      cat "$FAKE_GH/fail" >&2
      exit 1
      """
      
      NOW = datetime(2026, 9, 19, 12, 0, tzinfo=timezone.utc)
      
      
      def _git(repo: Path, *args: str) -> None:
          env = {**os.environ, "GIT_CONFIG_GLOBAL": "/dev/null"}
          subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, env=env)
      
      
      def _merged(n: int, when: datetime) -> list[dict]:
          stamp = when.strftime("%Y-%m-%dT%H:%M:%SZ")
          return [{"number": i + 1, "mergedAt": stamp, "title": f"t{i}"} for i in range(n)]
      
      
      @pytest.fixture
      def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
          repo = tmp_path / "repo"
          repo.mkdir()
          _git(repo, "init", "-q", "-b", "main")
          _git(repo, "remote", "add", "origin", "https://github.com/acme/widget.git")
      
          bindir, ghdir = tmp_path / "bin", tmp_path / "gh"
          bindir.mkdir()
          ghdir.mkdir()
          gh = bindir / "gh"
          gh.write_text(FAKE_GH)
          gh.chmod(gh.stat().st_mode | stat.S_IEXEC)
          (ghdir / "log").write_text("")
          (ghdir / "fail").write_text("gh: Not Found (HTTP 404)\n")
          (ghdir / "repo.json").write_text(json.dumps({"nameWithOwner": "acme/widget", "isPrivate": True}))
          monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ['PATH']}")
          monkeypatch.setenv("FAKE_GH", str(ghdir))
      
          class World:
              root = repo
              gh_dir = ghdir
      
              def serve(self, name: str, doc: object) -> None:
                  (ghdir / name).write_text(json.dumps(doc))
      
              def calls(self) -> list[str]:
                  return (ghdir / "log").read_text().splitlines()
      
          return World()
      
      
      def test_gate_cost_forty_merged(world) -> None:
          world.serve("prs.json", _merged(40, NOW - timedelta(days=1)))
          block = estimate_gate_cost(world.root, now=NOW)
          assert block["available"] is True
          assert block["runs_per_month"] == 40
          assert block["minutes_per_run"] == MINUTES_PER_RUN == 5
          assert block["minutes_per_month"] == 200
          assert block["private"] is True
          assert "assum" in block["assumption"].lower() and "5 minutes" in block["assumption"]
          pr_call = next(c for c in world.calls() if c.startswith("pr list"))
          assert "--state merged" in pr_call and "--limit" in pr_call
          assert "--jq" not in pr_call and "--template" not in pr_call
      
      
      def test_gate_cost_counts_only_the_last_thirty_days(world) -> None:
          world.serve("prs.json", _merged(3, NOW - timedelta(days=2)) + _merged(5, NOW - timedelta(days=45)))
          block = estimate_gate_cost(world.root, now=NOW)
          assert block["runs_per_month"] == 3
          assert block["minutes_per_month"] == 15
      
      
      def test_gate_cost_no_merge_history(world) -> None:
          world.serve("prs.json", [])
          block = estimate_gate_cost(world.root, now=NOW)
          assert block["available"] is False
          assert block["reason"].startswith("no_merge_history")
      
      
      def test_gate_cost_no_gh(world, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
          # PATH holds git and nothing else: the remote resolves, gh is missing.
          only_git = tmp_path / "only-git"
          only_git.mkdir()
          git = shutil.which("git")
          assert git is not None
          (only_git / "git").symlink_to(git)
          monkeypatch.setenv("PATH", str(only_git))
          block = estimate_gate_cost(world.root, now=NOW)
          assert block == {"available": False, "reason": block["reason"]}
          assert block["reason"].startswith("gh_not_installed")
      
      
      def test_gate_cost_unauthenticated(world) -> None:
          (world.gh_dir / "noauth").write_text("")
          world.serve("prs.json", _merged(40, NOW))
          block = estimate_gate_cost(world.root, now=NOW)
          assert block["available"] is False
          assert block["reason"].startswith("not_authenticated")
          assert not any(c.startswith("pr ") for c in world.calls())
      
      
      def test_gate_cost_no_remote_never_calls_gh(world) -> None:
          _git(world.root, "remote", "remove", "origin")
          block = estimate_gate_cost(world.root, now=NOW)
          assert block["available"] is False
          assert block["reason"].startswith("no_remote")
          assert world.calls() == []
      
      
      def test_gate_cost_private_unknown_when_repo_view_fails(world) -> None:
          (world.gh_dir / "repo.json").unlink()
          world.serve("prs.json", _merged(2, NOW))
          block = estimate_gate_cost(world.root, now=NOW)
          assert block["available"] is True
          assert block["private"] is None
      
      
      def test_gate_cost_capped_at_listing_limit(world, monkeypatch: pytest.MonkeyPatch) -> None:
          import lib.gate_cost as gate_cost
      
          monkeypatch.setattr(gate_cost, "PR_LIMIT", 3)
          world.serve("prs.json", _merged(3, NOW))
          block = estimate_gate_cost(world.root, now=NOW)
          assert block["capped"] is True
          assert "at least 3 merged" in block["assumption"]
          pr_call = next(c for c in world.calls() if c.startswith("pr list"))
          assert "--limit 3" in pr_call
      
      
      def test_gate_cost_not_capped_below_limit(world) -> None:
          world.serve("prs.json", _merged(2, NOW))
          block = estimate_gate_cost(world.root, now=NOW)
          assert block["capped"] is False
          assert "at least" not in block["assumption"]
      
      
      def test_gate_cost_non_list_answer_degrades(world) -> None:
          world.serve("prs.json", {"message": "unexpected"})
          block = estimate_gate_cost(world.root, now=NOW)
          assert block["available"] is False
          assert block["reason"].startswith("gh_bad_json")
      
    • test_generated_files.py 6.8 KB
      """Tests for lib/generated_files.py: the header sniff and long-line detector."""
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      import pytest
      
      sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
      
      from lib.generated_files import (  # noqa: E402
          LONG_LINE_THRESHOLD,
          matches_generated_name,
          average_line_length,
          generated_reason,
          has_generated_header,
          is_long_line_artifact,
      )
      
      
      @pytest.mark.parametrize("marker", [
          "-- GENERATED FILE - DO NOT EDIT",
          "// Code generated by protoc-gen-go. DO NOT EDIT.",
          "# @generated by buck",
          "/* This file is auto-generated */",
          "// AUTOGENERATED - regenerate with make",
          "// AUTO-GENERATED by tool",
          "# auto generated from schema.yaml",
          "// do not edit: owned by the schema tool",
      ])
      def test_generated_header_markers_match_case_insensitively(tmp_path, marker):
          f = tmp_path / "x.sql"
          f.write_text(f"{marker}\nSELECT 1;\n")
          assert has_generated_header(f) is True
          assert generated_reason(f) == "generated-header"
      
      
      @pytest.mark.parametrize("line", [
          "generated file whose name looks ordinary, such as a schema dump",
          "Writes the generated file; do not edit it by hand, rerun make.",
          "    return render(autogenerated=True)",
      ])
      def test_generated_header_prose_without_comment_leader_is_ignored(tmp_path, line):
          f = tmp_path / "codegen.py"
          q = '"' * 3
          f.write_text(q + "Codegen driver.\n\n" + line + "\n" + q + "\n")
          assert has_generated_header(f) is False
      
      
      @pytest.mark.parametrize("first_line,expected", [
          ('"""Writes the generated file for the API client."""', False),
          ("# Loads the generated file and patches it", False),
          ("-- GENERATED FILE - regenerate with make schema", True),
          ("/* Generated file */", True),
          ("# === GENERATED FILE ===", True),
      ])
      def test_generated_header_generated_file_only_as_banner(tmp_path, first_line, expected):
          f = tmp_path / "x.py"
          f.write_text(first_line + "\nA = 1\n")
          assert has_generated_header(f) is expected
      
      
      def test_long_line_small_file_skips_read(tmp_path, monkeypatch):
          f = tmp_path / "small.py"
          f.write_text("x = 1\n")
          import lib.generated_files as mod
          monkeypatch.setattr(mod, "average_line_length",
                              lambda p: (_ for _ in ()).throw(AssertionError("read")))
          assert mod.is_long_line_artifact(f) is False
      
      
      def test_generated_header_markdown_bullet_is_not_a_comment(tmp_path):
          f = tmp_path / "CHANGELOG.md"
          f.write_text("Release notes\n\n* Auto-generated release notes now include PRs\n"
                       "* Do not edit this table by hand\n")
          assert has_generated_header(f) is False
      
      
      def test_generated_header_markdown_heading_is_not_a_comment(tmp_path):
          md = tmp_path / "guide.md"
          md.write_text("# Do not edit these by hand\n\n## Auto-generated sections\n")
          assert has_generated_header(md) is False
          marked = tmp_path / "api.md"
          marked.write_text("<!-- DO NOT EDIT: generated by docgen -->\n# API\n")
          assert has_generated_header(marked) is True
      
      
      def test_generated_header_after_utf8_bom_matches(tmp_path):
          f = tmp_path / "Client.cs"
          f.write_bytes("\ufeff// <auto-generated/>\nclass C {}\n".encode("utf-8"))
          assert has_generated_header(f) is True
      
      
      def test_generated_header_jsdoc_and_docstring_leaders_match(tmp_path):
          js = tmp_path / "a.js"
          js.write_text("/**\n * @generated\n */\nexport const a = 1;\n")
          py = tmp_path / "b.py"
          q = '"' * 3
          py.write_text(q + "Autogenerated by schema2py." + q + "\nA = 1\n")
          assert has_generated_header(js) is True
          assert has_generated_header(py) is True
      
      
      def test_generated_header_on_line_five_matches(tmp_path):
          f = tmp_path / "x.py"
          f.write_text("a = 1\nb = 2\nc = 3\nd = 4\n# DO NOT EDIT\ne = 5\n")
          assert has_generated_header(f) is True
      
      
      def test_generated_header_after_a_long_first_line_matches(tmp_path):
          f = tmp_path / "bundle.js"
          f.write_text("var a = '" + "x" * 100_000 + "';\n// @generated by bundler\nvar b = 1;\n")
          assert has_generated_header(f) is True
      
      
      def test_generated_header_after_line_five_is_ignored(tmp_path):
          f = tmp_path / "hand.py"
          body = ["def route(x):"] + [f"# note {i}" for i in range(198)]
          body.append("# do not edit the routing table above without updating the docs")
          f.write_text("\n".join(body) + "\n")
          assert has_generated_header(f) is False
          assert generated_reason(f) is None
      
      
      def test_generated_header_unreadable_file_is_not_excluded(tmp_path):
          assert has_generated_header(tmp_path / "missing.sql") is False
          assert generated_reason(tmp_path / "missing.sql") is None
      
      
      def test_long_line_payload_is_excluded(tmp_path):
          f = tmp_path / "font.ts"
          f.write_text('export const FONT = "' + "A" * 40000 + '";\nexport default FONT;\n')
          assert average_line_length(f) > 20000
          assert is_long_line_artifact(f) is True
          assert generated_reason(f) == "long-lines"
      
      
      def test_long_line_threshold_keeps_wide_hand_written_code(tmp_path):
          f = tmp_path / "wide.py"
          f.write_text("\n".join(["    x = x + 1  # " + "p" * 85] * 20) + "\n")
          assert 90 < average_line_length(f) < 110
          assert is_long_line_artifact(f) is False
      
      
      def test_long_line_threshold_calibration_band():
          # Above a JSONL fixture (296 chars/line, kept), below a hand-built
          # HTML explainer page with inline data (3,817 chars/line, excluded).
          assert 296 < LONG_LINE_THRESHOLD < 3817  # strict: is_long_line_artifact uses >
      
      
      def test_long_line_measures_characters_not_bytes(tmp_path):
          f = tmp_path / "cjk.py"
          f.write_text("\n".join(["# " + "\u6f22" * 600] * 3) + "\n", encoding="utf-8")
          assert average_line_length(f) < LONG_LINE_THRESHOLD
          assert is_long_line_artifact(f) is False
      
      
      def test_long_line_counts_unterminated_last_line(tmp_path):
          f = tmp_path / "one.txt"
          f.write_bytes(b"abcd")
          assert average_line_length(f) == 4.0
          assert average_line_length(tmp_path / "gone.txt") == 0.0
      
      
      def test_generated_header_sniff_spares_its_own_module():
          """The module documents the markers; they must stay out of its first lines."""
          import lib.generated_files as mod
          assert has_generated_header(Path(mod.__file__)) is False
          assert generated_reason(Path(mod.__file__)) is None
      
      
      def test_long_line_average_uses_capped_head(tmp_path):
          f = tmp_path / "big.txt"
          # ~1.6 MB: a short-line first 1 MB followed by one enormous final line.
          f.write_text(("y" * 99 + "\n") * 10500 + "z" * 600000)
          assert average_line_length(f) < LONG_LINE_THRESHOLD
      
      
      
      @pytest.mark.parametrize("path,expected", [
          ("web/src/database.types.ts", True),
          ("api.generated.ts", True),
          ("schema.generated.sql", True),
          ("client.gen.ts", True),
          ("src/types.ts", False),
          ("generated/readme.md", False),
      ])
      def test_generated_header_free_name_patterns(path, expected):
          assert matches_generated_name(path) is expected
      
    • test_git_churn.py 2.7 KB
      """Contract tests for the churn-degeneracy detector (issue #172).
      
      `churn_is_degenerate` is the single source of truth that lets every downstream
      consumer (doc->complexity join, keyhole summary, treemap) tell a meaningless
      churn signal from a real one. A degenerate window - every file ~1 commit, the
      fingerprint of a shallow clone / fresh import / squashed or extracted history -
      must be flagged so a precise doc->code association can no longer stamp a
      high-confidence finding onto pure extraction artifact.
      """
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
      
      from lib.git_churn import churn_is_degenerate  # noqa: E402
      
      
      def test_all_ones_is_degenerate() -> None:
          """The canonical artifact: every file shows exactly one commit."""
          assert churn_is_degenerate([1] * 1979) is True
      
      
      def test_single_outlier_among_ones_still_degenerate() -> None:
          """One genuinely-churned file among thousands of single-commit files does
          not rescue the signal - p95 (not max) reports on the flat bulk."""
          counts = [1] * 1978 + [50]
          assert churn_is_degenerate(counts) is True
      
      
      def test_zero_commit_files_are_ignored() -> None:
          """Idle files (0 commits in the window) don't count toward the distribution:
          degeneracy is the shape of the activity among files that actually moved."""
          counts = [0] * 500 + [1] * 10
          assert churn_is_degenerate(counts) is True
      
      
      def test_genuine_variance_is_not_degenerate() -> None:
          """A real history with a spread of commits-per-file carries signal."""
          counts = list(range(1, 60))  # 1..59, p95 well above 1
          assert churn_is_degenerate(counts) is False
      
      
      def test_uniform_high_count_is_not_degenerate() -> None:
          """Flat-but-active (every file committed several times) still has p95 > 1, so
          it is not the single-commit artifact this guards against."""
          assert churn_is_degenerate([5] * 100) is False
      
      
      def test_too_few_active_files_is_not_called() -> None:
          """Below the minimum active-file count the distribution is too small to judge
          - a tiny utility repo where each file shows one commit is not the
          shallow-clone artifact, so we don't flatten its churn signal."""
          assert churn_is_degenerate([1, 1, 1]) is False
          assert churn_is_degenerate([]) is False
      
      
      def test_at_minimum_active_files_all_ones_is_degenerate() -> None:
          """Exactly at the threshold, an all-ones distribution is degenerate."""
          assert churn_is_degenerate([1, 1, 1, 1, 1]) is True
      
      
      def test_accepts_any_iterable() -> None:
          """Consumers pass a generator of churn-map values; the detector consumes it
          once without requiring a materialised list."""
          assert churn_is_degenerate(c for c in [1, 1, 1, 1, 1, 1]) is True
      
    • test_git_commit_info.py 3.8 KB
      """Tests for git_churn.git_commit_info - the measured-commit snapshot that lets
      the /assess report pin its absolute LOC/CCN figures to a SHA and warn when the
      snapshot is stale (issue #59)."""
      from __future__ import annotations
      
      import importlib.util
      from pathlib import Path
      
      _LIB = Path(__file__).resolve().parents[1] / "scripts" / "lib" / "git_churn.py"
      _spec = importlib.util.spec_from_file_location("git_churn", _LIB)
      git_churn = importlib.util.module_from_spec(_spec)
      _spec.loader.exec_module(git_churn)
      
      
      def test_returns_unavailable_outside_git_repo(tmp_path):
          """A plain directory (no .git) degrades to available:False with a reason,
          so the report omits the snapshot line rather than inventing a SHA."""
          info = git_churn.git_commit_info(tmp_path)
          assert info["available"] is False
          assert "reason" in info
      
      
      def test_pins_head_sha_and_clean_tree(git_repo):
          repo, commit = git_repo
          (repo / "a.py").write_text("x = 1\n", encoding="utf-8")
          commit("initial commit")
      
          info = git_churn.git_commit_info(repo)
          assert info["available"] is True
          assert len(info["head_sha"]) == 40
          assert info["head_short"] == info["head_sha"][:12]
          assert info["subject"] == "initial commit"
          assert info["committed_date"]  # ISO short date, non-empty
          # Clean working tree, and a fresh repo has no upstream configured.
          assert info["dirty"] is False
          assert info["upstream"] is None
          assert info["behind"] is None
      
      
      def test_flags_dirty_working_tree(git_repo):
          """Uncommitted edits to a tracked file mean the measured numbers reflect
          the working tree, not HEAD - the report must warn on this."""
          repo, commit = git_repo
          (repo / "a.py").write_text("x = 1\n", encoding="utf-8")
          commit("initial commit")
          # Modify the tracked file without committing.
          (repo / "a.py").write_text("x = 2\nprint(x)\n", encoding="utf-8")
      
          info = git_churn.git_commit_info(repo)
          assert info["dirty"] is True
      
      
      def test_untracked_file_does_not_mark_dirty(git_repo):
          """Only tracked-file changes count as dirty - a stray untracked file (e.g.
          a contributor's scratch note) must not flip the snapshot warning."""
          repo, commit = git_repo
          (repo / "a.py").write_text("x = 1\n", encoding="utf-8")
          commit("initial commit")
          (repo / "scratch.txt").write_text("notes\n", encoding="utf-8")
      
          info = git_churn.git_commit_info(repo)
          assert info["dirty"] is False
      
      
      def test_reports_behind_count_vs_upstream(git_repo, tmp_path):
          """When HEAD trails its upstream, `behind` is the commit gap - that is the
          staleness signal that explains absolute figures drifting low (#59)."""
          import subprocess
      
          repo, commit = git_repo
          (repo / "a.py").write_text("v = 1\n", encoding="utf-8")
          commit("c1")
          (repo / "a.py").write_text("v = 2\n", encoding="utf-8")
          commit("c2")
      
          # Stand up a local "remote" two commits ahead, then point the branch's
          # upstream at it while leaving HEAD one commit back.
          def _g(*args, cwd=repo):
              subprocess.run(["git", "-C", str(cwd), *args],
                             check=True, capture_output=True, text=True)
      
          remote = tmp_path / "remote.git"
          _g("clone", "--bare", str(repo), str(remote), cwd=tmp_path)
          branch = subprocess.run(
              ["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "HEAD"],
              check=True, capture_output=True, text=True).stdout.strip()
          _g("remote", "add", "origin", str(remote))
          _g("fetch", "-q", "origin")
          _g("branch", f"--set-upstream-to=origin/{branch}", branch)
      
          # Advance the remote by one commit so HEAD is exactly 1 behind.
          (repo / "a.py").write_text("v = 3\n", encoding="utf-8")
          commit("c3")
          _g("push", "-q", "origin", branch)
          _g("reset", "-q", "--hard", "HEAD~1")  # move HEAD back behind upstream
      
          info = git_churn.git_commit_info(repo)
          assert info["upstream"] == f"origin/{branch}"
          assert info["behind"] == 1
      
    • test_golden_baseline.py 16.2 KB
      """Tests for the /assess dogfood golden baseline (Phase 0 of assess-dogfooded).
      
      These guard the captured regression baseline itself: that the golden fixtures
      are complete (every block the report's prose depends on is present), that the
      normalization is idempotent and actually masks the volatile fields, and that the
      loaders work. Part 3's decomposition parity test reuses `golden.normalize_*` and
      `golden.load_*` from here - so a break in this scaffolding surfaces before the
      decomposition work depends on it.
      """
      from __future__ import annotations
      
      import golden
      from lib import keyhole_signals as ks
      
      # Blocks the report's prose sections read from run-context.json. The test
      # strategy for task 1 names these explicitly: a golden missing any of them would
      # let a decomposed pipeline silently drop a section and still "pass" parity.
      EXPECTED_BLOCKS = (
          "derived_findings",
          "attention",
          "behaviour",
          "documentation",
          "understanding",
          "runtime",
          "structure",
          "test_focus",
          "coverage_report",
      )
      
      
      def test_golden_run_context_has_all_expected_blocks() -> None:
          ctx = golden.load_golden_run_context()
          for block in EXPECTED_BLOCKS:
              assert block in ctx, f"golden run-context missing {block!r}"
      
      
      def test_golden_run_context_volatile_fields_are_normalized() -> None:
          ctx = golden.load_golden_run_context()
          assert ctx["plugin_version"] == golden.SENTINEL
          assert ctx["prior_plugin_version"] == golden.SENTINEL
          assert ctx["run_date"] == golden.SENTINEL
          mc = ctx["measured_commit"]
          assert mc["head_sha"] == golden.SENTINEL
          assert mc["committed_date"] == golden.SENTINEL
          # Structural fields survive normalization.
          assert mc["available"] is True
      
      
      def test_golden_run_context_keeps_derived_findings_shape() -> None:
          """All eight keyhole findings present in fixed order - the contract the
          report's findings section and Part 1's deterministic surfacing rely on. The
          E1/E2 trust-axis findings (untrusted_hotspot, self_referential_tests) join
          the original six between unexplained_complexity and orphaned_understanding."""
          ctx = golden.load_golden_run_context()
          names = [f["name"] for f in ctx["derived_findings"]]
          assert names == [
              "hidden_coupling",
              "lying_map",
              "unexplained_complexity",
              "untrusted_hotspot",
              "self_referential_tests",
              "orphaned_understanding",
              "candidate_dead_weight",
              "refactor_boundary",
          ]
      
      
      def test_golden_run_context_has_deterministic_keyhole_products() -> None:
          """Part 1 adds three deterministic report-skeleton products to the bus: the
          pre-rendered findings markdown, the keyhole readiness summary (reported
          alongside the 0-8 score), and the mandatory attention-derived Top-3
          actions."""
          ctx = golden.load_golden_run_context()
          assert ctx["findings_markdown"].startswith(
              "## Cross-Layer Findings (Keyhole Readiness)"
          )
          assert set(ctx["keyhole_summary"]) == {
              "concerns", "safe_zones", "total_concerns", "summary_text"
          }
          assert isinstance(ctx["prescribed_actions"], list)
      
      
      def test_normalize_run_context_is_idempotent() -> None:
          ctx = golden.load_golden_run_context()
          assert golden.normalize_run_context(ctx) == ctx
      
      
      def test_normalize_run_context_does_not_mutate_input() -> None:
          ctx = {"plugin_version": "9.9.9", "measured_commit": {"head_sha": "abc", "available": True}}
          snapshot = {"plugin_version": "9.9.9", "measured_commit": {"head_sha": "abc", "available": True}}
          golden.normalize_run_context(ctx)
          assert ctx == snapshot
      
      
      def test_golden_report_has_normalized_provenance() -> None:
          report = golden.load_golden_report()
          assert f"_Generated {golden.SENTINEL}._" in report
          assert f"- **Measured at commit:** {golden.SENTINEL}" in report
          # The report still carries its substantive sections (the scorecard table
          # now lives inside the 📊 fold rather than under a bare ## AI Readiness).
          assert "## Top 3 Actions" in report
          assert "Full scorecard" in report
          assert "| Layer | What it asks |" in report
      
      
      def test_normalize_report_is_idempotent() -> None:
          report = golden.load_golden_report()
          assert golden.normalize_report(report) == report
      
      
      def test_report_has_single_cross_layer_findings_heading() -> None:
          """The 'Cross-Layer Findings (Keyhole Readiness)' heading appears exactly
          once: `findings_markdown` owns it, and the report writer places framing
          prose directly under it instead of adding a duplicate framing heading
          (issue #164)."""
          report = golden.load_golden_report()
          assert report.count("Cross-Layer Findings (Keyhole Readiness)") == 1
      
      
      def test_load_bearing_surface_is_outside_folds() -> None:
          """The two-audience report keeps a short, picture-led human surface while
          keeping every verbose section present in the raw markdown inside collapsed
          <details> folds (an agent reading the file still sees all of it).
      
          A section-ablation A/B established the split: the score headline and the
          Top 3 Actions are load-bearing (folding the Top 3 made a fresh agent act on
          the wrong item), so they must stay on the visible surface; the verbose
          scorecard / findings / framing are foldable. This guard fails loudly if that
          split ever regresses in either direction.
          """
          report = golden.load_golden_report()
          surface, sep, folded = report.partition("<details>")
          assert sep, "report must contain at least one <details> fold"
      
          # Load-bearing: must be on the default-visible surface, never folded.
          assert "## Top 3 Actions" in surface
          assert "Score: 6.0 / 8" in surface
          # The two SVG snapshots carry the human value up top.
          assert surface.count("![") >= 2
      
          # Foldable verbose detail must live inside a fold, never on the surface.
          for marker in (
              "| Layer | What it asks |",          # the 9-layer scorecard table
              "## Cross-Layer Findings",            # the keyhole findings block
              "How to read this report",            # the framing/method preamble
          ):
              assert marker not in surface, f"{marker!r} leaked onto the visible surface"
              assert marker in folded, f"{marker!r} missing from the folded detail"
      
      
      def test_opening_summary_is_bespoke_not_boilerplate() -> None:
          """The first thing a human reads (the line under the score headline) must be
          a bespoke, strength-led summary of *this* run - not the old fixed caveat that
          printed identically under every score. The non-verdict reassurance still
          lives in the 'How to read' fold; it must not lead the report.
          """
          report = golden.load_golden_report()
          surface, _, folded = report.partition("<details>")
          boilerplate = "This is an improvement roadmap, not a verdict"
          assert boilerplate not in surface, (
              "the fixed 'not a verdict' caveat must not lead the report - the opening "
              "is a bespoke, strength-led summary"
          )
          assert boilerplate in folded, "the non-verdict framing should remain in the 'How to read' fold"
      
      
      def test_not_a_verdict_frame_adjacent_to_score() -> None:
          """A short 'not a verdict' frame sits on the score line above the fold, so a
          reader never mistakes the LLM score for a pass/fail gate (task 20). The full
          'improvement roadmap, not a verdict' framing still lives in the 'How to read'
          fold - guarded by test_opening_summary_is_bespoke_not_boilerplate - so this
          short frame must use different wording and must not reintroduce the boiler-
          plate onto the surface.
          """
          report = golden.load_golden_report()
          surface, sep, _ = report.partition("<details>")
          assert sep
          score_line = next(
              line for line in surface.splitlines() if line.startswith("**Score:")
          )
          assert "not a verdict" in score_line
          # The compact frame is not the folded boilerplate sentence.
          assert "This is an improvement roadmap, not a verdict" not in score_line
      
      
      def test_agents_start_here_pointer_above_the_fold() -> None:
          """The deterministic 'agents start here -> .assess/actions.json' pointer sits
          above the first fold and names the durable machine-readable Top-3 contract
          (actions.json schema v2, task 16), so an executing agent - even a smaller
          model - picks up the prioritized work without parsing the report prose."""
          report = golden.load_golden_report()
          surface, sep, _ = report.partition("<details>")
          assert sep
          assert "Agents start here" in surface
          assert "`.assess/actions.json`" in surface
          # It points at the Top 3 and therefore precedes the Top 3 Actions table.
          assert surface.index("Agents start here") < surface.index("## Top 3 Actions")
      
      
      def test_mutation_caveat_present_when_mutation_not_run() -> None:
          """The golden is a mutation-not-run snapshot, so the deterministic
          MUTATION_CAVEAT line appears above the fold: Layer 6 (Coverage) is capped at
          Partial and truth-pressure is unproven (task 8 data). It renders only when
          mutation did not run - keyed on mutation_not_run_cap.applies / mutation_run.
          """
          report = golden.load_golden_report()
          surface, sep, _ = report.partition("<details>")
          assert sep
          assert (
              "Mutation testing was not run. Layer 6 (Coverage) is capped at Partial "
              "and truth-pressure remains unproven." in surface
          )
      
      
      def test_golden_has_structure_drift_block_with_both_tiers() -> None:
          """The captured baseline carries the structure_drift block (Tier 0 + Tier 1).
      
          This repo declares an ownership map (the lib README seam doc + the README
          cross-links), so Tier 0 is available; the static import graph exists, so
          Tier 1 is available too. The block's shape is pinned here so a future
          pipeline change that drops or reshapes it fails loudly.
          """
          ctx = golden.load_golden_run_context()
          assert "structure_drift" in ctx, "golden run-context missing structure_drift"
          sd = ctx["structure_drift"]
      
          t0 = sd["tier_0"]
          assert t0["available"] is True
          assert isinstance(t0["empty_ownership_patterns"], list)
          assert isinstance(t0["total_patterns"], int)
          assert isinstance(t0["matched_patterns"], int)
          # Every empty-pattern row has the documented {pattern, declared_in, owners}.
          for row in t0["empty_ownership_patterns"]:
              assert set(row) == {"pattern", "declared_in", "owners"}
      
          t1 = sd["tier_1"]
          assert t1["available"] is True
          for key in (
              "human_grouped_static_splits",
              "human_split_static_fuses",
              "human_grouped_never_cochange",
              "human_split_but_cochange",
              "human_static_agree",
              "human_cochange_agree",
          ):
              assert isinstance(t1[f"{key}_count"], int)
          assert t1["seam_allowlist_applied"] is True
          assert isinstance(t1["allowlist_pairs_count"], int)
      
      
      def test_golden_structure_drift_tier1_has_no_false_positive_seam() -> None:
          """After the seam allowlist this repo surfaces no hidden-coupling seam.
      
          The documented seams (lib<->tests, build<->skills) are absorbed by the
          allowlist, and the version hot-file's repo-wide couplings don't recur as a
          directory pair - so the drift-derived hidden_coupling contribution is empty.
          The captured derived hidden_coupling finding therefore carries only the
          pre-existing containment-derived directories, never a drift false positive.
          """
          ctx = golden.load_golden_run_context()
          hc = next(f for f in ctx["derived_findings"] if f["name"] == "hidden_coupling")
          # The version-hot-file directory must never appear as a hidden-coupling seam.
          assert ".claude-plugin" not in hc["paths"]
      
      
      def test_golden_run_context_has_test_focus_and_coverage_shape() -> None:
          """The focus-funnel blocks the report's 'Where to focus testing' table reads:
          a ranked `test_focus` list and the `coverage_report` provenance. Pinned here
          so a pipeline change that drops or reshapes either fails loudly."""
          ctx = golden.load_golden_run_context()
      
          tf = ctx["test_focus"]
          assert tf["available"] is True
          assert isinstance(tf["coverage_present"], bool)
          assert tf["total_focus_targets"] == len(tf["entries"])
          assert tf["entries"], "golden test_focus must carry entries to exercise the table"
          for entry in tf["entries"]:
              assert set(entry) == {
                  "path", "risk_band", "test_signal",
                  "hollow_heuristic_kinds", "suggested_action",
              }
              assert entry["risk_band"] in {"high", "medium", "low"}
              assert entry["test_signal"] in {
                  "no_covering_test", "covered_but_hollow",
                  "unknown_no_coverage", "covered_clean", "unsupported",
                  "sibling_test_only",
              }
              # The orchestrator always passes repo_root, so the no-repo_root degrade
              # can no longer reach the golden.
              assert entry["test_signal"] != "unknown_no_coverage"
              assert entry["suggested_action"] in {
                  "add_tests", "strengthen_assertions", "measure_coverage", "none",
              }
      
          cov = ctx["coverage_report"]
          assert isinstance(cov["available"], bool)
          assert "source" in cov
      
      
      def test_report_renders_where_to_focus_testing_table() -> None:
          """The report renders the focus read inside a fold: the section heading, the
          File|Risk|Test Signal|Suggested Action columns, and at least one mapped row.
          The raw `test_focus` signal values must NOT leak - they are mapped to the
          human-readable labels."""
          report = golden.load_golden_report()
          surface, sep, folded = report.partition("<details>")
          assert sep
      
          assert "#### Where to focus testing" in folded
          assert "| File | Risk | Test Signal | Suggested Action |" in folded
          # The golden is a no-coverage run with repo_root passed (the orchestrator
          # always passes it), so no row can carry the Unknown label; every hot file in
          # this repo has a conventionally named test file.
          assert "Test file present, coverage unmeasured" in folded
          assert "Measure coverage" in folded
          assert "Unknown (no coverage)" not in report
          # Raw schema values must be mapped, never rendered verbatim into the table.
          assert "sibling_test_only" not in report
          assert "measure_coverage" not in report
          assert "unknown_no_coverage" not in report
          assert "add_tests" not in report
          # The verbose section lives in a fold, never on the human surface.
          assert "Where to focus testing" not in surface
      
      
      def test_report_always_explains_hatching() -> None:
          """The always-present hatching explainer kills the silent-absence problem:
          this golden is a mutation-not-run snapshot, so the report states the hatching
          is absent and why, rather than leaving a green-but-unverified treemap to read
          as safe."""
          report = golden.load_golden_report()
          assert "No hatching visible - mutation analysis was not run." in report
          assert "accept the mutation offer to enable it." in report
      
      
      def test_report_has_coverage_provenance_line() -> None:
          """The coverage provenance line states whether the test signals rest on a
          real coverage report or on heuristics alone. The golden has no report, so it
          must read 'none found'."""
          report = golden.load_golden_report()
          assert "Coverage data: none found - test signals are heuristic-only." in report
      
      
      def test_agent_assess_block_is_not_duplicated() -> None:
          """The 'read the .assess/ directory' block is for agents and belongs only in
          the Machine-readable fold - it used to also appear in the Strengths fold.
          """
          report = golden.load_golden_report()
          assert report.count("the `.assess/` directory is actionable feedback written for you") == 1
      
      
      def test_golden_attention_tie_break_order() -> None:
          """The stored attention rows agree with the tie-break key, and no
          non-hotspot row sits directly above a hotspot row of equal score. The
          golden's rows are five score-1 hidden_coupling directories (none a hotspot),
          so this pins the coupling arm only: skills/assess/tests (containment 0.0357)
          stays last and the stored order did not move. The marker arm is covered by
          the unit tests; run-context carries no marker scan to feed it here."""
          ctx = golden.load_golden_run_context()
          rows = ctx["attention"]
          assert rows
          tie_break = ks.attention_tie_break(ctx["stats_summary"], None, ctx["behaviour"])
          assert rows == sorted(rows, key=tie_break.key)
          hot = {h["path"] for h in ctx["stats_summary"]["top_hotspots"]}
          for above, below in zip(rows, rows[1:]):
              if above["score"] == below["score"]:
                  assert above["path"] in hot or below["path"] not in hot
      
    • test_golden_svg_render.py 14 KB
      """Golden-SVG tests: run the REAL renderers and lock their colour encoding.
      
      Unlike ``test_complexity_treemap.py`` / ``test_doc_graph_svg.py`` (which stub
      matplotlib to exercise pure logic), these tests drive the *actual* render path:
      they synthesize a git history over a tiny fixture repo, invoke the shipped
      script via ``uv run --script`` (matplotlib/lizard/squarify resolved from the
      script's PEP-723 inline metadata - no stubbing, no mocking), and parse the SVG
      the renderer produces on disk. That is the only way to prove the whole pipeline
      - lizard -> cap/blend maths -> OrRd colormap -> SVG - actually encodes
      ``ccn -> hue`` and ``churn -> saturation`` the way the report claims.
      
      The fixtures and their expected values are documented in
      ``tests/fixtures/golden-svg-repo/README.md`` and
      ``tests/fixtures/golden-doc-repo/FIXTURE.md``. If the colour maths changes these
      assertions fail - that is the point: update the fixture tables and these tests
      together, deliberately.
      
      ``uv`` drives the isolated script env, so the tests skip when it is absent
      (never true in CI, which installs uv). Renders are cached by uv after the first
      resolve; the fixtures are deliberately tiny to keep CI runtime low.
      """
      from __future__ import annotations
      
      import os
      import re
      import shutil
      import subprocess
      from pathlib import Path
      
      import pytest
      
      _SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
      _FIXTURES = Path(__file__).resolve().parent / "fixtures"
      
      # uv runs the script in an isolated env from its inline metadata. Skip only when
      # uv is missing (a machine that can't run the scripts at all); CI always has it.
      _UV = shutil.which("uv")
      pytestmark = pytest.mark.skipif(_UV is None, reason="uv not on PATH")
      
      
      def _hex_to_rgb(h: str) -> tuple[int, int, int]:
          h = h.lstrip("#")
          return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
      
      
      def _chroma(rgb: tuple[int, int, int]) -> int:
          """Saturation proxy: distance between the max and min RGB channel. A vivid
          (saturated) colour has a large spread; a colour blended toward neutral grey
          collapses toward zero chroma."""
          return max(rgb) - min(rgb)
      
      
      def _git(repo: Path, *args: str, env: dict) -> None:
          subprocess.run(["git", "-C", str(repo), *args],
                         check=True, capture_output=True, text=True, env=env)
      
      
      def _git_env() -> dict:
          """Ambient-config-free git env, matching conftest's hermetic-git policy, so a
          signing-enabled global config can't break commits in the disposable repo."""
          env = {**os.environ}
          env["GIT_CONFIG_GLOBAL"] = os.devnull
          env["GIT_CONFIG_SYSTEM"] = os.devnull
          env["GIT_CONFIG_NOSYSTEM"] = "1"
          # uv must resolve the script's OWN isolated env from inline metadata, not
          # reuse the pytest runner's active venv.
          env.pop("VIRTUAL_ENV", None)
          return env
      
      
      def _init_repo(repo: Path, env: dict) -> None:
          _git(repo, "init", "-q", env=env)
          _git(repo, "config", "user.email", "test@example.com", env=env)
          _git(repo, "config", "user.name", "Test", env=env)
      
      
      def _run_script(script: str, repo: Path, out: Path, env: dict,
                      *extra: str) -> str:
          """Run a shipped renderer via ``uv run --script`` and return the SVG text."""
          result = subprocess.run(
              ["uv", "run", "--script", str(_SCRIPTS / script),
               str(repo), "-o", str(out), *extra],
              capture_output=True, text=True, env=env,
          )
          assert result.returncode == 0, (
              f"{script} failed (rc={result.returncode})\nstderr:\n{result.stderr}"
          )
          assert out.exists(), f"{script} did not write {out}\nstderr:\n{result.stderr}"
          return out.read_text(encoding="utf-8")
      
      
      # ── complexity-treemap.py (code heatmap) ──────────────────────────────────────
      
      _RECT_RE = re.compile(
          r'<rect\b[^>]*\bfill="(#[0-9a-f]{6})"[^>]*>\s*<title>(.*?)</title>',
          re.DOTALL,
      )
      
      
      def _parse_rects(svg: str) -> dict[str, tuple[str, str]]:
          """Map ``basename -> (fill_hex, full_title)`` for every leaf rect. The
          tooltip's first line is the file's relative path."""
          out: dict[str, tuple[str, str]] = {}
          for fill, title in _RECT_RE.findall(svg):
              rel = title.splitlines()[0].strip()
              out[Path(rel).name] = (fill, title)
          return out
      
      
      @pytest.fixture(scope="module")
      def treemap_svg(tmp_path_factory) -> str:
          """Render golden-svg-repo through the real complexity-treemap pipeline.
      
          hot.py + simple_active.py are committed several extra times to create a
          churn gradient; complex_stable.py + simple_stable.py stay at one commit.
          """
          env = _git_env()
          repo = tmp_path_factory.mktemp("golden_svg_repo")
          src = _FIXTURES / "golden-svg-repo"
          # Copy only the scored source; README.md is fixture documentation, not input.
          for name in ("hot.py", "complex_stable.py",
                       "simple_active.py", "simple_stable.py"):
              shutil.copy(src / name, repo / name)
      
          _init_repo(repo, env)
          _git(repo, "add", "-A", env=env)
          _git(repo, "commit", "-q", "-m", "init", env=env)
          # Churn the two "active" files so churn (saturation) is a live gradient.
          for i in range(4):
              for name in ("hot.py", "simple_active.py"):
                  with (repo / name).open("a", encoding="utf-8") as fh:
                      fh.write(f"\n# churn {i}\n")
              _git(repo, "add", "-A", env=env)
              _git(repo, "commit", "-q", "-m", f"churn {i}", env=env)
      
          out = repo / "out.svg"
          return _run_script("complexity-treemap.py", repo, out, env)
      
      
      def test_treemap_a11y_metadata(treemap_svg):
          """Task 17: the root <svg> is role="img" with a <title>/<desc> pair as its
          first children (the accessible name + description)."""
          assert 'role="img"' in treemap_svg
          assert "<title>Complexity Hotspot Heatmap</title>" in treemap_svg
          assert (
              "<desc>Treemap showing code complexity by file size, hue indicates "
              "cyclomatic complexity, saturation indicates git churn</desc>"
          ) in treemap_svg
          # <title>/<desc> precede the first drawn element (<style>), i.e. they are the
          # root's first children.
          assert treemap_svg.index("<title>") < treemap_svg.index("<style>")
          assert treemap_svg.index("<desc>") < treemap_svg.index("<style>")
      
      
      def test_treemap_per_rect_titles_carry_path_loc_ccn(treemap_svg):
          """Task 17: every file rect has a <title> naming the path, LOC and CCN."""
          rects = _parse_rects(treemap_svg)
          assert set(rects) == {
              "hot.py", "complex_stable.py", "simple_active.py", "simple_stable.py"
          }
          for name, (_fill, title) in rects.items():
              assert name in title
              assert "loc" in title
              assert "ccn" in title
          # The two complex files scored the known aggregate CCN 21.
          assert "ccn 21" in rects["hot.py"][1]
          assert "ccn 21" in rects["complex_stable.py"][1]
      
      
      def test_treemap_ccn_maps_to_red_hue(treemap_svg):
          """ccn -> hue: with churn held equal (both high-churn), the high-CCN file is
          dark red while the low-CCN file is pale - red = low green/blue channels."""
          rects = _parse_rects(treemap_svg)
          hot = _hex_to_rgb(rects["hot.py"][0])           # ccn 21, churn high
          simple = _hex_to_rgb(rects["simple_active.py"][0])  # ccn 2, churn high
          # High CCN collapses green and blue toward zero (OrRd dark-red end).
          assert hot[1] < simple[1], "high-CCN green channel must be lower (redder)"
          assert hot[2] < simple[2], "high-CCN blue channel must be lower (redder)"
          assert hot[0] >= 120, "red channel stays high at the dark-red end"
          # Exact golden lock (see fixture README).
          assert rects["hot.py"][0] == "#7f0000"
      
      
      def test_treemap_churn_maps_to_saturation(treemap_svg):
          """churn -> saturation: with CCN held equal (both aggregate 21), the
          high-churn file is vivid and the low-churn file is blended toward grey."""
          rects = _parse_rects(treemap_svg)
          hot = _hex_to_rgb(rects["hot.py"][0])              # ccn 21, churn high
          stable = _hex_to_rgb(rects["complex_stable.py"][0])  # ccn 21, churn low
          assert _chroma(hot) > _chroma(stable), (
              "high-churn file must be more saturated than the frozen one"
          )
          assert _chroma(hot) > 100 and _chroma(stable) < 50
          # Same axis at the low-CCN end: active file stays more saturated than frozen.
          active = _hex_to_rgb(rects["simple_active.py"][0])
          idle = _hex_to_rgb(rects["simple_stable.py"][0])
          assert _chroma(active) > _chroma(idle)
          # Exact golden lock (see fixture README).
          assert rects["complex_stable.py"][0] == "#c0a7ab"
      
      
      # ── doc-graph-svg.py (doc navigability graph) ─────────────────────────────────
      
      _CIRCLE_RE = re.compile(r'(<circle\b[^>]*>)\s*<title>(.*?)</title>', re.DOTALL)
      _ATTR_RE = re.compile(r'(\S+?)="([^"]*)"')
      
      
      def _parse_circles(svg: str) -> dict[str, dict]:
          """Map ``doc-basename -> {fill, stroke, stroke-width, title}`` for each node.
          The tooltip's first line is the doc's path."""
          out: dict[str, dict] = {}
          for tag, title in _CIRCLE_RE.findall(svg):
              attrs = dict(_ATTR_RE.findall(tag))
              rel = title.splitlines()[0].strip()
              attrs["title"] = title
              out[Path(rel).name] = attrs
          return out
      
      
      @pytest.fixture(scope="module")
      def doc_graph_svg(tmp_path_factory) -> str:
          """Render golden-doc-repo through the real doc-graph-svg pipeline.
      
          old.md is committed far in the past (stale); everything else is committed
          "now". src/app.py is churned so the staleness saturation axis is live.
          """
          env = _git_env()
          repo = tmp_path_factory.mktemp("golden_doc_repo")
          src = _FIXTURES / "golden-doc-repo"
          (repo / "src").mkdir()
          # Explicit allowlist: FIXTURE.md is documentation, not a graphed doc.
          for name in ("README.md", "guide.md", "old.md"):
              shutil.copy(src / name, repo / name)
          shutil.copy(src / "src" / "app.py", repo / "src" / "app.py")
      
          _init_repo(repo, env)
          old = "2020-01-01T00:00:00"
          stale_env = {**env, "GIT_AUTHOR_DATE": old, "GIT_COMMITTER_DATE": old}
          _git(repo, "add", "old.md", env=env)
          _git(repo, "commit", "-q", "-m", "old notes", env=stale_env)
          _git(repo, "add", "README.md", "guide.md", "src/app.py", env=env)
          _git(repo, "commit", "-q", "-m", "docs + code", env=env)
          for i in range(3):
              with (repo / "src" / "app.py").open("a", encoding="utf-8") as fh:
                  fh.write(f"\n# churn {i}\n")
              _git(repo, "add", "-A", env=env)
              _git(repo, "commit", "-q", "-m", f"code churn {i}", env=env)
      
          out = repo / "doc.svg"
          return _run_script("doc-graph-svg.py", repo, out, env)
      
      
      def test_doc_graph_a11y_metadata(doc_graph_svg):
          """Task 17: root <svg> is role="img" with the doc-graph <title>/<desc> pair
          as its first children."""
          assert 'role="img"' in doc_graph_svg
          assert "<title>Documentation Navigability Graph</title>" in doc_graph_svg
          assert (
              "<desc>Graph showing documentation structure, reachability from entry "
              "point, and staleness indicators</desc>"
          ) in doc_graph_svg
          assert doc_graph_svg.index("<title>") < doc_graph_svg.index("<style>")
          assert doc_graph_svg.index("<desc>") < doc_graph_svg.index("<style>")
      
      
      def test_doc_graph_per_node_labels_carry_path_and_staleness(doc_graph_svg):
          """Task 17: every node's <title> is an accessible label with the doc path
          and its staleness."""
          circles = _parse_circles(doc_graph_svg)
          assert {"README.md", "guide.md", "old.md"} <= set(circles)
          for name in ("README.md", "guide.md", "old.md"):
              title = circles[name]["title"]
              assert name in title
              assert "stale" in title
      
      
      def test_doc_graph_entry_node_marked(doc_graph_svg):
          """The entry point (README.md) carries the blue entry ring - the navigation
          root stays obvious even though colour now encodes staleness."""
          circles = _parse_circles(doc_graph_svg)
          entry = circles["README.md"]
          assert entry["stroke"] == "#0072B2"
          assert float(entry["stroke-width"]) >= 3.0
          assert "entry" in entry["title"]
      
      
      def test_doc_graph_staleness_maps_to_red_hue(doc_graph_svg):
          """days-stale -> hue: the stale doc (committed years ago) is dark red while
          the fresh entry/guide docs are pale - staleness = low green/blue channels."""
          circles = _parse_circles(doc_graph_svg)
          old = _hex_to_rgb(circles["old.md"]["fill"])
          fresh = _hex_to_rgb(circles["README.md"]["fill"])
          assert old[1] < fresh[1], "stale doc green channel must be lower (redder)"
          assert old[2] < fresh[2], "stale doc blue channel must be lower (redder)"
          assert _chroma(old) > _chroma(fresh), "stale doc must be more saturated"
          # Exact golden lock (see fixture FIXTURE.md): oldest doc caps at darkest red,
          # same-day docs sit at the pale OrRd end.
          assert circles["old.md"]["fill"] == "#7f0000"
          assert circles["README.md"]["fill"] == "#fff7ec"
          assert circles["guide.md"]["fill"] == "#fff7ec"
      
      
      def test_doc_graph_unmeasured_node_is_hatched_not_a_measured_colour(tmp_path):
          """A `.claude/` doc brought in by a reference has no staleness row (the
          staleness scan keeps excluding `.claude/`). It is drawn hatched, a fill no
          measured doc can receive, with a legend key and an "unmeasured" tooltip,
          rather than the grey a zero-churn measured doc gets."""
          env = _git_env()
          repo = tmp_path / "repo"
          (repo / ".claude").mkdir(parents=True)
          (repo / "README.md").write_text("# Entry\nOpen `.claude/notes.md`.\n", encoding="utf-8")
          (repo / ".claude" / "notes.md").write_text("# Notes\n", encoding="utf-8")
          _init_repo(repo, env)
          _git(repo, "add", "-A", env=env)
          _git(repo, "commit", "-q", "-m", "docs", env=env)
          svg = _run_script("doc-graph-svg.py", repo, tmp_path / "doc.svg", env)
          circles = _parse_circles(svg)
          assert circles["notes.md"]["fill"] == "url(#unmeasured)"
          assert '<pattern id="unmeasured"' in svg
          assert ">not measured</text>" in svg
          assert "staleness not measured" in circles["notes.md"]["title"]
          assert "0d stale" not in circles["notes.md"]["title"]
          assert "stale" in circles["README.md"]["title"]  # measured docs keep the scale
          assert circles["README.md"]["fill"].startswith("#")
          assert "2 docs, 1 edges" in svg
      
    • test_hotspot_orphan_invariant.py 13.5 KB
      """Executable invariant for the /assess hotspot wiki (assess-obey-thyself, task 9).
      
      The `.assess/` wiki is a compounding history: a hotspot page survives across runs
      even after the file graduates off the top list. But a page whose *source file has
      been deleted* is a lying map - it keeps describing a file that no longer exists.
      The contract this suite enforces:
      
          No active (non-retired) hotspot page references a source path absent from disk.
      
      `prune_orphan_hotspots` maintains it by stamping every orphaned page RETIRED (the
      file's history is preserved; the page just stops claiming the file is live). The
      invariant helper below is the same check phrased as an assertion, so a page that
      slips through the pruner - or a pruner regression - fails the build. Mirrors the
      `test_self_architecture.py` idiom: a pure filesystem scan asserting a property.
      """
      from __future__ import annotations
      
      import json
      import os
      import shutil
      import subprocess
      from pathlib import Path
      
      import pytest
      
      from assess_core import build_run_context
      from assess_finalize import finalize_run
      from lib.badge import maturity_band
      from lib.wiki_writer import (
          RETIRED_EXCLUDED_STATUS,
          RETIRED_STATUS,
          slug_for_path,
          verify_log_chain,
          hotspot_page_source_path,
          hotspot_page_status,
          is_retired_status,
          prune_orphan_hotspots,
          retire_excluded_hotspots,
          write_hotspot_page,
      )
      
      
      def _active_orphans(assess_dir: Path, repo_root: Path) -> list[str]:
          """Every source path an *active* (non-retired) hotspot page names that is
          absent from disk. The invariant holds iff this list is empty."""
          hotspots_dir = assess_dir / "hotspots"
          if not hotspots_dir.is_dir():
              return []
          orphans: list[str] = []
          for page in sorted(hotspots_dir.glob("*.md")):
              content = page.read_text(encoding="utf-8")
              path = hotspot_page_source_path(content)
              if path is None:
                  continue
              if is_retired_status(hotspot_page_status(content)):
                  continue  # retired pages are allowed to reference a missing file
              if not (repo_root / path).exists():
                  orphans.append(path)
          return orphans
      
      
      def _write_page(assess_dir: Path, path: str, status: str = "active") -> None:
          assess_dir.mkdir(parents=True, exist_ok=True)
          write_hotspot_page(
              assess_dir, path=path, first_flagged="2026-01-01", last_seen="2026-07-07",
              status=status, loc=600, ccn=30, commits=5, has_tests=None,
              history_rows="| 2026-07-07 | 600 | 30 | 5 | active |",
              briefing="x", actions="- y",
          )
      
      
      def test_prune_retires_orphan_leaves_live_page(tmp_path: Path) -> None:
          """A deleted file's page is retired; a live file's page is untouched."""
          repo = tmp_path / "repo"
          assess = repo / ".assess"
          (repo / "src").mkdir(parents=True)
          (repo / "src" / "live.go").write_text("package main\n")
          _write_page(assess, "src/live.go")
          _write_page(assess, "src/gone.go")  # never created on disk
      
          retired = prune_orphan_hotspots(assess, repo)
          assert retired == ["src/gone.go"]
      
          # The invariant now holds: no active page references a missing file.
          assert _active_orphans(assess, repo) == []
      
      
      def test_orphan_invariant_fails_before_prune(tmp_path: Path) -> None:
          """The invariant helper catches a surviving active orphan - proving it isn't
          vacuously passing."""
          repo = tmp_path / "repo"
          assess = repo / ".assess"
          assess.mkdir(parents=True)
          _write_page(assess, "src/gone.go")
          assert _active_orphans(assess, repo) == ["src/gone.go"]
      
      
      def test_retired_page_preserves_history(tmp_path: Path) -> None:
          """Retirement stamps the status and a banner but keeps the page's history."""
          repo = tmp_path / "repo"
          assess = repo / ".assess"
          assess.mkdir(parents=True)
          _write_page(assess, "src/gone.go")
      
          prune_orphan_hotspots(assess, repo)
          page = next((assess / "hotspots").iterdir())
          content = page.read_text(encoding="utf-8")
          assert RETIRED_STATUS in content
          assert "Retired:" in content
          # History section and the original path are preserved.
          assert "src/gone.go" in content
          assert "## History across runs" in content
          assert hotspot_page_status(content) == RETIRED_STATUS
      
      
      def test_prune_is_idempotent(tmp_path: Path) -> None:
          """A second prune retires nothing new and doesn't double-stamp the banner."""
          repo = tmp_path / "repo"
          assess = repo / ".assess"
          assess.mkdir(parents=True)
          _write_page(assess, "src/gone.go")
      
          assert prune_orphan_hotspots(assess, repo) == ["src/gone.go"]
          assert prune_orphan_hotspots(assess, repo) == []
      
          page = next((assess / "hotspots").iterdir())
          assert page.read_text(encoding="utf-8").count("Retired:") == 1
      
      
      def test_prune_no_hotspots_dir(tmp_path: Path) -> None:
          """A repo with no hotspots/ directory yet prunes nothing (fresh install)."""
          repo = tmp_path / "repo"
          assess = repo / ".assess"
          assess.mkdir(parents=True)
          assert prune_orphan_hotspots(assess, repo) == []
      
      
      def test_file_recreated_can_be_rewritten_active(tmp_path: Path) -> None:
          """A retired page is overwritten fresh (active) if the file returns and is
          still a hotspot - write_hotspot_page rewrites the whole page."""
          repo = tmp_path / "repo"
          assess = repo / ".assess"
          (repo / "src").mkdir(parents=True)
          _write_page(assess, "src/flap.go")
          prune_orphan_hotspots(assess, repo)
          page = next((assess / "hotspots").iterdir())
          assert hotspot_page_status(page.read_text()) == RETIRED_STATUS
      
          # File comes back and is re-written as a live hotspot.
          (repo / "src" / "flap.go").write_text("package main\n")
          _write_page(assess, "src/flap.go", status="regressed")
          content = page.read_text(encoding="utf-8")
          assert hotspot_page_status(content) == "regressed"
          assert "Retired:" not in content
          assert _active_orphans(assess, repo) == []
      
      
      # --- excluded after an unfinalized run (#356) ---------------------------------
      #
      # A file first flagged by a core run that was never finalized, then excluded in
      # `.assess/config.toml` before the next run, was never part of a finished
      # assessment. Its page is retired (not deleted, not left live), its
      # first-flagged.json entry is dropped, and it does not re-enter index.md as
      # "graduated" through the rotated prior stats. An excluded file first flagged in
      # a finalized run keeps its page and its date.
      
      _EXCLUDE_DAY = "2026-09-18"
      
      
      def _git(repo: Path, *args: str) -> None:
          subprocess.run(
              ["git", "-C", str(repo), "-c", "user.email=t@example.com", "-c", "user.name=T", *args],
              check=True, capture_output=True, text=True, env=os.environ,
          )
      
      
      @pytest.fixture
      def excl_repo(tmp_path: Path) -> Path:
          repo = tmp_path / "repo"
          for rel in ("src/hot.py", "gen/big.py", "vendor/fin.py"):
              (repo / rel).parent.mkdir(parents=True, exist_ok=True)
              (repo / rel).write_text("def f(a):\n    return a\n", encoding="utf-8")
          _git(repo, "init", "-q")
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", "c1")
          (repo / ".assess").mkdir()
          return repo
      
      
      def _stats(assess_dir: Path, paths: list[str]) -> None:
          current = assess_dir / "complexity-stats.json"
          if current.exists():
              shutil.copy(current, assess_dir / "complexity-stats.prior.json")
          current.write_text(json.dumps({
              "files_scored": len(paths), "loc": {"total": len(paths)},
              "ccn": {"max": 1, "mean": 1},
              "top_hotspots": [{"path": p, "loc": 1, "ccn": 1, "commits": 1} for p in paths],
              "top_complex": [], "top_large": [],
          }), encoding="utf-8")
      
      
      def _core(repo: Path, day: str = _EXCLUDE_DAY) -> dict:
          return build_run_context(repo_root=repo, run_date=day, non_interactive=True)
      
      
      def _finalize(assess_dir: Path, ctx: dict) -> None:
          (assess_dir / ".cache").mkdir(exist_ok=True)
          (assess_dir / ".cache" / "finalize-input.json").write_text(json.dumps({
              "run_id": ctx["run_id"], "score": 4.0, "denominator": 8,
              "maturity_label": maturity_band(4.0, 8),
              "top_action": "fixture action", "hotspot_actions": {},
          }), encoding="utf-8")
          finalize_run(assess_dir=assess_dir)
      
      
      def _status(assess_dir: Path, path: str) -> str | None:
          page = assess_dir / "hotspots" / f"{slug_for_path(path)}.md"
          return hotspot_page_status(page.read_text(encoding="utf-8")) if page.exists() else None
      
      
      def _exclude(repo: Path, dirs: list[str]) -> None:
          (repo / ".assess" / "config.toml").write_text(f"exclude_dirs = {dirs!r}\n", encoding="utf-8")
      
      
      def test_core_retires_page_excluded_after_unfinalized_run(excl_repo: Path) -> None:
          assess = excl_repo / ".assess"
          _stats(assess, ["src/hot.py", "vendor/fin.py"])
          _finalize(assess, _core(excl_repo, "2026-09-17"))
          _stats(assess, ["gen/big.py", "src/hot.py", "vendor/fin.py"])
          _core(excl_repo)  # flags gen/big.py, never finalized
          _exclude(excl_repo, ["gen", "vendor"])
          _stats(assess, ["src/hot.py"])
          ctx = _core(excl_repo)
          _finalize(assess, ctx)
      
          assert _status(assess, "gen/big.py") == RETIRED_EXCLUDED_STATUS
          for live in ("vendor/fin.py", "src/hot.py"):
              status = _status(assess, live)
              assert status is not None and not status.startswith("retired")
          flagged = json.loads((assess / "first-flagged.json").read_text(encoding="utf-8"))
          assert flagged == {"src/hot.py": "2026-09-17", "vendor/fin.py": "2026-09-17"}
          index = (assess / "index.md").read_text(encoding="utf-8")
          assert "gen/big.py" not in index
          assert [p["path"] for p in ctx["diff_detail"]["graduated"]] == ["vendor/fin.py"]
          assert ctx["retired_excluded_hotspots"] == ["gen/big.py"]
          assert ctx["dropped_first_flagged"] == ["gen/big.py"]
          assert verify_log_chain(assess) == (True, None)
      
      
      def test_excluded_after_unfinalized_run_chain_of_superseded_runs(excl_repo: Path) -> None:
          """The file stays provisional across a second unfinalized run that
          supersedes the first, where it is no longer "new"."""
          assess = excl_repo / ".assess"
          _stats(assess, ["src/hot.py"])
          _finalize(assess, _core(excl_repo, "2026-09-17"))
          _stats(assess, ["gen/big.py", "src/hot.py"])
          _core(excl_repo)
          _stats(assess, ["gen/big.py", "src/hot.py"])
          _core(excl_repo)  # gen/big.py is persistent here, still unfinalized
          _exclude(excl_repo, ["gen"])
          _stats(assess, ["src/hot.py"])
          _core(excl_repo)
      
          assert _status(assess, "gen/big.py") == RETIRED_EXCLUDED_STATUS
          flagged = json.loads((assess / "first-flagged.json").read_text(encoding="utf-8"))
          assert flagged == {"src/hot.py": "2026-09-17"}
      
      
      def test_excluded_after_unfinalized_run_on_new_commit_is_kept(excl_repo: Path) -> None:
          """A run on a new commit does not supersede the unfinalized one, so the
          rule does not fire and the file graduates as before."""
          assess = excl_repo / ".assess"
          _stats(assess, ["gen/big.py", "src/hot.py"])
          _core(excl_repo)
          (excl_repo / "src" / "other.py").write_text("x = 1\n", encoding="utf-8")
          _git(excl_repo, "add", "-A")
          _git(excl_repo, "commit", "-q", "-m", "c2")
          _exclude(excl_repo, ["gen"])
          _stats(assess, ["src/hot.py"])
          ctx = _core(excl_repo)
      
          assert ctx["retired_excluded_hotspots"] == []
          status = _status(assess, "gen/big.py")
          assert status is not None and not status.startswith("retired")
      
      
      def test_excluded_after_unfinalized_run_ignores_pre_upgrade_run_context(excl_repo: Path) -> None:
          """A superseded run-context without provisional_first_flagged (written
          before the key existed) retires nothing, even for a path it lists as new."""
          assess = excl_repo / ".assess"
          _stats(assess, ["gen/big.py", "src/hot.py"])
          _core(excl_repo)
          ctx_path = assess / "run-context.json"
          prior = json.loads(ctx_path.read_text(encoding="utf-8"))
          assert "gen/big.py" in [h["path"] for h in prior["diff_detail"]["new"]]
          del prior["provisional_first_flagged"]
          ctx_path.write_text(json.dumps(prior), encoding="utf-8")
          _exclude(excl_repo, ["gen"])
          _stats(assess, ["src/hot.py"])
          ctx = _core(excl_repo)
      
          assert ctx["retired_excluded_hotspots"] == []
          assert ctx["dropped_first_flagged"] == []
          status = _status(assess, "gen/big.py")
          assert status is not None and not status.startswith("retired")
          flagged = json.loads((assess / "first-flagged.json").read_text(encoding="utf-8"))
          assert flagged["gen/big.py"] == _EXCLUDE_DAY
      
      
      def test_prune_leaves_excluded_retired_page_alone(tmp_path: Path) -> None:
          """A page already retired for another reason is not re-stamped when its
          file is later deleted."""
          repo = tmp_path / "repo"
          assess = repo / ".assess"
          _write_page(assess, "gen/big.py", status=RETIRED_EXCLUDED_STATUS)
          assert prune_orphan_hotspots(assess, repo) == []
          page = next((assess / "hotspots").iterdir())
          assert hotspot_page_status(page.read_text(encoding="utf-8")) == RETIRED_EXCLUDED_STATUS
          # The file is absent from disk, yet the invariant holds: the page is retired.
          assert _active_orphans(assess, repo) == []
      
      
      def test_retire_excluded_skips_page_without_status_token(tmp_path: Path) -> None:
          """A page with no status token cannot be stamped, so it is reported as
          unstamped rather than retired, and the core keeps its first-flagged entry."""
          assess = tmp_path / ".assess"
          _write_page(assess, "gen/big.py")
          _write_page(assess, "gen/odd.py")
          odd = assess / "hotspots" / f"{slug_for_path('gen/odd.py')}.md"
          odd.write_text("# Hotspot: `gen/odd.py`\n\nno metadata line\n", encoding="utf-8")
          before = odd.read_text(encoding="utf-8")
      
          retired, unstamped = retire_excluded_hotspots(assess, ["gen/odd.py", "gen/big.py", "gen/none.py"])
          assert retired == ["gen/big.py"]
          assert unstamped == ["gen/odd.py"]
          assert odd.read_text(encoding="utf-8") == before
      
    • test_instruction_bloat.py 6.9 KB
      """Tests for the instruction-file bloat penalty and skills-delegation credit.
      
      The core thesis (see `test_monolith_scores_strictly_below_lean_plus_skills`):
      an oversized monolithic instruction file that is NOT factored into on-demand
      skills scores STRICTLY BELOW an equivalent lean-file-plus-skills repo. The
      monolith is penalized, not merely annotated. Conservative thresholds ensure
      small/legitimate instruction files are never penalized.
      """
      from __future__ import annotations
      
      from pathlib import Path
      
      from lib.agent_instructions_grader import (
          SIZE_THRESHOLD_LINES,
          compute_bloat_penalty,
          compute_size_metrics,
          detect_skills_delegation,
          detect_skills_dir,
          grade_instructions,
      )
      
      
      def test_bloat_penalty_on_monolith(fixtures_dir: Path) -> None:
          """Monolithic 600+ line file without skills gets a bloat penalty."""
          text = (fixtures_dir / "monolithic_instructions.md").read_text()
          size = compute_size_metrics(text)
          assert size["exceeds_line_threshold"] is True
      
          penalty, msg = compute_bloat_penalty(
              size, skills_present=False, delegates_to_skills=False
          )
          assert penalty >= 5
          assert msg is not None
          assert "factor guidance into on-demand skills" in msg
      
      
      def test_no_bloat_penalty_with_skills(fixtures_dir: Path) -> None:
          """Lean file with skills delegation gets no bloat penalty."""
          repo = fixtures_dir / "lean_with_skills"
          text = (repo / "CLAUDE.md").read_text()
          size = compute_size_metrics(text)
          skills = detect_skills_dir(repo)
          delegation = detect_skills_delegation(text)
      
          penalty, msg = compute_bloat_penalty(
              size,
              skills["skills_dirs_present"],
              delegation["delegates_to_skills"],
          )
          assert penalty == 0
          assert msg is None
      
      
      def test_oversized_but_factored_into_skills_no_penalty(fixtures_dir: Path) -> None:
          """An oversized hub file is NOT penalized when skills are present - it may
          be a hub that points to skills (progressive disclosure)."""
          text = (fixtures_dir / "monolithic_instructions.md").read_text()
          size = compute_size_metrics(text)
          assert size["exceeds_line_threshold"] is True
      
          # skills_present short-circuits the penalty even for an oversized file.
          penalty, msg = compute_bloat_penalty(
              size, skills_present=True, delegates_to_skills=False
          )
          assert penalty == 0
          assert msg is None
      
          # delegation pointers in the text alone also suppress the penalty.
          penalty2, _ = compute_bloat_penalty(
              size, skills_present=False, delegates_to_skills=True
          )
          assert penalty2 == 0
      
      
      def test_monolith_scores_strictly_below_lean_plus_skills(fixtures_dir: Path) -> None:
          """REGRESSION TEST (core thesis): equivalent guidance scores STRICTLY LOWER
          as an unfactored monolith than as a lean-file-plus-skills repo.
      
          Grading the same guidance two ways isolates the penalty as the sole
          difference: one repo inlines everything (no skills factoring), the other
          factors it into on-demand skills. The monolith is penalized, not merely
          annotated, so its score is strictly lower.
          """
          text = (fixtures_dir / "monolithic_instructions.md").read_text()
      
          monolith_grade = grade_instructions(
              text, freshness_days=10, skills_present=False, delegates_to_skills=False
          )
          factored_grade = grade_instructions(
              text, freshness_days=10, skills_present=True
          )
      
          assert monolith_grade.subscores["bloat_penalty"] >= 5
          assert factored_grade.subscores["bloat_penalty"] == 0
          assert monolith_grade.score < factored_grade.score
      
      
      def test_lean_fixture_repo_detects_skills_and_avoids_penalty(fixtures_dir: Path) -> None:
          """End-to-end on the lean fixture repo: skills dir is detected, delegation
          pointers are present, and the graded file carries no bloat penalty."""
          repo = fixtures_dir / "lean_with_skills"
          text = (repo / "CLAUDE.md").read_text()
      
          skills = detect_skills_dir(repo)
          assert skills["skills_dirs_present"] is True
          assert skills["skills_count"] == 2
          assert any("java-conventions" in f for f in skills["skill_files"])
      
          grade = grade_instructions(
              text, freshness_days=10, skills_present=skills["skills_dirs_present"]
          )
          assert grade.subscores["bloat_penalty"] == 0
      
      
      def test_graceful_no_skills_dir(tmp_path: Path) -> None:
          """Repos without any skills directory degrade gracefully."""
          skills = detect_skills_dir(tmp_path)
          assert skills["skills_dirs_present"] is False
          assert skills["skills_count"] == 0
          assert skills["skill_files"] == []
          assert skills["skills_dirs"] == []
      
      
      def test_small_file_no_penalty() -> None:
          """Small/legitimate instruction files are never penalized."""
          small_text = "Use bcrypt for password hashing.\n" * 100  # 100 lines
          size = compute_size_metrics(small_text)
      
          assert size["exceeds_line_threshold"] is False
          assert size["exceeds_word_threshold"] is False
          penalty, msg = compute_bloat_penalty(
              size, skills_present=False, delegates_to_skills=False
          )
          assert penalty == 0
          assert msg is None
      
      
      def test_threshold_boundary_lines() -> None:
          """Files at exactly the line threshold are not penalized; one more is."""
          boundary_text = "Line\n" * SIZE_THRESHOLD_LINES
          size = compute_size_metrics(boundary_text)
          assert size["line_count"] == SIZE_THRESHOLD_LINES
          assert size["exceeds_line_threshold"] is False
      
          over_text = "Line\n" * (SIZE_THRESHOLD_LINES + 1)
          over_size = compute_size_metrics(over_text)
          assert over_size["exceeds_line_threshold"] is True
          penalty, _ = compute_bloat_penalty(
              over_size, skills_present=False, delegates_to_skills=False
          )
          assert penalty == 5
      
      
      def test_penalty_tiers_by_lines() -> None:
          """Penalty escalates with overage: -5 / -10 / -15."""
          def lines_penalty(n: int) -> int:
              size = compute_size_metrics("x\n" * n)
              return compute_bloat_penalty(size, False, False)[0]
      
          assert lines_penalty(600) == 5    # 500-750
          assert lines_penalty(800) == 10   # 750-1000
          assert lines_penalty(1200) == 15  # 1000+
      
      
      def test_penalty_tiers_by_words() -> None:
          """Word-count tiers mirror the line tiers at 3000/4500/6000."""
          def words_penalty(n: int) -> int:
              # Few lines, many words: isolates the word-count metric.
              size = compute_size_metrics(" ".join(["word"] * n))
              return compute_bloat_penalty(size, False, False)[0]
      
          assert words_penalty(3500) == 5    # 3000-4500
          assert words_penalty(5000) == 10   # 4500-6000
          assert words_penalty(7000) == 15   # 6000+
      
      
      def test_penalty_takes_higher_of_two_metrics() -> None:
          """When both metrics exceed, the higher penalty wins."""
          # 600 lines (-5 by lines) but 7000 words (-15 by words) -> expect -15.
          text = ("word " * 12 + "\n") * 600
          size = compute_size_metrics(text)
          assert size["line_count"] > SIZE_THRESHOLD_LINES
          assert size["word_count"] > 6000
          penalty, _ = compute_bloat_penalty(size, False, False)
          assert penalty == 15
      
    • test_instruction_claims.py 20.5 KB
      """Tests for lib/instruction_claims.py - verifying claims in agent instruction files.
      
      An instruction file that says "`scripts/check-x.sh` is enforced in CI" or "Node
      20.11.0 is pinned in `.nvmrc`" makes a checkable promise. The scan extracts each
      such sentence and checks it against the repository, so a claim nothing backs
      surfaces as a failure with its file and line.
      """
      from __future__ import annotations
      
      import json
      import os
      from pathlib import Path
      
      import pytest
      
      from lib.instruction_claims import extract_claims, scan_instruction_claims
      
      ENFORCED = "# Agents\n\nKeep changes small.\n\n`scripts/check-x.sh` is enforced in CI.\n"
      WORKFLOW_WITHOUT = (
          "on: push\njobs:\n  hello:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hello\n"
      )
      WORKFLOW_WITH = (
          "on: push\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n"
          "      - run: bash scripts/check-x.sh\n"
      )
      
      
      def _failures(block: dict) -> list[list]:
          return [[f["file"], f["line"], f["kind"]] for f in block["failures"]]
      
      
      @pytest.fixture
      def repo(tmp_path: Path) -> Path:
          (tmp_path / "scripts").mkdir()
          (tmp_path / "scripts" / "check-x.sh").write_text("echo ok\n")
          (tmp_path / ".github" / "workflows").mkdir(parents=True)
          return tmp_path
      
      
      def test_enforcement_claim_with_no_workflow_reference_fails_with_its_line(repo: Path) -> None:
          (repo / "AGENTS.md").write_text(ENFORCED)
          (repo / ".github" / "workflows" / "ci.yml").write_text(WORKFLOW_WITHOUT)
          block = scan_instruction_claims(repo, ["AGENTS.md"])
          assert (block["total"], block["verified"], block["failed"]) == (1, 0, 1)
          assert _failures(block) == [["AGENTS.md", 5, "enforcement"]]
          assert block["failures"][0]["path"] == "scripts/check-x.sh"
      
      
      def test_enforcement_claim_verifies_when_a_workflow_calls_the_script(repo: Path) -> None:
          (repo / "AGENTS.md").write_text(ENFORCED)
          (repo / ".github" / "workflows" / "ci.yml").write_text(WORKFLOW_WITH)
          block = scan_instruction_claims(repo, ["AGENTS.md"])
          assert block == {"total": 1, "verified": 1, "failed": 0, "failures": []}
      
      
      def test_enforcement_claim_is_skipped_when_the_repo_has_no_ci_config(tmp_path: Path) -> None:
          # Nothing to check against: unverifiable, not false - no accusation.
          (tmp_path / "AGENTS.md").write_text(ENFORCED)
          assert scan_instruction_claims(tmp_path, ["AGENTS.md"])["total"] == 0
      
      
      def test_enforcement_failure_carries_a_reason(repo: Path) -> None:
          (repo / "AGENTS.md").write_text(ENFORCED)
          (repo / ".github" / "workflows" / "ci.yml").write_text(WORKFLOW_WITHOUT)
          reason = scan_instruction_claims(repo, ["AGENTS.md"])["failures"][0]["reason"]
          assert "references the script" in reason
      
      
      @pytest.mark.parametrize("ci_file", [".gitlab-ci.yml", "Jenkinsfile", ".circleci/config.yml"])
      def test_enforcement_claim_verifies_against_non_github_ci(tmp_path: Path, ci_file: str) -> None:
          (tmp_path / "AGENTS.md").write_text(ENFORCED)
          (tmp_path / ci_file).parent.mkdir(parents=True, exist_ok=True)
          (tmp_path / ci_file).write_text("lint:\n  script: bash scripts/check-x.sh\n")
          block = scan_instruction_claims(tmp_path, ["AGENTS.md"])
          assert (block["total"], block["verified"]) == (1, 1)
      
      
      @pytest.mark.parametrize("runner, body", [
          ("Makefile", "lint:\n\tbash scripts/check-x.sh\n"),
          ("package.json", '{"scripts": {"lint": "scripts/check-x.sh"}}\n'),
          (".pre-commit-config.yaml", "- id: x\n  entry: scripts/check-x.sh\n"),
      ])
      def test_enforcement_claim_verifies_through_a_task_runner(repo: Path, runner: str, body: str) -> None:
          # The workflow calls `make lint` / `npm run lint` / pre-commit, not the path.
          (repo / "AGENTS.md").write_text(ENFORCED)
          (repo / ".github" / "workflows" / "ci.yml").write_text(WORKFLOW_WITHOUT)
          (repo / runner).write_text(body)
          block = scan_instruction_claims(repo, ["AGENTS.md"])
          assert (block["total"], block["verified"]) == (1, 1)
      
      
      def test_file_with_no_matching_pattern_yields_zero_claims(repo: Path) -> None:
          (repo / "AGENTS.md").write_text("# Agents\n\nKeep changes small. Prefer plain names.\n")
          block = scan_instruction_claims(repo, ["AGENTS.md"])
          assert block == {"total": 0, "verified": 0, "failed": 0, "failures": []}
      
      
      def test_no_instruction_files_yields_the_empty_block(tmp_path: Path) -> None:
          assert scan_instruction_claims(tmp_path, []) == {
              "total": 0, "verified": 0, "failed": 0, "failures": []}
      
      
      def test_unreadable_or_missing_file_is_skipped_not_raised(tmp_path: Path) -> None:
          (tmp_path / "AGENTS.md").write_bytes(b"\xff\xfe not utf-8 `scripts/a.sh` runs in CI")
          block = scan_instruction_claims(tmp_path, ["AGENTS.md", "CLAUDE.md"])
          assert block["total"] == 0
      
      
      def test_pin_claims_verify_on_version_either_side_of_pinned_in(tmp_path: Path) -> None:
          (tmp_path / ".nvmrc").write_text("18.19.0\n")
          (tmp_path / ".tool-versions").write_text("golang 1.22.3\n")
          (tmp_path / "AGENTS.md").write_text(
              "# Agents\n\nNode 20.11.0 is pinned in `.nvmrc`.\n\n"
              "The Go toolchain is pinned in `.tool-versions` at 1.22.3.\n\n"
              "Ruby 3.3.0 is pinned in `.ruby-version`.\n"
          )
          block = scan_instruction_claims(tmp_path, ["AGENTS.md"])
          assert (block["total"], block["verified"], block["failed"]) == (3, 1, 2)
          assert sorted(_failures(block)) == [["AGENTS.md", 3, "pin"], ["AGENTS.md", 7, "pin"]]
          by_line = {f["line"]: f for f in block["failures"]}
          assert (by_line[3]["path"], by_line[3]["version"]) == (".nvmrc", "20.11.0")
          assert by_line[3]["reason"] == "pinned file does not contain the version"
          assert by_line[7]["path"] == ".ruby-version"
          assert by_line[7]["reason"] == "pinned file does not exist"
      
      
      def test_pin_sentence_without_a_version_is_skipped() -> None:
          assert extract_claims("The toolchain is pinned in `.tool-versions`.\n") == []
      
      
      def test_pin_sentence_with_two_versions_is_skipped_as_ambiguous() -> None:
          text = "Node 20.11.0 is pinned in `.nvmrc`, upgraded from 18.19.0.\n"
          assert extract_claims(text) == []
      
      
      def test_claim_in_a_wrapped_paragraph_reports_the_line_the_sentence_starts_on() -> None:
          text = "# Agents\n\nKeep it small. The lint script\n`scripts/lint.sh` is enforced\nin CI.\n"
          claims = extract_claims(text)
          assert [(c.kind, c.line, c.path) for c in claims] == [("enforcement", 3, "scripts/lint.sh")]
      
      
      def test_fenced_code_is_not_read_as_claims() -> None:
          text = "```\n`scripts/lint.sh` is enforced in CI.\n```\n"
          assert extract_claims(text) == []
      
      
      def test_enforcement_needs_a_trigger_phrase_and_a_script_path() -> None:
          assert extract_claims("Run `scripts/lint.sh` before pushing.\n") == []
          assert extract_claims("Linting is enforced in CI.\n") == []
          # "CI" is a word, not a substring of another word.
          assert extract_claims("Run `scripts/lint.sh` for CIRCLE builds.\n") == []
          # A backticked workflow file is not a script the workflows would call.
          assert extract_claims("CI runs `.github/workflows/ci.yml`.\n") == []
      
      
      @pytest.mark.parametrize("sentence", [
          "Do not edit `src/db/env.py`; CI will fail if you do.",
          "`src/index.ts` must compile before CI passes.",
          "`lib/scripts_helper.rb` is checked by the linter.",
      ])
      def test_ordinary_source_files_are_not_enforcement_claims(sentence: str) -> None:
          assert extract_claims(sentence + "\n") == []
      
      
      @pytest.mark.parametrize("path", ["scripts/gate.py", "bin/check.js", "tools/ci/lint.ts",
                                        "hack/verify.rb", "ops/deploy.sh"])
      def test_script_paths_that_ci_invokes_are_enforcement_claims(path: str) -> None:
          claims = extract_claims(f"`{path}` is enforced in CI.\n")
          assert [c.path for c in claims] == [path]
      
      
      @pytest.mark.parametrize("phrase", ["is enforced by the pipeline", "runs in the lint job",
                                          "is checked by the gate", "gates CI"])
      def test_each_enforcement_trigger_phrase_makes_a_claim(phrase: str) -> None:
          claims = extract_claims(f"`./scripts/lint.sh` {phrase}.\n")
          assert [(c.kind, c.path) for c in claims] == [("enforcement", "scripts/lint.sh")]
      
      
      def test_duplicate_file_through_a_symlink_is_scanned_once(repo: Path) -> None:
          (repo / "CLAUDE.md").write_text(ENFORCED)
          (repo / "AGENTS.md").symlink_to("CLAUDE.md")
          block = scan_instruction_claims(repo, ["CLAUDE.md", "AGENTS.md"])
          assert block["total"] == 1
      
      
      def test_block_is_json_serialisable(repo: Path) -> None:
          (repo / "AGENTS.md").write_text(ENFORCED)
          json.dumps(scan_instruction_claims(repo, ["AGENTS.md"]))
      
      
      def test_build_run_context_carries_the_block(tmp_path: Path) -> None:
          from assess_core import build_run_context
      
          (tmp_path / "AGENTS.md").write_text(ENFORCED)
          (tmp_path / ".github" / "workflows").mkdir(parents=True)
          (tmp_path / ".github" / "workflows" / "ci.yml").write_text(WORKFLOW_WITHOUT)
          ctx = build_run_context(repo_root=tmp_path, run_date="2026-09-18")
          assert _failures(ctx["instruction_claims"]) == [["AGENTS.md", 5, "enforcement"]]
          written = json.loads((tmp_path / ".assess" / "run-context.json").read_text())
          assert written["instruction_claims"]["failed"] == 1
      
      
      def test_heading_directly_above_prose_does_not_lend_its_words_or_line() -> None:
          # No blank line under the heading: the `CI` in it must not trigger the
          # sentence below, and a claim there reports its own line, not the heading's.
          assert extract_claims("## CI\n`scripts/lint.sh` must pass.\n") == []
          claims = extract_claims("# Agents\n## Lint\n`scripts/lint.sh` is enforced in CI.\n")
          assert [(c.kind, c.line) for c in claims] == [("enforcement", 3)]
      
      
      @pytest.fixture
      def counted(tmp_path: Path) -> Path:
          (tmp_path / "supabase" / "tests").mkdir(parents=True)
          for i in range(177):
              (tmp_path / "supabase" / "tests" / f"t{i}.sql").write_text("")
          (tmp_path / "cmds").mkdir()
          for i in range(7):
              (tmp_path / "cmds" / f"c{i}.md").write_text("# cmd\n")
          return tmp_path
      
      
      def test_count_claim_far_from_the_pattern_fails_with_both_numbers(counted: Path) -> None:
          (counted / "AGENTS.md").write_text(
              "# Agents\n\nThere are 43 pgTAP suites matching `supabase/tests/*.sql`.\n")
          block = scan_instruction_claims(counted, ["AGENTS.md"])
          assert (block["total"], block["verified"], block["failed"]) == (1, 0, 1)
          failure = block["failures"][0]
          assert [failure["file"], failure["line"], failure["kind"]] == ["AGENTS.md", 3, "count"]
          assert (failure["claimed"], failure["actual"]) == (43, 177)
          assert failure["path"] == "supabase/tests/*.sql"
      
      
      def test_count_sentence_without_a_backticked_pattern_is_no_claim(counted: Path) -> None:
          (counted / "AGENTS.md").write_text("# Agents\n\nWe maintain 43 pgTAP suites.\n")
          assert scan_instruction_claims(counted, ["AGENTS.md"]) == {
              "total": 0, "verified": 0, "failed": 0, "failures": []}
      
      
      def test_count_within_ten_percent_or_two_verifies(counted: Path) -> None:
          # 170 vs 177 is inside 10%; 5 vs 7 is a difference of exactly 2; 150 vs 177 is not.
          (counted / "AGENTS.md").write_text(
              "# Agents\n\nThere are 170 pgTAP files matching `supabase/tests/*.sql`.\n\n"
              "The plugin ships 5 commands in `cmds/*.md`.\n\n"
              "The 150 migrations live in `supabase/tests/*.sql`.\n")
          block = scan_instruction_claims(counted, ["AGENTS.md"])
          assert (block["total"], block["verified"], block["failed"]) == (3, 2, 1)
          failure = block["failures"][0]
          assert (failure["line"], failure["claimed"], failure["actual"]) == (7, 150, 177)
      
      
      def test_count_tolerance_is_the_larger_of_ten_percent_or_two() -> None:
          from lib.instruction_claims import count_within_tolerance
      
          assert count_within_tolerance(5, 7)
          assert not count_within_tolerance(4, 7)
          assert count_within_tolerance(100, 110)
          assert not count_within_tolerance(100, 112)
      
      
      def test_count_pattern_matching_nothing_in_an_existing_directory_fails_with_actual_zero(
              tmp_path: Path) -> None:
          (tmp_path / "tests").mkdir()
          (tmp_path / "AGENTS.md").write_text("There are 12 suites in `tests/*.sql`.\n")
          failure = scan_instruction_claims(tmp_path, ["AGENTS.md"])["failures"][0]
          assert (failure["kind"], failure["claimed"], failure["actual"]) == ("count", 12, 0)
      
      
      def test_count_pattern_whose_directory_is_missing_is_unverifiable_not_failed(
              tmp_path: Path) -> None:
          (tmp_path / "AGENTS.md").write_text("There are 12 suites in `tests/*.sql`.\n")
          assert scan_instruction_claims(tmp_path, ["AGENTS.md"])["total"] == 0
      
      
      def _thirty_pages(root: Path) -> None:
          for i in range(30):
              sub = root / "docs" / ("a" if i % 2 else "b/c")
              sub.mkdir(parents=True, exist_ok=True)
              (sub / f"p{i}.md").write_text("")
      
      
      def test_count_recursive_glob_counts_files_not_directories(tmp_path: Path) -> None:
          _thirty_pages(tmp_path)
          for i in range(10):  # directories whose names match the pattern
              (tmp_path / "docs" / f"legacy{i}.md").mkdir()
              (tmp_path / "docs" / f"legacy{i}.md" / "keep").write_text("")
          (tmp_path / "AGENTS.md").write_text("The 30 pages under `docs/**/*.md` are the map.\n")
          block = scan_instruction_claims(tmp_path, ["AGENTS.md"])
          assert (block["total"], block["verified"]) == (1, 1)
      
      
      def test_count_skips_git_metadata_and_matches_outside_the_repo(tmp_path: Path) -> None:
          repo, outside = tmp_path / "repo", tmp_path / "outside"
          _thirty_pages(repo)
          (repo / "docs" / ".git").mkdir()
          outside.mkdir()
          for i in range(10):
              (repo / "docs" / ".git" / f"g{i}.md").write_text("")
              (outside / f"o{i}.md").write_text("")
          (repo / "docs" / "vendor").symlink_to(outside)
          (repo / "AGENTS.md").write_text("The 30 pages under `docs/**/*.md` are the map.\n")
          block = scan_instruction_claims(repo, ["AGENTS.md"])
          assert (block["total"], block["verified"]) == (1, 1)
      
      
      def test_count_does_not_count_a_symlinked_file_outside_the_repo(tmp_path: Path) -> None:
          repo, outside = tmp_path / "repo", tmp_path / "outside"
          _thirty_pages(repo)
          outside.mkdir()
          for i in range(10):
              (outside / f"o{i}.md").write_text("")
              (repo / "docs" / "a" / f"link{i}.md").symlink_to(outside / f"o{i}.md")
          (repo / "AGENTS.md").write_text("The 30 pages under `docs/**/*.md` are the map.\n")
          block = scan_instruction_claims(repo, ["AGENTS.md"])
          assert (block["total"], block["verified"]) == (1, 1)
      
      
      @pytest.mark.parametrize("pattern", ["skills/*", "skills/*/"])
      def test_count_of_directories_is_unverifiable_not_a_zero_count(tmp_path: Path, pattern: str) -> None:
          for i in range(12):
              (tmp_path / "skills" / f"s{i}").mkdir(parents=True)
              (tmp_path / "skills" / f"s{i}" / "SKILL.md").write_text("")
          (tmp_path / "AGENTS.md").write_text(f"The 12 skills live in `{pattern}`.\n")
          assert scan_instruction_claims(tmp_path, ["AGENTS.md"])["total"] == 0
      
      
      def test_count_ignores_tool_output_and_dependency_trees_below_the_pattern(tmp_path: Path) -> None:
          _thirty_pages(tmp_path)
          for tree in (".assess/hotspots", "node_modules/pkg", ".venv/lib"):
              (tmp_path / tree).mkdir(parents=True)
              for i in range(10):
                  (tmp_path / tree / f"x{i}.md").write_text("")
          (tmp_path / "AGENTS.md").write_text("The 30 pages under `**/*.md` are the map.\n")
          # AGENTS.md itself is the 31st page; within tolerance.
          block = scan_instruction_claims(tmp_path, ["AGENTS.md"])
          assert (block["total"], block["verified"]) == (1, 1)
      
      
      def test_count_inside_an_excluded_directory_named_on_purpose_still_counts(tmp_path: Path) -> None:
          (tmp_path / "vendor" / "docs").mkdir(parents=True)
          for i in range(30):
              (tmp_path / "vendor" / "docs" / f"v{i}.md").write_text("")
          (tmp_path / "AGENTS.md").write_text("We vendor 12 pages in `vendor/docs/*.md`.\n")
          failure = scan_instruction_claims(tmp_path, ["AGENTS.md"])["failures"][0]
          assert (failure["claimed"], failure["actual"]) == (12, 30)
      
      
      @pytest.mark.skipif(not hasattr(os, "geteuid") or os.geteuid() == 0,
                          reason="root reads a directory whatever its mode")
      def test_count_with_an_unreadable_subtree_is_unverifiable(tmp_path: Path) -> None:
          _thirty_pages(tmp_path)
          locked = tmp_path / "docs" / "b"
          locked.chmod(0)
          try:
              (tmp_path / "AGENTS.md").write_text("The 30 pages under `docs/**/*.md` are the map.\n")
              assert scan_instruction_claims(tmp_path, ["AGENTS.md"])["total"] == 0
          finally:
              locked.chmod(0o755)
      
      
      @pytest.mark.parametrize("sentence", [
          "Keep every page under `docs/**/*.md` below 500 lines.",
          "Keep at most 10 files in `x/*.md`.",
          "Cap `src/**/*.ts` at 80 columns.",
          "Review any change to `skills/*/SKILL.md` within 3 days.",
          "Allow no more than 5 pages in `docs/*.md`.",
      ])
      def test_count_threshold_integer_is_not_a_count_claim(sentence: str) -> None:
          assert [c for c in extract_claims(sentence + "\n") if c.kind == "count"] == []
      
      
      @pytest.mark.parametrize("sentence", [
          "Indent `scripts/*.sh` with 4 spaces.",
          "Cap `src/**/*.ts` at 15 cyclomatic complexity.",
          "Run the suite 2 times before touching `tests/*.py`.",
          # The pattern before the integer, even with a linking word.
          "Files in `docs/*.md` number 30.",
      ])
      def test_count_needs_the_integer_then_a_linking_word_before_the_pattern(sentence: str) -> None:
          assert [c for c in extract_claims(sentence + "\n") if c.kind == "count"] == []
      
      
      @pytest.mark.parametrize("sentence, claimed", [
          ("There are 43 pgTAP suites matching `supabase/tests/*.sql`.", 43),
          ("There are 170 pgTAP files matching `supabase/tests/*.sql`.", 170),
          ("The plugin ships 5 commands in `cmds/*.md`.", 5),
          ("The 150 migrations live in `supabase/tests/*.sql`.", 150),
      ])
      def test_count_contract_sentences_pass_the_order_gate(sentence: str, claimed: int) -> None:
          claims = extract_claims(sentence + "\n")
          assert [(c.kind, c.fields) for c in claims] == [("count", {"claimed": claimed})]
      
      
      def test_count_claim_without_a_unit_or_comparator_is_still_extracted() -> None:
          claims = extract_claims("There are 43 pgTAP suites matching `supabase/tests/*.sql`.\n")
          assert [(c.kind, c.fields) for c in claims] == [("count", {"claimed": 43})]
      
      
      def test_count_non_recursive_pattern_over_files_and_directories_is_unverifiable(
              tmp_path: Path) -> None:
          (tmp_path / "docs").mkdir()
          (tmp_path / "docs" / "README.md").write_text("")
          for i in range(12):
              (tmp_path / "docs" / f"guide{i}").mkdir()
          (tmp_path / "AGENTS.md").write_text("The 12 guides live in `docs/*`.\n")
          assert scan_instruction_claims(tmp_path, ["AGENTS.md"])["total"] == 0
      
      
      def test_count_non_recursive_pattern_over_files_only_still_counts(tmp_path: Path) -> None:
          (tmp_path / "docs").mkdir()
          for i in range(30):
              (tmp_path / "docs" / f"g{i}").write_text("")
          (tmp_path / "AGENTS.md").write_text("The 12 guides live in `docs/*`.\n")
          failure = scan_instruction_claims(tmp_path, ["AGENTS.md"])["failures"][0]
          assert (failure["claimed"], failure["actual"]) == (12, 30)
      
      
      def test_count_glob_error_is_unverifiable_not_a_zero_count(
              counted: Path, monkeypatch: pytest.MonkeyPatch) -> None:
          def broken(self: Path, pattern: str) -> list[Path]:
              raise ValueError("Invalid pattern")
      
          monkeypatch.setattr(Path, "glob", broken)
          (counted / "AGENTS.md").write_text("There are 43 suites in `supabase/tests/*.sql`.\n")
          assert scan_instruction_claims(counted, ["AGENTS.md"])["total"] == 0
      
      
      @pytest.mark.parametrize("sentence", [
          # A path with no wildcard: a directory may hold files or subdirectories.
          "The 12 skills live in `skills/`.",
          # Two numbers: which one is the count is a guess.
          "Keep 5 of the 7 commands in `cmds/*.md`.",
          # Two patterns: which one the number counts is a guess.
          "There are 7 commands in `cmds/*.md` and `extra/*.md`.",
          # A version, a percentage or a number inside the backticks is not a count.
          "Node 20.11.0 builds `cmds/*.md`.",
          "Keep 80% coverage in `cmds/*.md`.",
          "Run `ls cmds/*.md | head -3` first.",
          # A pattern that leaves the repository is not counted.
          "There are 3 files in `../other/*.md`.",
          "There are 3 files in `/etc/*.conf`.",
          "There are 3 files in `C:\\logs\\*.txt`.",
          # Not path-shaped: code, placeholders, flags.
          "All 3 helpers take `**kwargs`.",
          "All 3 helpers take `*args`.",
          "Write `?` for 3 unless known.",
          "Pass `--only=*` to run 5 checks.",
          # A year is not a count.
          "Since 2024 every migration lives in `supabase/migrations/*.sql`.",
      ])
      def test_count_claim_is_skipped_when_the_sentence_is_ambiguous(sentence: str) -> None:
          assert [c for c in extract_claims(sentence + "\n") if c.kind == "count"] == []
      
      
      def test_count_claim_is_extracted_with_its_number_and_pattern() -> None:
          claims = extract_claims("# Agents\n\nThe plugin ships 5 commands in `cmds/*.md`.\n")
          assert [(c.kind, c.line, c.path, c.fields) for c in claims] == [
              ("count", 3, "cmds/*.md", {"claimed": 5})]
      
    • test_interactivity.py 3.5 KB
      """Tests for the non-interactive consent contract (Task 13).
      
      The decider is an **explicit** signal the orchestrator passes in, never a
      subprocess stdin probe: the core always runs under `uv run ...` from a Bash
      tool with no controlling terminal, so `isatty()` is False even in a normal
      interactive /assess. A run is interactive by default and non-interactive only
      when marked so (the --non-interactive flag / ASSESS_NON_INTERACTIVE env var) or
      when CI is set.
      """
      from __future__ import annotations
      
      from lib.interactivity import (
          NON_INTERACTIVE_ENV,
          OFFER_TYPES,
          build_offers_block,
          is_interactive,
          non_interactive_offers,
      )
      
      
      # ── detection ───────────────────────────────────────────────────────────────
      
      def test_default_run_is_interactive() -> None:
          # No flag, no CI env: a normal /assess invocation is interactive.
          assert is_interactive(env={}) is True
      
      
      def test_explicit_flag_forces_non_interactive() -> None:
          assert is_interactive(non_interactive=True, env={}) is False
      
      
      def test_ci_env_forces_non_interactive() -> None:
          assert is_interactive(env={"CI": "true"}) is False
      
      
      def test_assess_non_interactive_env_forces_non_interactive() -> None:
          assert is_interactive(env={NON_INTERACTIVE_ENV: "1"}) is False
      
      
      def test_empty_ci_env_is_not_ci() -> None:
          assert is_interactive(env={"CI": ""}) is True
      
      
      def test_empty_non_interactive_env_is_interactive() -> None:
          assert is_interactive(env={NON_INTERACTIVE_ENV: ""}) is True
      
      
      def test_flag_wins_even_without_env() -> None:
          # The explicit flag alone is sufficient; no env var needed.
          assert is_interactive(non_interactive=True, env={"CI": ""}) is False
      
      
      # ── offer recording ─────────────────────────────────────────────────────────
      
      def test_non_interactive_offers_all_skipped() -> None:
          offers = non_interactive_offers()
          assert {o["type"] for o in offers} == set(OFFER_TYPES)
          assert all(o["status"] == "skipped" for o in offers)
          assert all(o["reason"] == "non-interactive" for o in offers)
      
      
      def test_block_default_run_presents_offers() -> None:
          # A normal (no-flag, no-CI) run is interactive: offers stay empty for the
          # orchestrator to present live, NOT pre-recorded as skipped.
          block = build_offers_block(env={})
          assert block["interactive"] is True
          assert block["offers"] == []
      
      
      def test_block_non_interactive_records_every_offer_skipped() -> None:
          block = build_offers_block(non_interactive=True, env={})
          assert block["interactive"] is False
          assert len(block["offers"]) == len(OFFER_TYPES)
          # Mutation (code modification) is recorded as skipped like every other offer.
          assert any(o["type"] == "mutation" and o["status"] == "skipped"
                     for o in block["offers"])
      
      
      def test_block_ci_env_records_every_offer_skipped() -> None:
          block = build_offers_block(env={"CI": "true"})
          assert block["interactive"] is False
          assert len(block["offers"]) == len(OFFER_TYPES)
      
      
      def test_offer_types_cover_all_three_phases_plus_uninstall() -> None:
          assert "tool_install" in OFFER_TYPES      # Phase 1
          assert "mutation" in OFFER_TYPES          # Phase 3
          assert {"pr", "issue_tracking", "ci_gate", "feedback"} <= set(OFFER_TYPES)  # Phase 2
          assert "uninstall" in OFFER_TYPES         # end-of-run
      
    • test_jvm_capabilities.py 16.7 KB
      """Signal-consumption tests for the capability-driven JVM offer flow (#113).
      
      The CI contract is *signal consumption*: given a tool's output (canned
      ``mvn dependency:analyze`` text), the deterministic core feeds the scorecard
      correctly. The agent's runtime tool *choice* is human-judged and not tested
      here. A synthetic Maven fixture under ``tests/fixtures/maven_project/`` stands in
      for the real Helidon repo CI cannot reach.
      """
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      import pytest
      
      from lib.jvm_capabilities import (
          count_used_undeclared,
          detect_build_system,
          detect_configured_plugins,
          parse_dependency_analyze,
          scan_jvm_capabilities,
      )
      from lib.liveness_scan import scan_liveness
      
      FIXTURE = Path(__file__).parent / "fixtures" / "maven_project"
      
      
      def _write(root: Path, rel: str, text: str = "x") -> None:
          p = root / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      def _analyze_output() -> str:
          return (FIXTURE / "dependency-analyze-output.txt").read_text(encoding="utf-8")
      
      
      # ── parsing dependency:analyze (the consumed signal) ───────────────────────
      
      def test_parse_dependency_analyze_extracts_unused_declared() -> None:
          candidates = parse_dependency_analyze(_analyze_output(), pom_path="pom.xml")
          symbols = {c["symbol"] for c in candidates}
          assert symbols == {
              "org.apache.commons:commons-lang3",
              "com.google.guava:guava",
          }
          # Used-undeclared (slf4j) is NOT a liveness dead-weight candidate.
          assert "org.slf4j:slf4j-api" not in symbols
          for c in candidates:
              assert c["kind"] == "unused declared dependency"
              assert c["path"] == "pom.xml"
      
      
      def test_used_undeclared_counted_but_not_a_candidate() -> None:
          assert count_used_undeclared(_analyze_output()) == 1
      
      
      def test_parse_empty_output_is_no_candidates() -> None:
          assert parse_dependency_analyze("[INFO] BUILD SUCCESS\n") == []
      
      
      def test_parse_stops_at_section_boundary() -> None:
          # An unused block followed by a non-coordinate line must not bleed into it.
          text = (
              "[WARNING] Unused declared dependencies found:\n"
              "[WARNING]    a.b:c:jar:1.0:compile\n"
              "[INFO] BUILD SUCCESS\n"
              "    d.e:f:jar:2.0:compile\n"  # outside the block - ignored
          )
          syms = {c["symbol"] for c in parse_dependency_analyze(text)}
          assert syms == {"a.b:c"}
      
      
      # ── build-system detection ─────────────────────────────────────────────────
      
      def test_detect_maven_in_fixture() -> None:
          system, files = detect_build_system(FIXTURE)
          assert system == "maven"
          assert "pom.xml" in files
      
      
      def test_detect_gradle(tmp_path: Path) -> None:
          _write(tmp_path, "build.gradle", "plugins { id 'java' }")
          _write(tmp_path, "src/Main.java", "class Main {}")
          system, files = detect_build_system(tmp_path)
          assert system == "gradle"
          assert "build.gradle" in files
      
      
      def test_maven_wins_over_gradle_when_both_present(tmp_path: Path) -> None:
          _write(tmp_path, "pom.xml", "<project/>")
          _write(tmp_path, "build.gradle", "plugins {}")
          _write(tmp_path, "src/main/java/A.java", "class A {}")
          system, _ = detect_build_system(tmp_path)
          assert system == "maven"
      
      
      def test_detect_none_for_non_jvm(tmp_path: Path) -> None:
          _write(tmp_path, "main.py", "x = 1")
          system, files = detect_build_system(tmp_path)
          assert system is None
          assert files == []
      
      
      def test_fixture_pom_excluded_when_under_tests_fixtures(tmp_path: Path) -> None:
          # A pom under tests/fixtures/ must not make a repo look like a Maven project
          # (the auto-exclude that keeps the assess run-context baseline stable).
          # JVM source outside the fixture meets the source threshold, so a null
          # result here can only come from the fixture exclusion.
          _write(tmp_path, "tests/fixtures/sample/pom.xml", "<project/>")
          _write(tmp_path, "src/main/java/A.java", "class A {}")
          system, _ = detect_build_system(tmp_path)
          assert system is None
      
      
      def test_user_exclude_dir_prunes_build_file(tmp_path: Path) -> None:
          _write(tmp_path, "legacy/pom.xml", "<project/>")
          _write(tmp_path, "src/main/java/A.java", "class A {}")
          assert detect_build_system(tmp_path)[0] == "maven"
          assert detect_build_system(tmp_path, extra_exclude_dirs={"legacy"}) == (None, [])
      
      
      def test_user_exclude_pattern_on_source_drops_threshold(tmp_path: Path) -> None:
          # A basename pattern applies to source files too: excluding the only JVM
          # source leaves the build file below the source threshold.
          _write(tmp_path, "pom.xml", "<project/>")
          _write(tmp_path, "src/main/java/Generated.java", "class Generated {}")
          assert detect_build_system(tmp_path)[0] == "maven"
          assert detect_build_system(
              tmp_path, extra_exclude_patterns=["Generated*.java"]) == (None, [])
      
      
      def test_requires_jvm_source_groovy_counts(tmp_path: Path) -> None:
          # Grails apps, Jenkins plugins and Gradle plugin projects hold Groovy only.
          _write(tmp_path, "build.gradle", "plugins { id 'groovy' }")
          _write(tmp_path, "src/main/groovy/Plugin.groovy", "class Plugin {}")
          assert detect_build_system(tmp_path) == ("gradle", ["build.gradle"])
      
      
      # ── JVM source threshold and platform wrappers ──────────────────────────────
      
      def _flutter_app(root: Path, prefix: str = "") -> None:
          _write(root, f"{prefix}pubspec.yaml", "name: demo")
          _write(root, f"{prefix}lib/main.dart", "void main() {}")
          _write(root, f"{prefix}android/build.gradle.kts", "plugins {}")
          _write(root, f"{prefix}android/app/build.gradle.kts", "plugins {}")
          _write(root, f"{prefix}android/app/src/main/kotlin/MainActivity.kt",
                 "class MainActivity")
      
      
      def test_requires_jvm_source_pom_alone_is_not_maven(tmp_path: Path) -> None:
          _write(tmp_path, "pom.xml", "<project/>")
          assert detect_build_system(tmp_path) == (None, [])
      
      
      def test_requires_jvm_source_one_file_meets_threshold(tmp_path: Path) -> None:
          _write(tmp_path, "build.gradle", "plugins {}")
          _write(tmp_path, "src/main/scala/App.scala", "object App")
          assert detect_build_system(tmp_path) == ("gradle", ["build.gradle"])
      
      
      def test_requires_jvm_source_outside_wrapper_not_inside(tmp_path: Path) -> None:
          # A root build file does not make a JVM codebase when the only JVM source
          # sits inside the Flutter wrapper.
          _flutter_app(tmp_path)
          _write(tmp_path, "build.gradle", "plugins {}")
          assert detect_build_system(tmp_path) == (None, [])
      
      
      def test_platform_wrapper_flutter_android_is_not_jvm(tmp_path: Path) -> None:
          _flutter_app(tmp_path)
          assert detect_build_system(tmp_path) == (None, [])
          assert scan_jvm_capabilities(tmp_path, mvn_on_path=False) == {
              "available": False, "build_system": None, "build_files": []}
      
      
      def test_platform_wrapper_nested_flutter_app_is_not_jvm(tmp_path: Path) -> None:
          _flutter_app(tmp_path, "apps/shop/")
          assert detect_build_system(tmp_path) == (None, [])
      
      
      @pytest.mark.parametrize("section, package", [
          ("dependencies", "react-native"),
          ("dependencies", "@capacitor/android"),
          ("devDependencies", "cordova-android"),
      ])
      def test_platform_wrapper_package_json_android_is_not_jvm(
              tmp_path: Path, section: str, package: str) -> None:
          _write(tmp_path, "package.json", json.dumps({section: {package: "1.0.0"}}))
          _write(tmp_path, "android/build.gradle", "buildscript {}")
          _write(tmp_path, "android/app/src/main/java/MainActivity.java",
                 "class MainActivity {}")
          assert detect_build_system(tmp_path) == (None, [])
      
      
      def test_platform_wrapper_package_json_without_wrapper_dep_counts(
              tmp_path: Path) -> None:
          # A package.json that names no wrapper dependency leaves android/ counted.
          _write(tmp_path, "package.json", json.dumps({"dependencies": {"left-pad": "1"}}))
          _write(tmp_path, "android/build.gradle", "buildscript {}")
          _write(tmp_path, "android/app/src/main/java/MainActivity.java",
                 "class MainActivity {}")
          assert detect_build_system(tmp_path) == ("gradle", ["android/build.gradle"])
      
      
      def _cordova_app(root: Path, prefix: str = "", package: str | None = None,
                       config_xml: bool = True) -> None:
          # `cordova platform add android` layout: config.xml and package.json at the
          # app root, the generated Android project under platforms/android/.
          if config_xml:
              _write(root, f"{prefix}config.xml",
                     '<widget xmlns:cdv="http://cordova.apache.org/ns/1.0"></widget>')
          deps = {"devDependencies": {package: "13.0.0"}} if package else {}
          _write(root, f"{prefix}package.json", json.dumps(deps))
          _write(root, f"{prefix}platforms/android/build.gradle", "buildscript {}")
          _write(root, f"{prefix}platforms/android/app/build.gradle", "apply plugin: 'x'")
          _write(root, f"{prefix}platforms/android/app/src/main/java/MainActivity.java",
                 "class MainActivity {}")
      
      
      @pytest.mark.parametrize("package", [None, "cordova-android"])
      def test_platform_wrapper_cordova_platforms_android_is_not_jvm(
              tmp_path: Path, package: str | None) -> None:
          _cordova_app(tmp_path, package=package)
          assert detect_build_system(tmp_path) == (None, [])
      
      
      def test_platform_wrapper_cordova_package_json_alone_is_not_jvm(
              tmp_path: Path) -> None:
          # No config.xml: the cordova-android package.json alone marks the root.
          _cordova_app(tmp_path, package="cordova-android", config_xml=False)
          assert detect_build_system(tmp_path) == (None, [])
      
      
      @pytest.mark.parametrize("package", ["react-native", "@capacitor/android"])
      def test_platform_wrapper_platforms_android_other_wrapper_package_counts(
              tmp_path: Path, package: str) -> None:
          # Only cordova-android marks platforms/android/; other wrapper packages
          # mark a sibling android/ and nothing else.
          _cordova_app(tmp_path, package=package, config_xml=False)
          assert detect_build_system(tmp_path)[0] == "gradle"
      
      
      def test_platform_wrapper_nested_cordova_app_is_not_jvm(tmp_path: Path) -> None:
          _cordova_app(tmp_path, "apps/hybrid/")
          assert detect_build_system(tmp_path) == (None, [])
      
      
      def test_platform_wrapper_platforms_android_without_cordova_counts(
              tmp_path: Path) -> None:
          # platforms/android/ with no Cordova marker beside platforms/ stays counted.
          _write(tmp_path, "platforms/android/build.gradle", "buildscript {}")
          _write(tmp_path, "platforms/android/src/main/java/A.java", "class A {}")
          assert detect_build_system(tmp_path) == (
              "gradle", ["platforms/android/build.gradle"])
      
      
      def test_platform_wrapper_does_not_hide_backend_jvm_source(tmp_path: Path) -> None:
          _flutter_app(tmp_path, "mobile/")
          _write(tmp_path, "backend/build.gradle.kts", "plugins {}")
          _write(tmp_path, "backend/src/main/kotlin/demo/App.kt", "fun main() {}")
          result = scan_jvm_capabilities(tmp_path, mvn_on_path=False)
          assert result["build_system"] == "gradle"
          assert result["build_files"] == ["backend/build.gradle.kts"]
          assert {k: v["state"] for k, v in result["capabilities"].items()} == {
              c: "honest_degrade" for c in
              ("liveness", "module_graph", "linting", "modernization")}
      
      
      def test_platform_wrapper_scan_liveness_has_no_java_tool(tmp_path: Path) -> None:
          _flutter_app(tmp_path)
          result = scan_liveness(tmp_path, run_dead_code=False)
          assert "jvm_capabilities" not in result
          assert not [t for t in result["dead_code"]["tools"]
                      if t.get("language") == "java"]
      
      
      # ── plugin crediting ───────────────────────────────────────────────────────
      
      def test_configured_checkstyle_is_credited() -> None:
          served = detect_configured_plugins(FIXTURE, ["pom.xml"])
          assert served.get("linting") == ["Checkstyle"]
          # Nothing configures modernization in the fixture.
          assert "modernization" not in served
      
      
      def test_error_prone_compiler_arg_credits_linting(tmp_path: Path) -> None:
          _write(tmp_path, "pom.xml",
                 "<project><build><plugins><plugin>"
                 "<artifactId>maven-compiler-plugin</artifactId>"
                 "<configuration><annotationProcessorPaths><path>"
                 "<artifactId>error_prone_core</artifactId></path>"
                 "</annotationProcessorPaths></configuration>"
                 "</plugin></plugins></build></project>")
          served = detect_configured_plugins(tmp_path, ["pom.xml"])
          assert served.get("linting") == ["error-prone"]
      
      
      # ── capability scan: states ────────────────────────────────────────────────
      
      def test_maven_liveness_offers_run_consent_when_mvn_present() -> None:
          result = scan_jvm_capabilities(FIXTURE, mvn_on_path=True)
          liveness = result["capabilities"]["liveness"]
          assert liveness["state"] == "offer"
          assert liveness["consent"] == "run"
          assert liveness["candidate_tool"] == "mvn dependency:analyze"
      
      
      def test_maven_liveness_offers_install_consent_when_mvn_absent() -> None:
          result = scan_jvm_capabilities(FIXTURE, mvn_on_path=False)
          liveness = result["capabilities"]["liveness"]
          assert liveness["state"] == "offer"
          assert liveness["consent"] == "install"
      
      
      def test_served_liveness_feeds_candidates() -> None:
          result = scan_jvm_capabilities(
              FIXTURE, mvn_on_path=True, analyze_output=_analyze_output())
          liveness = result["capabilities"]["liveness"]
          assert liveness["state"] == "served"
          assert liveness["candidate_count"] == 2
          assert liveness["used_undeclared_count"] == 1
      
      
      def test_linting_credited_module_graph_and_modernization_degrade() -> None:
          result = scan_jvm_capabilities(FIXTURE, mvn_on_path=True)
          caps = result["capabilities"]
          assert caps["linting"]["state"] == "credited"
          assert caps["linting"]["served_by"] == ["Checkstyle"]
          # Honest-degrade is a deliverable: state set AND a candidate tool named.
          assert caps["module_graph"]["state"] == "honest_degrade"
          assert caps["module_graph"]["candidate_tool"] == "jdeps"
          assert caps["modernization"]["state"] == "honest_degrade"
          assert caps["modernization"]["candidate_tool"] == "OpenRewrite"
      
      
      def test_every_capability_names_a_candidate_tool() -> None:
          # No capability may be silently absent - each carries a candidate.
          caps = scan_jvm_capabilities(FIXTURE, mvn_on_path=True)["capabilities"]
          for name, cap in caps.items():
              assert cap.get("candidate_tool"), f"{name} has no candidate tool"
              assert cap.get("gloss"), f"{name} has no gloss"
      
      
      def test_gradle_honest_degrades_liveness(tmp_path: Path) -> None:
          _write(tmp_path, "build.gradle", "plugins { id 'java' }")
          _write(tmp_path, "src/Main.java", "class Main {}")
          result = scan_jvm_capabilities(tmp_path, mvn_on_path=True)
          assert result["build_system"] == "gradle"
          liveness = result["capabilities"]["liveness"]
          assert liveness["state"] == "honest_degrade"
          assert liveness["candidate_tool"] == "mvn dependency:analyze"
      
      
      def test_non_jvm_repo_reports_unavailable(tmp_path: Path) -> None:
          _write(tmp_path, "main.py", "x = 1")
          result = scan_jvm_capabilities(tmp_path)
          assert result == {"available": False, "build_system": None, "build_files": []}
      
      
      # ── integration: scan_liveness merges JVM signal ───────────────────────────
      
      def test_scan_liveness_attaches_jvm_block_and_offer_tool() -> None:
          result = scan_liveness(FIXTURE, run_dead_code=False)
          assert "jvm_capabilities" in result
          java_tools = [t for t in result["dead_code"]["tools"]
                        if t.get("language") == "java"]
          assert len(java_tools) == 1
          assert java_tools[0]["status"] == "available_not_run"
          assert java_tools[0]["consent"] in {"run", "install"}
      
      
      def test_scan_liveness_served_merges_candidates_into_dead_code(monkeypatch) -> None:
          # Drive the served path by stubbing the analyze run so dead_code carries the
          # Maven candidates the runtime block (static_reachability) consumes.
          import lib.jvm_capabilities as jc
      
          monkeypatch.setattr(jc, "_run_dependency_analyze",
                              lambda root: _analyze_output())
          monkeypatch.setattr(jc.shutil, "which", lambda _: "/usr/bin/mvn")
          result = scan_liveness(FIXTURE, run_dead_code=False, run_build_tools=True)
          dc = result["dead_code"]
          java_candidates = [c for c in dc["candidates"]
                             if c["kind"] == "unused declared dependency"]
          assert len(java_candidates) == 2
          assert dc["available"] is True
      
      
      def test_non_jvm_scan_liveness_has_no_jvm_key(tmp_path: Path) -> None:
          _write(tmp_path, "main.py", "x = 1")
          result = scan_liveness(tmp_path, run_dead_code=False)
          assert "jvm_capabilities" not in result
      
    • test_keyhole_signals.py 54.1 KB
      """Unit tests for the deterministic keyhole-signal integration helpers.
      
      These cover the *derivation* logic that turns the five lib modules' outputs
      into the run-context blocks and the six named derived findings - the pure,
      git-free core of task #5. End-to-end wiring (build_run_context) is covered in
      test_assess_core.py.
      """
      from __future__ import annotations
      
      from pathlib import Path
      
      from lib import keyhole_signals as ks
      
      
      # --- containment_by_dir ------------------------------------------------------
      
      def test_containment_by_dir_flags_island_and_bleeder() -> None:
          """A directory whose commits stay inside it scores high; one whose commits
          keep dragging in outside files scores low."""
          commit_sets = [
              # island/ changes alone, repeatedly (self-contained)
              {Path("island/a.py")},
              {Path("island/a.py"), Path("island/b.py")},
              {Path("island/b.py")},
              {Path("island/a.py")},
              {Path("island/c.py")},
              # bleeder/ always drags in core/
              {Path("bleeder/x.py"), Path("core/util.py")},
              {Path("bleeder/y.py"), Path("core/util.py")},
              {Path("bleeder/x.py"), Path("core/other.py")},
              {Path("bleeder/z.py"), Path("core/util.py")},
              {Path("bleeder/x.py"), Path("shared/s.py")},
          ]
          cont = ks.containment_by_dir(Path("/nonexistent"), commit_sets, min_commits=5)
          assert cont["island"] == 1.0
          assert cont["bleeder"] == 0.0
          # The repo root "." is never a candidate directory (vacuously contained).
          assert "." not in cont
      
      
      def test_containment_by_dir_respects_min_commits() -> None:
          """Directories touched fewer than min_commits times are omitted."""
          commit_sets = [{Path("rare/a.py")}, {Path("rare/a.py")}]
          cont = ks.containment_by_dir(Path("/nonexistent"), commit_sets, min_commits=5)
          assert cont == {}
      
      
      # --- static-modularity projection -------------------------------------------
      
      def test_project_static_modularity_repo_level_onto_dirs() -> None:
          structure = {"available": True, "modularity_q": 0.5, "front_door_ratio": 0.9}
          proj = ks.project_static_modularity(structure, ["a", "b"])
          assert proj == {
              "a": {"modularity_q": 0.5, "front_door_ratio": 0.9},
              "b": {"modularity_q": 0.5, "front_door_ratio": 0.9},
          }
      
      
      def test_project_static_modularity_none_when_unavailable() -> None:
          assert ks.project_static_modularity({"available": False}, ["a"]) is None
          assert ks.project_static_modularity(None, ["a"]) is None
      
      
      # --- behaviour block ---------------------------------------------------------
      
      def test_behaviour_block_hidden_coupling_when_modular_but_bleeds() -> None:
          """A bleeding dir that looks modular statically becomes hidden_coupling."""
          commit_sets = [
              {Path("looksmodular/x.py"), Path("core/util.py")},
              {Path("looksmodular/y.py"), Path("core/util.py")},
              {Path("looksmodular/x.py"), Path("core/other.py")},
              {Path("looksmodular/z.py"), Path("core/util.py")},
              {Path("looksmodular/x.py"), Path("shared/s.py")},
          ]
          structure = {"available": True, "modularity_q": 0.6, "front_door_ratio": 0.95}
          block = ks.build_behaviour_block(Path("/nonexistent"), commit_sets, structure)
          assert block["available"] is True
          hc_paths = [h["path"] for h in block["hidden_coupling_findings"]]
          assert "looksmodular" in hc_paths
          # B1 change-coupling pairs are wired in (a list; exact contents depend on
          # min_support, exercised in test_change_coupling.py).
          assert isinstance(block["change_coupling_pairs"], list)
      
      
      def test_behaviour_block_bleeding_module_without_static_graph() -> None:
          """No static graph -> a bleeding dir degrades to bleeding_module."""
          commit_sets = [
              {Path("bleeder/x.py"), Path("core/util.py")},
              {Path("bleeder/y.py"), Path("core/util.py")},
              {Path("bleeder/x.py"), Path("core/other.py")},
              {Path("bleeder/z.py"), Path("core/util.py")},
              {Path("bleeder/x.py"), Path("shared/s.py")},
          ]
          block = ks.build_behaviour_block(
              Path("/nonexistent"), commit_sets, {"available": False}
          )
          findings = {f["finding"] for f in block["static_history_disagreement"]}
          assert "bleeding_module" in findings
          assert block["hidden_coupling_findings"] == []
      
      
      def test_behaviour_block_non_python_dir_never_hidden_coupling() -> None:
          """The static import graph is silent on doc/config trees, so a bleeding
          non-Python dir degrades to bleeding_module, never a false hidden_coupling -
          even when a Python static graph is available."""
          commit_sets = [
              {Path("docs/a.md"), Path("core/util.py")},
              {Path("docs/b.md"), Path("core/util.py")},
              {Path("docs/a.md"), Path("core/other.py")},
              {Path("docs/c.md"), Path("core/util.py")},
              {Path("docs/a.md"), Path("src/s.py")},
          ]
          structure = {"available": True, "modularity_q": 0.6, "front_door_ratio": 0.95}
          block = ks.build_behaviour_block(Path("/nonexistent"), commit_sets, structure)
          hc_paths = [h["path"] for h in block["hidden_coupling_findings"]]
          assert "docs" not in hc_paths
          findings = {f["path"]: f["finding"] for f in block["static_history_disagreement"]}
          assert findings.get("docs") == "bleeding_module"
      
      
      def test_behaviour_block_refactor_boundary_is_positive() -> None:
          commit_sets = [
              {Path("island/a.py")},
              {Path("island/b.py")},
              {Path("island/a.py")},
              {Path("island/c.py")},
              {Path("island/b.py")},
          ]
          block = ks.build_behaviour_block(Path("/nonexistent"), commit_sets, None)
          assert any(b["path"] == "island" for b in block["refactor_boundaries"])
      
      
      # --- documentation block -----------------------------------------------------
      
      def test_documentation_block_maps_doc_join() -> None:
          doc_join = {
              "available": True,
              "high_ccn_threshold": 10.0,
              "docs": [
                  {"path": "docs/api.md", "complexity_summarised": 20.0, "freshness": -1.0,
                   "doc_value": -20.0, "finding": "lying_map", "confidence": "high",
                   "subject_code_count": 2, "recommendation": "fix or delete"},
                  {"path": "src/hot.py", "complexity_summarised": 25.0, "freshness": 0.0,
                   "doc_value": 0.0, "finding": "unexplained_complexity", "confidence": None,
                   "subject_code_count": 0, "recommendation": "write contract"},
              ],
              "findings": {
                  "lying_maps": [{"path": "docs/api.md"}],
                  "unexplained_complexity": [{"path": "src/hot.py"}],
                  "good_contracts": [],
              },
          }
          block = ks.build_documentation_block(doc_join)
          assert block["available"] is True
          assert block["freshness_by_doc"] == {"docs/api.md": -1.0}
          assert "docs/api.md" in block["complexity_coverage"]
          assert "src/hot.py" not in block["freshness_by_doc"]  # not a real doc
          assert [d["path"] for d in block["stale_doc_on_complexity"]] == ["docs/api.md"]
          assert [d["path"] for d in block["unexplained_complexity"]] == ["src/hot.py"]
      
      
      def test_documentation_block_unavailable_passthrough() -> None:
          block = ks.build_documentation_block({"available": False})
          assert block["available"] is False
      
      
      # --- understanding block -----------------------------------------------------
      
      def test_understanding_block_maps_understanding() -> None:
          understanding = {
              "available": True,
              "high_ccn_threshold": 10.0,
              "modules": [
                  {"path": "a.py", "human_anchor": True, "intent_source": False,
                   "authorship_class": "human", "days_since_comprehension_event": 3,
                   "finding": None, "recommendation": None},
                  {"path": "b.py", "human_anchor": False, "intent_source": False,
                   "authorship_class": "agent", "days_since_comprehension_event": None,
                   "finding": "orphaned_understanding", "recommendation": "anchor"},
              ],
              "orphaned_understanding": ["b.py"],
          }
          block = ks.build_understanding_block(understanding)
          assert block["human_anchor_by_path"] == {"a.py": True, "b.py": False}
          assert block["intent_source_by_path"] == {"a.py": False, "b.py": False}
          assert block["authorship_class_by_path"] == {"a.py": "human", "b.py": "agent"}
          assert block["orphaned_understanding"] == ["b.py"]
      
      
      # --- runtime block -----------------------------------------------------------
      
      def test_runtime_block_carries_static_reachability() -> None:
          dead_code = {
              "available": True, "candidate_count": 1,
              "candidates": [{"path": "dead.py", "symbol": "f", "line": 1, "kind": "unused"}],
              "tools": [{"tool": "vulture", "status": "ran"}],
              "caveat": "static only",
          }
          observability = {"rung": 1, "reachable": {"present": False, "signals": []}}
          block = ks.build_runtime_block(dead_code, observability)
          assert block["static_reachability"]["candidate_count"] == 1
          assert block["static_reachability"]["candidates"][0]["path"] == "dead.py"
          assert block["observability_rung"] == 1
          assert block["runtime_evidence_available"] is False
      
      
      # --- derived findings --------------------------------------------------------
      
      def test_assemble_findings_fixed_order_and_actions() -> None:
          findings = ks.assemble_findings({
              "hidden_coupling": ["dir/a"],
              "lying_map": ["docs/x.md"],
              "unexplained_complexity": ["src/c.py"],
              "orphaned_understanding": ["src/o.py"],
              "candidate_dead_weight": ["src/d.py"],
              "refactor_boundary": ["island"],
          })
          names = [f["name"] for f in findings]
          assert names == ks.FINDING_ORDER
          for f in findings:
              assert set(f) == {"name", "paths", "action"}
              assert isinstance(f["paths"], list)
              assert f["action"] == ks.FINDING_ACTIONS[f["name"]]
          # spot-check the exact action strings the task contract requires
          by_name = {f["name"]: f for f in findings}
          assert by_name["hidden_coupling"]["action"] == "investigate the seam"
          assert by_name["unexplained_complexity"]["action"] == (
              "write the missing contract (do NOT auto-generate)"
          )
          assert by_name["refactor_boundary"]["action"] == "safe to hand an agent in isolation"
      
      
      def test_assemble_findings_dedupes_and_sorts_paths() -> None:
          findings = ks.assemble_findings({"lying_map": ["b.md", "a.md", "b.md"]})
          lying = next(f for f in findings if f["name"] == "lying_map")
          assert lying["paths"] == ["a.md", "b.md"]
      
      
      def test_candidate_dead_weight_requires_positive_dead_code_evidence() -> None:
          """Bias-to-keep: a high-complexity path is dead weight ONLY when static
          reachability positively flags it AND no intent source explains it."""
          complexity_stats = {
              "ccn": {"p95": 8.0},
              "top_complex": [
                  {"path": "dead.py", "ccn": 30},
                  {"path": "documented.py", "ccn": 30},
                  {"path": "alive.py", "ccn": 30},
              ],
          }
          dead_code = {"candidates": [
              {"path": "dead.py", "symbol": "f"},
              {"path": "documented.py", "symbol": "g"},
          ]}
          intent_source_by_path = {"documented.py": True}
          paths = ks.candidate_dead_weight_paths(
              complexity_stats, dead_code, intent_source_by_path
          )
          assert paths == ["dead.py"]  # documented.py has intent; alive.py not flagged
      
      
      def test_candidate_dead_weight_empty_without_dead_code() -> None:
          """No static-reachability evidence -> no dead-weight finding (keep bias)."""
          complexity_stats = {"ccn": {"p95": 8.0}, "top_complex": [{"path": "x.py", "ccn": 30}]}
          assert ks.candidate_dead_weight_paths(complexity_stats, {"candidates": []}, {}) == []
      
      
      # --- attention list ----------------------------------------------------------
      
      def test_attention_list_ranks_by_cross_axis_count() -> None:
          findings = [
              {"name": "hidden_coupling", "paths": ["worst"], "action": "x"},
              {"name": "lying_map", "paths": ["worst"], "action": "x"},
              {"name": "unexplained_complexity", "paths": ["worst", "single"], "action": "x"},
              {"name": "refactor_boundary", "paths": ["safe"], "action": "x"},
          ]
          attention = ks.build_attention_list(findings)
          assert attention[0]["path"] == "worst"
          assert attention[0]["score"] == 3
          # the positive refactor_boundary is never an attention (worst-across-axes) row
          assert all(a["path"] != "safe" for a in attention)
          paths = [a["path"] for a in attention]
          assert "single" in paths
      
      
      def test_attention_tie_break_orders_hotspot_rank_then_severity_then_path() -> None:
          """Five score-1 rows: top_hotspots members lead in hotspot rank order (here
          the reverse of path order), then descending marker severity, then path."""
          findings = ks.assemble_findings({
              "unactioned_intent": [
                  "src/zeta.py", "src/mid.py", "src/beta.py", "src/gamma.py", "src/alpha.py",
              ],
          })
          tie_break = ks.attention_tie_break(
              {"top_hotspots": [{"path": "src/zeta.py"}, {"path": "src/mid.py"}]},
              {"top_offenders": [
                  {"path": "src/alpha.py", "severity": 6.0},
                  {"path": "src/beta.py", "severity": 11.0},
                  {"path": "src/gamma.py", "severity": 3.0},
                  {"path": "src/gamma.py", "severity": 9.0},  # the file's highest counts
              ]},
              {},
          )
          attention = ks.build_attention_list(findings, tie_break=tie_break)
          assert [(u["path"], u["score"]) for u in attention] == [
              ("src/zeta.py", 1), ("src/mid.py", 1),
              ("src/beta.py", 1), ("src/gamma.py", 1), ("src/alpha.py", 1),
          ]
      
      
      def test_attention_tie_break_never_outranks_score() -> None:
          """The tie-break only orders equal scores: a score-2 row still leads."""
          findings = ks.assemble_findings({
              "unactioned_intent": ["hot.py", "cold.py"],
              "lying_map": ["cold.py"],
          })
          tie_break = ks.attention_tie_break({"top_hotspots": [{"path": "hot.py"}]}, None, {})
          ranked = [u["path"] for u in ks.build_attention_list(findings, tie_break=tie_break)]
          assert ranked == ["cold.py", "hot.py"]
      
      
      def test_attention_tie_break_hidden_coupling_lower_containment_first() -> None:
          """Hidden-coupling severity is 1 - containment_ratio: the directory whose
          commits bleed out most leads; a drift-only directory falls back to
          containment_by_dir, and equal severity falls through to path."""
          findings = ks.assemble_findings({"hidden_coupling": ["a", "b", "c", "d"]})
          tie_break = ks.attention_tie_break(
              {"top_hotspots": []},
              None,
              {
                  "hidden_coupling_findings": [
                      {"path": "a", "containment_ratio": 0.4},
                      {"path": "b", "containment_ratio": 0.1},
                      {"path": "c", "containment_ratio": 0.4},
                  ],
                  "containment_by_dir": {"d": 0.0},
              },
          )
          ranked = [u["path"] for u in ks.build_attention_list(findings, tie_break=tie_break)]
          assert ranked == ["d", "b", "a", "c"]
      
      
      def test_attention_tie_break_mixed_marker_and_coupling_rows_share_one_scale() -> None:
          """Marker severity (at least 5 for a stale marker) and coupling severity
          (0-1) meet in one sort. On a shared 0-1 scale a fully bleeding seam ranks
          with the worst marker file instead of below every marker file, and at the
          attention cap coupling rows are not all evicted by marker rows."""
          markers = [f"m{i}.py" for i in range(10)]
          findings = ks.assemble_findings({
              "unactioned_intent": markers,
              "hidden_coupling": ["bleeds", "tight"],
          })
          tie_break = ks.attention_tie_break(
              {"top_hotspots": []},
              {"top_offenders": [
                  {"path": p, "severity": 50.0 - i} for i, p in enumerate(markers)
              ]},
              {"hidden_coupling_findings": [
                  {"path": "bleeds", "containment_ratio": 0.0},
                  {"path": "tight", "containment_ratio": 0.9},
              ]},
          )
          ranked = [u["path"] for u in ks.build_attention_list(findings, tie_break=tie_break)]
          assert ranked[:2] == ["bleeds", "m0.py"]  # both 1.0; path breaks the tie
          assert len(ranked) == ks.MAX_ATTENTION_UNITS
          assert "bleeds" in ranked and "tight" not in ranked  # 0.1 sits below m8.py (0.84)
      
      
      def test_attention_tie_break_absent_falls_back_to_path() -> None:
          findings = ks.assemble_findings({"unactioned_intent": ["b.py", "a.py"]})
          assert [u["path"] for u in ks.build_attention_list(findings)] == ["a.py", "b.py"]
      
      
      def test_integrate_attention_tie_break_uses_top_hotspots(tmp_path: Path) -> None:
          """integrate threads top_hotspots and marker severity into the ranking."""
          pm = {
              "available": True, "aging_reliable": True,
              "stale_by_file": {"a.py": {}, "b.py": {}, "c.py": {}},
              "top_offenders": [
                  {"path": "a.py", "severity": 1.0}, {"path": "b.py", "severity": 5.0},
              ],
          }
          out = ks.integrate(
              repo_root=tmp_path,
              complexity_stats={"top_hotspots": [{"path": "c.py"}]},
              doc_staleness={}, dead_code={}, observability={}, structure={},
              promissory_markers=pm,
          )
          assert [u["path"] for u in out["attention"]] == ["c.py", "b.py", "a.py"]
      
      
      # --- Task 2: render_findings_markdown ----------------------------------------
      
      def _sample_findings() -> list[dict]:
          """Two findings with paths + the positive boundary, the rest empty."""
          return ks.assemble_findings({
              "hidden_coupling": ["dir/a"],
              "lying_map": ["docs/x.md", "docs/y.md"],
              "refactor_boundary": ["island"],
          })
      
      
      def test_render_findings_markdown_includes_paths_and_actions() -> None:
          findings = _sample_findings()
          attention = ks.build_attention_list(findings)
          md = ks.render_findings_markdown(findings, attention)
          assert md.startswith("## Cross-Layer Findings (Keyhole Readiness)")
          # Only findings with paths render a heading.
          assert "### hidden_coupling" in md
          assert "### lying_map" in md
          assert "### refactor_boundary" in md
          # The deterministic action text appears verbatim.
          assert f"Action: {ks.FINDING_ACTIONS['hidden_coupling']}" in md
          # Every path is listed.
          assert "- docs/x.md" in md
          assert "- docs/y.md" in md
          # Empty findings produce no heading.
          assert "### unexplained_complexity" not in md
          assert md.endswith("\n")
      
      
      def test_render_findings_markdown_empty_is_minimal_but_valid() -> None:
          findings = ks.assemble_findings({})  # all six/eight empty
          md = ks.render_findings_markdown(findings, [])
          assert md.startswith("## Cross-Layer Findings (Keyhole Readiness)")
          assert "No cross-layer findings surfaced" in md
          # No finding headings when nothing has paths.
          assert "###" not in md
      
      
      def test_render_findings_markdown_caps_paths_at_ten() -> None:
          findings = ks.assemble_findings({
              "lying_map": [f"docs/{i}.md" for i in range(20)],
          })
          md = ks.render_findings_markdown(findings, [])
          listed = [ln for ln in md.splitlines() if ln.startswith("- docs/")]
          assert len(listed) == ks.MAX_FINDING_PATHS_RENDERED
      
      
      def test_render_findings_markdown_attention_section_caps_at_five() -> None:
          findings = ks.assemble_findings({
              "hidden_coupling": [f"u{i}" for i in range(8)],
              "lying_map": [f"u{i}" for i in range(8)],  # each unit scores 2
          })
          attention = ks.build_attention_list(findings)
          md = ks.render_findings_markdown(findings, attention)
          assert "### Attention List (Priority Order)" in md
          # Attention rows carry the "(score N)" marker; finding path bullets do not.
          rows = [ln for ln in md.splitlines() if ln.startswith("- u") and "(score" in ln]
          assert len(rows) == ks.MAX_ATTENTION_ROWS_RENDERED
      
      
      # --- Issue #172: degenerate churn drops churn-derived findings ----------------
      
      # A bleeding-but-statically-modular dir -> hidden_coupling; reused below.
      _BLEEDING_COMMIT_SETS = [
          {Path("looksmodular/x.py"), Path("core/util.py")},
          {Path("looksmodular/y.py"), Path("core/util.py")},
          {Path("looksmodular/x.py"), Path("core/other.py")},
          {Path("looksmodular/z.py"), Path("core/util.py")},
          {Path("looksmodular/x.py"), Path("shared/s.py")},
      ]
      _MODULAR_STRUCTURE = {
          "available": True, "modularity_q": 0.6, "front_door_ratio": 0.95,
      }
      # A complex code file under a doc dir, so a stale high-confidence doc -> lying_map.
      _COMPLEXITY_STATS = {
          "ccn": {"p50": 3.0, "p95": 8.0, "max": 30.0},
          "files_scored": 1,
          "top_complex": [{"path": "pkg/core.py", "loc": 400, "ccn": 30.0}],
          "top_hotspots": [],
          "top_large": [],
      }
      
      
      def _stale_doc_staleness(*, churn_degenerate: bool) -> dict:
          """A high-confidence stale doc over pkg/core.py - a lying_map when the churn
          history is real, suppressed when it is degenerate."""
          return {
              "available": True,
              "churn_window": "commits (last 12mo)",
              "churn_degenerate": churn_degenerate,
              "docs": [{
                  "path": "pkg/README.md",
                  "ratio": 6.0,
                  "last_commit_days": 10,
                  "doc_churn_in_window": 1,
                  "code_churn_in_window": 6,
                  "subject_code_count": 1,
                  "subject_method": "nearest-ancestor",
                  "confidence": "high",
              }],
          }
      
      
      def _integrate(*, churn_degenerate: bool) -> dict:
          return ks.integrate(
              repo_root=Path("/nonexistent"),
              complexity_stats=_COMPLEXITY_STATS,
              doc_staleness=_stale_doc_staleness(churn_degenerate=churn_degenerate),
              dead_code={"available": False, "candidate_count": 0,
                         "candidates": [], "tools": []},
              observability={"rung": None, "reachable": {"present": False}},
              structure=_MODULAR_STRUCTURE,
              commit_sets=_BLEEDING_COMMIT_SETS,
          )
      
      
      def _finding_paths(result: dict, name: str) -> list[str]:
          return next(f["paths"] for f in result["derived_findings"] if f["name"] == name)
      
      
      def test_real_churn_produces_churn_derived_findings() -> None:
          """Baseline: with real churn (not degenerate), the lying_map and
          hidden_coupling findings fire and are counted in the keyhole summary."""
          result = _integrate(churn_degenerate=False)
          assert _finding_paths(result, "lying_map") == ["pkg/README.md"]
          assert _finding_paths(result, "hidden_coupling") == ["looksmodular"]
          counted = {c["name"] for c in result["keyhole_summary"]["concerns"]}
          assert {"lying_map", "hidden_coupling"} <= counted
      
      
      def test_degenerate_churn_drops_churn_derived_findings_from_summary() -> None:
          """Issue #172: on a degenerate churn history the same inputs yield zero
          churn-derived findings - lying_map (confidence capped in the join) and
          hidden_coupling (dropped here) are absent from derived_findings AND the
          keyhole_summary, so a reader sees 0 lying maps from a meaningless signal."""
          result = _integrate(churn_degenerate=True)
          assert _finding_paths(result, "lying_map") == []
          assert _finding_paths(result, "hidden_coupling") == []
          counted = {c["name"] for c in result["keyhole_summary"]["concerns"]}
          assert "lying_map" not in counted
          assert "hidden_coupling" not in counted
      
      
      # --- Task 3: build_keyhole_summary -------------------------------------------
      
      def test_build_keyhole_summary_counts_concerns_and_safe_zones() -> None:
          findings = ks.assemble_findings({
              "hidden_coupling": ["a", "b"],
              "lying_map": ["c"],
              "refactor_boundary": ["s1", "s2", "s3"],
          })
          summary = ks.build_keyhole_summary(findings)
          assert summary["safe_zones"] == 3
          assert summary["total_concerns"] == 3
          by_name = {c["name"]: c["count"] for c in summary["concerns"]}
          assert by_name == {"hidden_coupling": 2, "lying_map": 1}
          # The summary text is a pure count, parallel to the 0-8 score - never a score.
          assert summary["summary_text"] == (
              "3 structural concerns (2 hidden coupling, 1 lying map), 3 safe zones."
          )
      
      
      def test_build_keyhole_summary_empty_findings_neutral_message() -> None:
          summary = ks.build_keyhole_summary(ks.assemble_findings({}))
          assert summary["concerns"] == []
          assert summary["total_concerns"] == 0
          assert summary["safe_zones"] == 0
          assert summary["summary_text"] == "No structural concerns, 0 safe zones."
      
      
      def test_build_keyhole_summary_singular_plural() -> None:
          findings = ks.assemble_findings({
              "hidden_coupling": ["a"],
              "refactor_boundary": ["s1"],
          })
          summary = ks.build_keyhole_summary(findings)
          assert summary["summary_text"] == (
              "1 structural concern (1 hidden coupling), 1 safe zone."
          )
      
      
      def test_build_keyhole_summary_no_concerns_with_safe_zones() -> None:
          findings = ks.assemble_findings({"refactor_boundary": ["s1", "s2"]})
          summary = ks.build_keyhole_summary(findings)
          assert summary["summary_text"] == "No structural concerns, 2 safe zones."
      
      
      def test_build_keyhole_summary_hyphenates_self_referential_tests() -> None:
          """Compound-adjective finding names get an explicit display name
          ('self-referential tests'), not a naive underscore->space replace."""
          findings = ks.assemble_findings({"self_referential_tests": ["a", "b"]})
          summary = ks.build_keyhole_summary(findings)
          assert summary["summary_text"] == (
              "2 structural concerns (2 self-referential tests), 0 safe zones."
          )
      
      
      def test_finding_display_name_map_with_space_fallback() -> None:
          """Mapped names use the display-name override; unmapped names fall back to
          the plain underscore->space replace."""
          assert ks.finding_display_name("self_referential_tests") == "self-referential tests"
          assert ks.finding_display_name("hidden_coupling") == "hidden coupling"
          assert ks.finding_display_name("some_future_finding") == "some future finding"
      
      
      # --- Task 4: build_prescribed_actions / render_prescribed_actions ------------
      
      def test_build_prescribed_actions_picks_worst_finding_per_unit() -> None:
          findings = ks.assemble_findings({
              "hidden_coupling": ["worst"],
              "lying_map": ["worst", "mid"],
              "unexplained_complexity": ["worst", "mid"],
          })
          attention = ks.build_attention_list(findings)
          prescribed = ks.build_prescribed_actions(attention, findings)
          # 'worst' lands in 3 findings (score 3) -> ranks first.
          assert prescribed[0]["path"] == "worst"
          assert prescribed[0]["rank"] == 1
          # 'worst' spans hidden_coupling + lying_map + unexplained; hidden_coupling
          # is highest severity (earliest in FINDING_ORDER).
          assert prescribed[0]["action"] == ks.FINDING_ACTIONS["hidden_coupling"]
          # 'mid' lands in lying_map + unexplained_complexity; lying_map is worse.
          mid = next(p for p in prescribed if p["path"] == "mid")
          assert mid["action"] == ks.FINDING_ACTIONS["lying_map"]
      
      
      def test_build_prescribed_actions_caps_at_three() -> None:
          findings = ks.assemble_findings({
              "hidden_coupling": [f"u{i}" for i in range(5)],
              "lying_map": [f"u{i}" for i in range(5)],
          })
          attention = ks.build_attention_list(findings)
          prescribed = ks.build_prescribed_actions(attention, findings)
          assert len(prescribed) == 3
          assert [p["rank"] for p in prescribed] == [1, 2, 3]
      
      
      def test_build_prescribed_actions_empty_attention() -> None:
          assert ks.build_prescribed_actions([], ks.assemble_findings({})) == []
      
      
      def test_attention_low_signal_when_top_score_is_one() -> None:
          """Every row in one finding only: the ranking is weak, so it is flagged."""
          findings = ks.assemble_findings({"lying_map": [f"u{i}" for i in range(5)]})
          attention = ks.build_attention_list(findings)
          assert ks.is_attention_low_signal(attention) is True
      
      
      def test_attention_low_signal_false_when_any_row_scores_two() -> None:
          findings = ks.assemble_findings({
              "hidden_coupling": ["worst"],
              "lying_map": ["worst", "u1", "u2", "u3"],
          })
          assert ks.is_attention_low_signal(ks.build_attention_list(findings)) is False
      
      
      def test_attention_low_signal_false_on_empty_attention() -> None:
          """No rows means nothing to cap: the flag stays false."""
          assert ks.is_attention_low_signal([]) is False
      
      
      def test_integrate_attention_low_signal_caps_prescribed_at_one(tmp_path: Path) -> None:
          """Five score-1 rows: flag true, one prescribed action (rank 1), attention intact."""
          pm = {
              "available": True, "aging_reliable": True,
              "stale_by_file": {f"u{i}.py": {} for i in range(5)},
              "top_offenders": [],
          }
          out = ks.integrate(
              repo_root=tmp_path, complexity_stats={"top_hotspots": [{"path": "u3.py"}]},
              doc_staleness={}, dead_code={}, observability={}, structure={},
              promissory_markers=pm,
          )
          assert out["attention_low_signal"] is True
          assert len(out["attention"]) == 5
          assert [(p["path"], p["rank"]) for p in out["prescribed_actions"]] == [("u3.py", 1)]
      
      
      def test_build_prescribed_actions_attention_low_signal_false_keeps_three() -> None:
          """One score-2 row: flag false, prescribed_actions built as before (three)."""
          findings = ks.assemble_findings({
              "hidden_coupling": ["u0.py"],
              "unactioned_intent": [f"u{i}.py" for i in range(5)],
          })
          attention = ks.build_attention_list(findings)
          assert ks.is_attention_low_signal(attention) is False
          assert len(ks.build_prescribed_actions(attention, findings)) == 3
      
      
      def test_render_prescribed_actions_rows_and_empty() -> None:
          findings = ks.assemble_findings({
              "hidden_coupling": ["worst"],
              "lying_map": ["worst"],
          })
          attention = ks.build_attention_list(findings)
          prescribed = ks.build_prescribed_actions(attention, findings)
          rows = ks.render_prescribed_actions(prescribed)
          # Seven-column table row matching the SKILL.md Top 3 Actions template.
          assert rows.startswith("| 1 |")
          assert rows.count("|") == 8  # 7 columns => 8 pipes
          assert "`worst`" in rows
          assert ks.render_prescribed_actions([]) == ""
      
      
      # --- Task 5 E1: find_untrusted_hotspots --------------------------------------
      
      def test_find_untrusted_hotspots_flags_high_survivor_density() -> None:
          complexity_stats = {"top_hotspots": [
              {"path": "src/hot.py"}, {"path": "src/cold.py"},
          ]}
          test_pressure = {"per_file": [
              {"file": "src/hot.py", "survived": 4, "total": 10},   # 0.4 >= 0.3
              {"file": "src/cold.py", "survived": 1, "total": 10},  # 0.1 < 0.3
          ]}
          assert ks.find_untrusted_hotspots(complexity_stats, test_pressure) == ["src/hot.py"]
      
      
      def test_find_untrusted_hotspots_silent_without_mutation_data() -> None:
          complexity_stats = {"top_hotspots": [{"path": "src/hot.py"}]}
          # No per_file -> no mutation evidence -> nothing flagged (read-only default).
          assert ks.find_untrusted_hotspots(complexity_stats, {"per_file": []}) == []
          assert ks.find_untrusted_hotspots(complexity_stats, {}) == []
          assert ks.find_untrusted_hotspots(complexity_stats, None) == []
      
      
      def test_find_untrusted_hotspots_only_flags_actual_hotspots() -> None:
          complexity_stats = {"top_hotspots": [{"path": "src/hot.py"}]}
          test_pressure = {"per_file": [
              {"file": "src/not_a_hotspot.py", "survived": 9, "total": 10},
          ]}
          assert ks.find_untrusted_hotspots(complexity_stats, test_pressure) == []
      
      
      # --- Task 5 E2: test-to-code mapping -----------------------------------------
      
      def test_build_test_to_code_map_finds_colocated_tests(tmp_path: Path) -> None:
          repo = tmp_path
          (repo / "pkg").mkdir()
          (repo / "pkg" / "svc.go").write_text("package pkg")
          (repo / "pkg" / "svc_test.go").write_text("package pkg")
          (repo / "pkg" / "lonely.go").write_text("package pkg")  # no sibling test
          mapping = ks.build_test_to_code_map(repo, ["pkg/svc.go", "pkg/lonely.go"])
          assert mapping == {"pkg/svc_test.go": "pkg/svc.go"}
      
      
      def test_build_test_to_code_map_python_and_adjacent_dir(tmp_path: Path) -> None:
          repo = tmp_path
          (repo / "src").mkdir()
          (repo / "src" / "mod.py").write_text("x = 1")
          (repo / "src" / "tests").mkdir()
          (repo / "src" / "tests" / "test_mod.py").write_text("x = 1")
          mapping = ks.build_test_to_code_map(repo, ["src/mod.py"])
          assert mapping == {"src/tests/test_mod.py": "src/mod.py"}
      
      
      def test_find_sibling_test_skips_test_files_themselves(tmp_path: Path) -> None:
          repo = tmp_path
          (repo / "foo_test.go").write_text("package x")
          assert ks._find_sibling_test(repo, "foo_test.go") is None
          assert ks.build_test_to_code_map(repo, ["foo_test.go"]) == {}
      
      
      # --- Accretion ratchet finding -----------------------------------------------
      
      def _accreting_block(*, reliable: bool = True) -> dict:
          """A minimal well-formed accretion_ratchet run-context block."""
          return {
              "available": True,
              "reliable": reliable,
              "deletion_fraction_threshold": 0.15,
              "total_in_band": 2,
              "files": [
                  {
                      "path": "src/fat.py",
                      "net_additions": 2400,
                      "commit_count": 31,
                      "deletion_fraction": 0.0,
                      "time_span_months": 18.0,
                  },
                  {
                      "path": "lib/bloat.py",
                      "net_additions": 800,
                      "commit_count": 12,
                      "deletion_fraction": 0.05,
                      "time_span_months": 6.0,
                  },
              ],
          }
      
      
      def test_accretion_ratchet_finding_returns_paths_worst_first() -> None:
          """Files are returned sorted by net additions descending, then path."""
          run_ctx = {"accretion_ratchet": _accreting_block()}
          paths = ks._accretion_ratchet_finding(run_ctx)
          # net_additions: fat.py=2400 > bloat.py=800
          assert paths == ["src/fat.py", "lib/bloat.py"]
      
      
      def test_accretion_ratchet_finding_unavailable_returns_empty() -> None:
          """Block with available=False -> no finding paths (graceful degrade)."""
          run_ctx = {"accretion_ratchet": {"available": False, "files": []}}
          assert ks._accretion_ratchet_finding(run_ctx) == []
      
      
      def test_accretion_ratchet_finding_absent_block_returns_empty() -> None:
          """Missing accretion_ratchet key -> no finding paths."""
          assert ks._accretion_ratchet_finding({}) == []
      
      
      def test_accretion_ratchet_finding_no_files_returns_empty() -> None:
          """Available block but empty files list -> no finding paths."""
          run_ctx = {"accretion_ratchet": {"available": True, "reliable": True, "files": []}}
          assert ks._accretion_ratchet_finding(run_ctx) == []
      
      
      def test_accretion_ratchet_finding_action_text_matches_config() -> None:
          """The action string in FINDING_ACTIONS matches the requirement."""
          assert ks.FINDING_ACTIONS["accretion_ratchet"] == (
              "refactor down: extract, delete dead code, or split the file"
          )
      
      
      def test_format_accretion_items_roll_up_and_per_file_lines() -> None:
          """Roll-up sentence + one line per file in worst-first order."""
          run_ctx = {"accretion_ratchet": _accreting_block()}
          items = ks._format_accretion_items(run_ctx)
          # First item is the roll-up sentence.
          assert items[0].startswith("2 files show monotonic growth")
          assert "2 hottest" in items[0]
          # Per-file lines include path, LOC, time span, commits, deletion fraction.
          assert any("src/fat.py" in line and "+2,400 LOC" in line for line in items)
          assert any("lib/bloat.py" in line for line in items)
          # Worst offender comes first.
          fat_idx = next(i for i, line in enumerate(items) if "src/fat.py" in line)
          bloat_idx = next(i for i, line in enumerate(items) if "lib/bloat.py" in line)
          assert fat_idx < bloat_idx
      
      
      def test_format_accretion_items_reliable_false_adds_disclaimer() -> None:
          """Unreliable history appends a disclaimer line."""
          run_ctx = {"accretion_ratchet": _accreting_block(reliable=False)}
          items = ks._format_accretion_items(run_ctx)
          assert any("UNRELIABLE" in line for line in items)
      
      
      def test_format_accretion_items_reliable_true_no_disclaimer() -> None:
          """Reliable history: no disclaimer line."""
          run_ctx = {"accretion_ratchet": _accreting_block(reliable=True)}
          items = ks._format_accretion_items(run_ctx)
          assert not any("UNRELIABLE" in line for line in items)
      
      
      def test_accretion_ratchet_in_finding_order_and_actions() -> None:
          """accretion_ratchet is present in both FINDING_ORDER and FINDING_ACTIONS."""
          assert "accretion_ratchet" in ks.FINDING_ORDER
          assert "accretion_ratchet" in ks.FINDING_ACTIONS
          # Placement: after unactioned_intent, before orphaned_understanding.
          order = ks.FINDING_ORDER
          ui_idx = order.index("unactioned_intent")
          ar_idx = order.index("accretion_ratchet")
          ou_idx = order.index("orphaned_understanding")
          assert ui_idx < ar_idx < ou_idx
      
      
      def test_integrate_accretion_ratchet_wired_in() -> None:
          """When accretion_ratchet block is passed to integrate(), the finding fires.
      
          assemble_findings sorts paths alphabetically for determinism - the finding
          result carries both files regardless of net_additions order.
          """
          accreting = _accreting_block()
          result = ks.integrate(
              repo_root=Path("/nonexistent"),
              complexity_stats=_COMPLEXITY_STATS,
              doc_staleness=_stale_doc_staleness(churn_degenerate=False),
              dead_code={"available": False, "candidate_count": 0,
                         "candidates": [], "tools": []},
              observability={"rung": None, "reachable": {"present": False}},
              structure=_MODULAR_STRUCTURE,
              commit_sets=_BLEEDING_COMMIT_SETS,
              accretion_ratchet=accreting,
          )
          paths = _finding_paths(result, "accretion_ratchet")
          # assemble_findings sorts alphabetically: lib/ before src/
          assert paths == sorted(["src/fat.py", "lib/bloat.py"])
          assert "src/fat.py" in paths
          assert "lib/bloat.py" in paths
      
      
      def test_integrate_accretion_ratchet_absent_is_silent() -> None:
          """Without accretion_ratchet arg, the finding is silent (empty paths)."""
          result = ks.integrate(
              repo_root=Path("/nonexistent"),
              complexity_stats=_COMPLEXITY_STATS,
              doc_staleness=_stale_doc_staleness(churn_degenerate=False),
              dead_code={"available": False, "candidate_count": 0,
                         "candidates": [], "tools": []},
              observability={"rung": None, "reachable": {"present": False}},
              structure=_MODULAR_STRUCTURE,
              commit_sets=_BLEEDING_COMMIT_SETS,
          )
          assert _finding_paths(result, "accretion_ratchet") == []
      
      
      # --- override_contradicts_signals finding (archetype marker vs signals) ------
      
      def test_override_contradicts_in_finding_order_and_actions() -> None:
          """override_contradicts_signals is a named finding, positioned before the
          one positive finding (refactor_boundary stays last)."""
          assert "override_contradicts_signals" in ks.FINDING_ORDER
          assert "override_contradicts_signals" in ks.FINDING_ACTIONS
          order = ks.FINDING_ORDER
          assert order.index("override_contradicts_signals") < order.index("refactor_boundary")
          assert ks.FINDING_ACTIONS["override_contradicts_signals"] == (
              "Review archetype marker - deterministic signals suggest a different "
              "classification"
          )
      
      
      def test_integrate_override_contradiction_fires_finding() -> None:
          """A contradicting archetype block makes the finding fire against its source."""
          archetype = {
              "available": True,
              "override_contradicts_signals": True,
              "override_source": "CLAUDE.md",
          }
          result = ks.integrate(
              repo_root=Path("/nonexistent"),
              complexity_stats=_COMPLEXITY_STATS,
              doc_staleness=_stale_doc_staleness(churn_degenerate=False),
              dead_code={"available": False, "candidate_count": 0,
                         "candidates": [], "tools": []},
              observability={"rung": None, "reachable": {"present": False}},
              structure=_MODULAR_STRUCTURE,
              commit_sets=_BLEEDING_COMMIT_SETS,
              archetype=archetype,
          )
          assert _finding_paths(result, "override_contradicts_signals") == ["CLAUDE.md"]
      
      
      def test_integrate_override_contradiction_absent_is_silent() -> None:
          """No archetype contradiction -> the finding is silent (empty paths)."""
          result = ks.integrate(
              repo_root=Path("/nonexistent"),
              complexity_stats=_COMPLEXITY_STATS,
              doc_staleness=_stale_doc_staleness(churn_degenerate=False),
              dead_code={"available": False, "candidate_count": 0,
                         "candidates": [], "tools": []},
              observability={"rung": None, "reachable": {"present": False}},
              structure=_MODULAR_STRUCTURE,
              commit_sets=_BLEEDING_COMMIT_SETS,
              archetype={"available": True, "override_contradicts_signals": False},
          )
          assert _finding_paths(result, "override_contradicts_signals") == []
      
      
      # --- config-exclusion disclosure (apply_config_excludes) ---------------------
      
      def test_apply_config_excludes_filters_and_counts() -> None:
          """Excluded finding paths are dropped from findings and returned separately."""
          findings = [
              {"name": "hidden_coupling", "paths": ["vendor/x", "src/a"], "action": "z"},
              {"name": "refactor_boundary", "paths": ["vendor/y"], "action": "z"},
          ]
          filtered, dropped = ks.apply_config_excludes(findings, {"vendor"}, [])
          kept = [p for f in filtered for p in f["paths"]]
          assert kept == ["src/a"]
          assert dropped == ["vendor/x", "vendor/y"]
      
      
      def test_apply_config_excludes_pattern_match() -> None:
          """A basename glob pattern suppresses matching finding paths."""
          findings = [{"name": "lying_map", "paths": ["docs/gen.md", "docs/hand.md"],
                       "action": "z"}]
          filtered, dropped = ks.apply_config_excludes(findings, set(), ["gen.md"])
          assert filtered[0]["paths"] == ["docs/hand.md"]
          assert dropped == ["docs/gen.md"]
      
      
      def test_apply_config_excludes_noop_without_config() -> None:
          """No excludes -> findings untouched, nothing dropped."""
          findings = [{"name": "hidden_coupling", "paths": ["a"], "action": "z"}]
          filtered, dropped = ks.apply_config_excludes(findings, set(), [])
          assert filtered == findings
          assert dropped == []
      
      
      # A self-contained directory: repeatedly touched alone, so it reads as a
      # refactor_boundary (the git-log containment view, which scan-level excludes
      # never filter).
      _ISLAND_COMMIT_SETS = [
          {Path("island/a.py")},
          {Path("island/b.py")},
          {Path("island/a.py")},
          {Path("island/c.py")},
          {Path("island/b.py")},
      ]
      
      
      def test_integrate_excludes_suppress_finding_and_report_paths() -> None:
          """A config-excluded finding path is filtered from findings but disclosed.
      
          The refactor_boundary comes from the git-log containment view, which the
          scan-level exclude never touches - so integrate() is where it is filtered.
          """
          result = ks.integrate(
              repo_root=Path("/nonexistent"),
              complexity_stats=_COMPLEXITY_STATS,
              doc_staleness=_stale_doc_staleness(churn_degenerate=False),
              dead_code={"available": False, "candidate_count": 0,
                         "candidates": [], "tools": []},
              observability={"rung": None, "reachable": {"present": False}},
              structure=None,
              commit_sets=_ISLAND_COMMIT_SETS,
              exclude_dirs={"island"},
          )
          # island/ was a refactor_boundary; the exclude filters it out of findings.
          assert "island" not in _finding_paths(result, "refactor_boundary")
          # ...but it is disclosed as a suppressed finding path.
          assert "island" in result["excluded_finding_paths"]
      
      
      def test_integrate_no_excludes_reports_empty_suppression() -> None:
          """Without excludes, excluded_finding_paths is empty."""
          result = ks.integrate(
              repo_root=Path("/nonexistent"),
              complexity_stats=_COMPLEXITY_STATS,
              doc_staleness=_stale_doc_staleness(churn_degenerate=False),
              dead_code={"available": False, "candidate_count": 0,
                         "candidates": [], "tools": []},
              observability={"rung": None, "reachable": {"present": False}},
              structure=None,
              commit_sets=_ISLAND_COMMIT_SETS,
          )
          assert result["excluded_finding_paths"] == []
          assert "island" in _finding_paths(result, "refactor_boundary")
      
      
      # --- structure drift (Tier 1) folded into hidden_coupling --------------------
      
      def _drift_tier1(pairs: list[tuple[str, str]]) -> dict:
          """A minimal available Tier 1 result carrying only the hidden-seam list."""
          return {
              "available": True,
              "human_split_but_cochange": [
                  {"file_a": a, "file_b": b} for a, b in pairs
              ],
          }
      
      
      def test_drift_hidden_coupling_unavailable_is_empty() -> None:
          """An unavailable Tier 1 result yields no hidden-coupling dirs."""
          assert ks.structure_drift_hidden_coupling_dirs({"available": False}) == []
          assert ks.structure_drift_hidden_coupling_dirs({}) == []
      
      
      def test_drift_hidden_coupling_recurring_dir_pair_surfaces() -> None:
          """Two trees straddled by >= min_pairs distinct file pairs both surface.
      
          Two distinct file pairs link src/ and lib/; the pair recurs, so both
          directories read as a genuine hidden seam.
          """
          tier1 = _drift_tier1([
              ("src/a.py", "lib/x.py"),
              ("src/b.py", "lib/y.py"),
          ])
          assert ks.structure_drift_hidden_coupling_dirs(tier1) == ["lib", "src"]
      
      
      def test_drift_hidden_coupling_hub_file_is_not_a_seam() -> None:
          """A single hub file coupling with every tree manufactures no seam.
      
          The version hot-file shape: one file in cfg/ co-changes with a different
          directory on each pair. Each directory *pair* occurs exactly once, so none
          recurs and no directory surfaces - the version-bump ritual is not drift.
          """
          tier1 = _drift_tier1([
              ("cfg/v.json", "src/a.py"),
              ("cfg/v.json", "lib/b.py"),
              ("cfg/v.json", "docs/c.md"),
          ])
          assert ks.structure_drift_hidden_coupling_dirs(tier1) == []
      
      
      def test_drift_hidden_coupling_ignores_root_and_intra_dir() -> None:
          """Pairs touching the repo root, or within one directory, are ignored.
      
          A root-level file (dir ``.``) has vacuous containment; an intra-directory
          pair is cohesion, not a cross-tree seam. Neither contributes a finding even
          when repeated.
          """
          tier1 = _drift_tier1([
              ("README.md", "src/a.py"),   # root side -> ignored
              ("README.md", "src/b.py"),   # root side -> ignored
              ("src/c.py", "src/d.py"),    # intra-dir -> ignored
              ("src/e.py", "src/f.py"),    # intra-dir -> ignored
          ])
          assert ks.structure_drift_hidden_coupling_dirs(tier1) == []
      
      
      def test_drift_hidden_coupling_single_pair_below_threshold() -> None:
          """One file pair straddling two trees is below the recurrence threshold."""
          tier1 = _drift_tier1([("src/a.py", "lib/x.py")])
          assert ks.structure_drift_hidden_coupling_dirs(tier1) == []
      
      
      def test_drift_tier1_silent_without_static_graph() -> None:
          """_structure_drift_tier1 returns unavailable when the static graph is out.
      
          With no import graph there is nothing to disagree with, so Tier 1 is not
          even attempted - the detector is never called.
          """
          out = ks._structure_drift_tier1(
              Path("/nonexistent"),
              {"available": False},
              {"available": True, "change_coupling_pairs": []},
          )
          assert out == {"available": False}
      
      
      def test_integrate_returns_structure_drift_tier1() -> None:
          """integrate() exposes the Tier 1 result for the orchestrator to serialise.
      
          With a /nonexistent repo there is no ownership map, so the detector degrades
          to available:False - but the key is present, proving the wiring exists.
          """
          result = ks.integrate(
              repo_root=Path("/nonexistent"),
              complexity_stats=_COMPLEXITY_STATS,
              doc_staleness=_stale_doc_staleness(churn_degenerate=False),
              dead_code={"available": False, "candidate_count": 0,
                         "candidates": [], "tools": []},
              observability={"rung": None, "reachable": {"present": False}},
              structure=_MODULAR_STRUCTURE,
              commit_sets=_BLEEDING_COMMIT_SETS,
          )
          assert "structure_drift_tier1" in result
          assert result["structure_drift_tier1"].get("available") in (True, False)
      
      
      # --- FINDING_MODES / mode_for_finding ---------------------------------------
      
      def test_finding_modes_cover_every_finding() -> None:
          """Every named finding maps to a mode - no finding reaches the report
          without a deterministic execution posture."""
          assert set(ks.FINDING_MODES) == set(ks.FINDING_ORDER)
      
      
      def test_finding_modes_use_only_the_closed_mode_set() -> None:
          """The three modes are a closed vocabulary; no finding invents a fourth."""
          allowed = {"characterize_first", "verify_then_retire", "refactor_safe"}
          assert set(ks.FINDING_MODES.values()) <= allowed
          assert ks.FINDING_MODE_VALUES == frozenset(ks.FINDING_MODES.values())
      
      
      def test_mode_for_finding_maps_known_types() -> None:
          assert ks.mode_for_finding("lying_map") == "verify_then_retire"
          assert ks.mode_for_finding("refactor_boundary") == "refactor_safe"
          assert ks.mode_for_finding("hidden_coupling") == "characterize_first"
      
      
      def test_mode_for_finding_defaults_for_unknown_or_missing() -> None:
          """An unknown or absent finding falls back to the conservative default."""
          assert ks.mode_for_finding(None) == ks.DEFAULT_FINDING_MODE
          assert ks.mode_for_finding("not_a_real_finding") == ks.DEFAULT_FINDING_MODE
          assert ks.DEFAULT_FINDING_MODE == "characterize_first"
      
      
      # --- archive exclusion from attention ------------------------------------------
      
      def test_archive_paths_excluded_from_attention_and_disclosed() -> None:
          """A path with an archive/archived/attic component never ranks in attention.
      
          The archived plan scores 2 (two negative findings) and would lead the list;
          it is excluded from attention and prescribed actions, and returned for the
          disclosure. A file whose name merely contains "archive" is not excluded.
          """
          findings = ks.assemble_findings({
              "accretion_ratchet": ["docs/archive/PLAN.md", "src/app.py"],
              "unactioned_intent": ["docs/archive/PLAN.md", "old/Attic/x.py"],
              "lying_map": ["legacy/archived/notes.py", "src/archive_writer.py"],
              "refactor_boundary": ["archive"],
          })
          attention, archived = ks.exclude_archive_from_attention(findings)
          ranked = [u["path"] for u in attention]
          assert ranked == ["src/app.py", "src/archive_writer.py"]
          assert archived == [
              "docs/archive/PLAN.md", "legacy/archived/notes.py", "old/Attic/x.py",
          ]
          prescribed = ks.build_prescribed_actions(attention, findings)
          assert [p["path"] for p in prescribed] == ["src/app.py", "src/archive_writer.py"]
          # The findings themselves still name the archived path (a true observation).
          acc = next(f for f in findings if f["name"] == "accretion_ratchet")
          assert "docs/archive/PLAN.md" in acc["paths"]
      
      
      def test_archive_paths_excluded_noop_without_archive() -> None:
          """No archive path -> attention equals build_attention_list, nothing disclosed."""
          findings = ks.assemble_findings({"accretion_ratchet": ["src/a.py"]})
          attention, archived = ks.exclude_archive_from_attention(findings)
          assert attention == ks.build_attention_list(findings)
          assert archived == []
      
      
      # --- prune_missing_finding_paths (renamed / deleted history) -----------------
      
      def test_pruned_finding_paths_only_git_history_findings(tmp_path: Path) -> None:
          """A git-history finding path absent from disk is dropped and returned
          sorted; paths that exist, and findings read from the working tree, are
          untouched."""
          (tmp_path / "live").mkdir()
          findings = ks.assemble_findings({
              "hidden_coupling": ["live", "gone", "also_gone"],
              "refactor_boundary": ["gone_island"],
              "unactioned_intent": ["not/on/disk.py"],
          })
          pruned, dropped = ks.prune_missing_finding_paths(findings, tmp_path)
          by_name = {f["name"]: f["paths"] for f in pruned}
          assert by_name["hidden_coupling"] == ["live"]
          assert by_name["refactor_boundary"] == []
          assert by_name["unactioned_intent"] == ["not/on/disk.py"]
          assert dropped == ["also_gone", "gone", "gone_island"]
          assert [f["name"] for f in pruned] == [f["name"] for f in findings]
      
      
      def test_pruned_finding_paths_stand_down_when_rename_map_incomplete(tmp_path: Path) -> None:
          """With git history read, a hidden_coupling dir absent from disk is pruned.
          When the rename map could not be built (git failed), a missing path may be
          an unfolded old name rather than a deletion, so nothing is pruned."""
          import subprocess
      
          from lib.change_coupling import RenameMap
      
          subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
      
          def run(complete: bool) -> dict:
              return ks.integrate(
                  repo_root=tmp_path,
                  complexity_stats=_COMPLEXITY_STATS,
                  doc_staleness=_stale_doc_staleness(churn_degenerate=False),
                  dead_code={"available": False, "candidate_count": 0,
                             "candidates": [], "tools": []},
                  observability={"rung": None, "reachable": {"present": False}},
                  structure=_MODULAR_STRUCTURE,
                  commit_sets=_BLEEDING_COMMIT_SETS,
                  rename_map=RenameMap({}, complete=complete),
              )
      
          stood_down = run(complete=False)
          assert _finding_paths(stood_down, "hidden_coupling")
          assert stood_down["pruned_finding_paths"] == []
          assert stood_down["rename_map_complete"] is False
          pruned = run(complete=True)
          assert _finding_paths(pruned, "hidden_coupling") == []
          assert pruned["pruned_finding_paths"] == sorted(
              _finding_paths(stood_down, "hidden_coupling"))
      
      
      def test_rename_map_incomplete_when_ancestry_check_fails(tmp_path: Path, monkeypatch) -> None:
          """A chain hop needs `git merge-base --is-ancestor`. Exit 128 (an object git
          cannot resolve, as at a shallow boundary) is a failure, not "unrelated": the
          map comes back empty and incomplete, and the prune stands down, so a live
          finding is never reported as deleted."""
          import subprocess
      
          import lib.change_coupling as cc
      
          def git(*args: str) -> None:
              subprocess.run(["git", "-C", str(tmp_path), "-c", "user.email=t@example.com",
                              "-c", "user.name=T", *args], check=True, capture_output=True)
      
          git("init", "-q")
          (tmp_path / "a.py").write_text("a = 1\n" * 5)
          git("add", "-A")
          git("commit", "-q", "-m", "a")
          git("mv", "a.py", "b.py")
          git("commit", "-q", "-m", "a -> b")
          git("mv", "b.py", "c.py")
          git("commit", "-q", "-m", "b -> c")
      
          real_run = subprocess.run
      
          def fake_run(cmd, *args, **kwargs):
              if "--is-ancestor" in cmd:
                  return subprocess.CompletedProcess(cmd, 128, b"", b"fatal: bad object")
              return real_run(cmd, *args, **kwargs)
      
          monkeypatch.setattr(cc.subprocess, "run", fake_run)
          rename_map = cc.build_rename_map(tmp_path)
          assert rename_map == cc.RenameMap({}, complete=False)
      
          result = ks.integrate(
              repo_root=tmp_path,
              complexity_stats=_COMPLEXITY_STATS,
              doc_staleness=_stale_doc_staleness(churn_degenerate=False),
              dead_code={"available": False, "candidate_count": 0,
                         "candidates": [], "tools": []},
              observability={"rung": None, "reachable": {"present": False}},
              structure=_MODULAR_STRUCTURE,
              commit_sets=_BLEEDING_COMMIT_SETS,
              rename_map=rename_map,
          )
          assert _finding_paths(result, "hidden_coupling")
          assert result["pruned_finding_paths"] == []
          assert result["rename_map_complete"] is False
      
    • test_liveness_scan.py 21.5 KB
      """Tests for Layer 1 liveness inputs: dead-code tier + observability rungs."""
      from __future__ import annotations
      
      import subprocess
      from pathlib import Path
      
      import lib.liveness_scan as liveness
      from lib.liveness_scan import (
          _parse_deadcode,
          _parse_staticcheck,
          _parse_ts_prune,
          _parse_vulture,
          scan_dead_code,
          scan_observability,
      )
      
      
      def _write(root: Path, rel: str, text: str) -> None:
          p = root / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      # ── dead-code parsers ──────────────────────────────────────────────────────
      
      def test_parse_vulture() -> None:
          out = (
              "src/foo.py:12: unused function 'bar' (60% confidence)\n"
              "src/foo.py:3: unused import 'os' (90% confidence)\n"
          )
          parsed = _parse_vulture(out)
          assert len(parsed) == 2
          assert parsed[0]["symbol"] == "bar"
          assert parsed[0]["line"] == 12
      
      
      def test_parse_ts_prune_skips_used_in_module() -> None:
          out = "src/a.ts:4 - unusedThing\nsrc/b.ts:9 - helper (used in module)\n"
          parsed = _parse_ts_prune(out)
          assert len(parsed) == 1
          assert parsed[0]["symbol"] == "unusedThing"
      
      
      def test_parse_staticcheck_and_deadcode() -> None:
          sc = "pkg/x.go:10:2: func unusedHelper is unused (U1000)\n"
          assert _parse_staticcheck(sc)[0]["line"] == 10
          dc = "pkg/y.go:5:1: unreachable func: deadOne\n"
          assert "unreachable" in _parse_deadcode(dc)[0]["kind"]
      
      
      # ── dead-code scan plumbing ────────────────────────────────────────────────
      
      def test_dead_code_degrades_when_tool_absent(tmp_path: Path, monkeypatch) -> None:
          _write(tmp_path, "foo.py", "x = 1")
          monkeypatch.setattr(liveness.shutil, "which", lambda _tool: None)
          r = scan_dead_code(tmp_path).as_dict()
          assert r["available"] is False
          assert r["candidate_count"] == 0
          assert any(t["status"] == "tool_absent" for t in r["tools"])
          assert "external consumer" in r["caveat"]  # the hard-limit caveat is present
      
      
      def test_dead_code_runs_and_parses(tmp_path: Path, monkeypatch) -> None:
          _write(tmp_path, "foo.py", "def unused(): pass")
          monkeypatch.setattr(liveness.shutil, "which", lambda _tool: "/usr/bin/" + _tool)
      
          def fake_run(cmd, **kwargs):
              return subprocess.CompletedProcess(
                  cmd, 3, stdout="foo.py:1: unused function 'unused' (60% confidence)\n",
                  stderr="",
              )
      
          monkeypatch.setattr(liveness.subprocess, "run", fake_run)
          r = scan_dead_code(tmp_path).as_dict()
          assert r["available"] is True
          assert r["candidate_count"] == 1
          assert r["candidates"][0]["symbol"] == "unused"
      
      
      def test_dead_code_timeout_degrades(tmp_path: Path, monkeypatch) -> None:
          _write(tmp_path, "foo.py", "x = 1")
          monkeypatch.setattr(liveness.shutil, "which", lambda _tool: "/usr/bin/" + _tool)
      
          def fake_run(cmd, **kwargs):
              raise subprocess.TimeoutExpired(cmd, 60)
      
          monkeypatch.setattr(liveness.subprocess, "run", fake_run)
          r = scan_dead_code(tmp_path).as_dict()
          assert any(t["status"] == "timeout" for t in r["tools"])
          assert r["candidate_count"] == 0  # no crash
      
      
      def test_dead_code_no_languages(tmp_path: Path) -> None:
          _write(tmp_path, "README.md", "just docs")
          r = scan_dead_code(tmp_path).as_dict()
          assert r["available"] is False
          assert r["candidate_count"] == 0
      
      
      def test_dead_code_skips_user_excluded_dirs_at_probe(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """User excludes apply at the language-presence probe too. A repo whose
          only Python lives in `regulatory-raw/` reports `tool_absent` (vulture
          never runs) when that dir is excluded - same as if no Python existed
          in scope at all."""
          _write(tmp_path, "regulatory-raw/loader.py", "x = 1")
          monkeypatch.setattr(liveness.shutil, "which", lambda _tool: "/usr/bin/" + _tool)
          ran = []
      
          def fake_run(cmd, **kwargs):
              ran.append(cmd)
              return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
      
          monkeypatch.setattr(liveness.subprocess, "run", fake_run)
          r = scan_dead_code(
              tmp_path, extra_exclude_dirs={"regulatory-raw"},
          ).as_dict()
          # vulture must not have been invoked - no Python in scope.
          assert ran == []
          assert r["available"] is False
      
      
      def test_dead_code_filters_user_excluded_candidates_post_scan(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """When vulture runs and emits candidates from a user-excluded dir,
          the post-scan filter drops them so they never reach the report."""
          _write(tmp_path, "app.py", "def used(): pass")
          _write(tmp_path, "regulatory-raw/loader.py", "def unused(): pass")
          monkeypatch.setattr(liveness.shutil, "which", lambda _tool: "/usr/bin/" + _tool)
      
          def fake_run(cmd, **kwargs):
              return subprocess.CompletedProcess(
                  cmd, 3,
                  stdout=(
                      "app.py:1: unused function 'foo' (60% confidence)\n"
                      "regulatory-raw/loader.py:1: unused function 'unused' (60% confidence)\n"
                  ),
                  stderr="",
              )
      
          monkeypatch.setattr(liveness.subprocess, "run", fake_run)
          r = scan_dead_code(
              tmp_path, extra_exclude_dirs={"regulatory-raw"},
          ).as_dict()
          assert r["available"] is True
          paths = {c["path"] for c in r["candidates"]}
          assert "app.py" in paths
          assert all("regulatory-raw" not in p for p in paths)
      
      
      def test_dead_code_excludes_test_fixtures(tmp_path: Path, monkeypatch) -> None:
          """Vulture is told to skip `**/tests/fixtures/**` at scan time, and the
          post-scan filter drops any fixture candidate that slips through, so a
          deliberately-dead fixture file never surfaces as repo dead code (#83)."""
          from lib.liveness_scan import _under_excluded, _vulture_excludes
      
          assert "*/tests/fixtures/*" in _vulture_excludes().split(",")
          assert _under_excluded("a/tests/fixtures/sample.py") is True
          assert _under_excluded("src/app.py") is False
      
      
      # ── observability rungs ────────────────────────────────────────────────────
      
      def test_observability_none(tmp_path: Path) -> None:
          _write(tmp_path, "README.md", "nothing runtime here")
          assert scan_observability(tmp_path).rung == 0
      
      
      def test_instrumented_but_human_only_is_rung_2(tmp_path: Path) -> None:
          """The meridian case: telemetry + a prose runbook, but no agent-invokable path."""
          _write(tmp_path, "package.json", '{"dependencies":{"prom-client":"1","winston":"3"}}')
          _write(tmp_path, "OBSERVABILITY.md",
                 "Dashboards live in Grafana. SLOs are tracked by the platform team.")
          r = scan_observability(tmp_path).as_dict()
          assert r["rung"] == 2
          assert r["instrumented"]["present"] is True
          assert r["discoverable"]["present"] is True
          assert r["reachable"]["present"] is False
      
      
      def test_agent_queryable_is_rung_3_via_mcp_and_runbook(tmp_path: Path) -> None:
          _write(tmp_path, "pyproject.toml", '[project]\ndependencies = ["opentelemetry-sdk"]')
          _write(tmp_path, ".mcp.json", '{"mcpServers":{"loki-logs":{"command":"loki-mcp"}}}')
          _write(tmp_path, "runbooks/oncall.md",
                 "Query logs:\n```bash\nkubectl logs deploy/api\n```\n")
          r = scan_observability(tmp_path).as_dict()
          assert r["rung"] == 3
          assert r["reachable"]["present"] is True
          # both the MCP server and the runnable runbook are recorded
          assert len(r["reachable"]["signals"]) >= 2
      
      
      def test_prose_mention_of_grafana_does_not_reach_rung_3(tmp_path: Path) -> None:
          """A runbook that merely *names* a dashboard tool isn't agent-reachable."""
          _write(tmp_path, "go.mod", "require go.opentelemetry.io/otel v1.0.0")
          _write(tmp_path, "runbooks/notes.md",
                 "We use Grafana and Datadog. Ask the on-call engineer for access.")
          r = scan_observability(tmp_path)
          assert r.rung == 2  # discoverable, not reachable - no runnable command in a fence
      
      
      def test_repo_skill_for_logs_is_reachable(tmp_path: Path) -> None:
          _write(tmp_path, "requirements.txt", "structlog\n")
          _write(tmp_path, ".claude/skills/tail-logs/SKILL.md", "# tail logs")
          r = scan_observability(tmp_path)
          assert r.rung == 3
          assert any("repo skill" in s for s in r.reachable)
      
      
      def test_overloaded_token_names_do_not_reach_rung_3(tmp_path: Path) -> None:
          """changelog/blog/login etc. must not be mistaken for telemetry channels."""
          _write(tmp_path, "requirements.txt", "structlog\n")  # rung 1 instrumentation
          _write(tmp_path, ".mcp.json", '{"mcpServers":{"changelog-bot":{"command":"x"},"login":{"command":"y"}}}')
          _write(tmp_path, "skills/blog-publisher/SKILL.md", "# blog")
          _write(tmp_path, "skills/catalog-search/SKILL.md", "# catalog")
          r = scan_observability(tmp_path)
          assert r.reachable == []      # no genuine log/metric/trace tool
          assert r.rung == 1            # instrumented only
      
      
      def test_observ_substring_does_not_reach_rung_3(tmp_path: Path) -> None:
          """`observer`/`observation` are not observability tooling; only `observab*` is."""
          _write(tmp_path, "requirements.txt", "structlog\n")
          _write(tmp_path, ".mcp.json", '{"mcpServers":{"observer-service":{"command":"x"}}}')
          assert scan_observability(tmp_path).rung == 1  # observer != observability
      
          _write(tmp_path, ".mcp.json", '{"mcpServers":{"observability-gw":{"command":"x"}}}')
          assert scan_observability(tmp_path).rung == 3  # observability is genuine
      
      
      def test_single_body_token_does_not_reach_discoverable(tmp_path: Path) -> None:
          """A lone product 'dashboard'/'alerting' mention isn't an observability doc."""
          _write(tmp_path, "features.md", "Our product dashboard shows charts to users.")
          _write(tmp_path, "ops.md", "We have alerting in the UI.")
          assert scan_observability(tmp_path).rung == 0
      
      
      def test_two_body_tokens_reach_discoverable(tmp_path: Path) -> None:
          # Instrumentation present so the discoverable doc can elevate the rung -
          # this test isolates the 2-distinct-token threshold for *discoverable*
          # detection, not the rung floor (see test_docs_only_without_instrumentation).
          _write(tmp_path, "requirements.txt", "structlog\n")
          _write(tmp_path, "ops.md",
                 "We watch SLOs in Grafana and page via alerting when budgets burn.")
          r = scan_observability(tmp_path)
          assert r.rung == 2
          assert r.discoverable
      
      
      def test_docs_only_without_instrumentation_is_rung_0(tmp_path: Path) -> None:
          """A repo that only *documents* observability - no telemetry emitted - must
          not inflate past rung 0. Regression for the self-referential false positive
          where this toolkit's own SKILL.md (prose describing runbooks/runnable
          queries) scored rung 3 with `instrumented: false`."""
          _write(tmp_path, "runbooks/oncall.md",
                 "We watch SLOs in Grafana and Datadog.\n"
                 "Query logs:\n```bash\nkubectl logs deploy/api\n```\n")
          r = scan_observability(tmp_path).as_dict()
          assert r["instrumented"]["present"] is False
          # the doc still registers as discoverable/reachable evidence...
          assert r["discoverable"]["present"] is True
          assert r["reachable"]["present"] is True
          # ...but with nothing instrumented there is no telemetry to reach: rung 0.
          assert r["rung"] == 0
      
      
      def test_mcp_tooling_reaches_rung_3_without_instrumentation(tmp_path: Path) -> None:
          """An invokable telemetry tool (.mcp.json log/metric server) is real
          agent-reachability on its own - it stands at rung 3 even with no in-repo
          instrumentation manifest (the telemetry lives in deployed infra)."""
          _write(tmp_path, ".mcp.json",
                 '{"mcpServers":{"loki-logs":{"command":"loki-mcp"}}}')
          r = scan_observability(tmp_path).as_dict()
          assert r["instrumented"]["present"] is False
          assert r["rung"] == 3
          assert any("MCP server" in s for s in r["reachable"]["signals"])
      
      
      # ── read-only: build-mutating dead-code tools are gated ────────────────────
      
      def test_build_tools_not_run_by_default(tmp_path: Path, monkeypatch) -> None:
          """Go tools (deadcode/staticcheck) compile the project, so a read-only run
          reports them available-but-not-run rather than executing them."""
          _write(tmp_path, "main.go", "package main\nfunc main(){}")
          monkeypatch.setattr(liveness.shutil, "which", lambda t: "/usr/bin/" + t)
      
          def fake_run(cmd, **kwargs):
              raise AssertionError("a build tool was executed in a read-only scan")
      
          monkeypatch.setattr(liveness.subprocess, "run", fake_run)
          r = scan_dead_code(tmp_path).as_dict()  # run_build_tools defaults False
          deadcode = next(t for t in r["tools"] if t["tool"] == "deadcode")
          assert deadcode["status"] == "available_not_run"
          assert "build" in deadcode["reason"].lower()
          assert r["candidate_count"] == 0
      
      
      def test_build_tools_run_when_opted_in(tmp_path: Path, monkeypatch) -> None:
          _write(tmp_path, "main.go", "package main\nfunc main(){}")
          monkeypatch.setattr(liveness.shutil, "which", lambda t: "/usr/bin/" + t)
      
          def fake_run(cmd, **kwargs):
              return subprocess.CompletedProcess(
                  cmd, 0, stdout="main.go:9:1: unreachable func: helper\n", stderr="")
      
          monkeypatch.setattr(liveness.subprocess, "run", fake_run)
          r = scan_dead_code(tmp_path, run_build_tools=True).as_dict()
          assert r["available"] is True
          assert r["candidate_count"] == 1
      
      
      def test_vendored_dead_code_is_filtered(tmp_path: Path, monkeypatch) -> None:
          """Candidates under .venv/node_modules are dropped so the cap stays about THIS repo."""
          _write(tmp_path, "app.py", "x = 1")
          monkeypatch.setattr(liveness.shutil, "which", lambda t: "/usr/bin/" + t)
      
          def fake_run(cmd, **kwargs):
              return subprocess.CompletedProcess(cmd, 3, stderr="", stdout=(
                  ".venv/lib/dep.py:5: unused function 'vendored' (60% confidence)\n"
                  "app.py:1: unused function 'mine' (60% confidence)\n"))
      
          monkeypatch.setattr(liveness.subprocess, "run", fake_run)
          r = scan_dead_code(tmp_path).as_dict()
          paths = {c["path"] for c in r["candidates"]}
          assert paths == {"app.py"}  # vendored candidate dropped
      
      
      # ── JavaScript / TypeScript tool choice ────────────────────────────────────
      
      def _which_without(*hidden: str):
          return lambda t, *a, **k: None if t in hidden else "/usr/bin/" + t
      
      
      def test_ts_prune_requires_tsconfig_not_applicable_without_one(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """ts-prune run bare from the root needs a root tsconfig.json; without one
          it has no project to analyse, so it is recorded not_applicable, not run,
          and never reads as a clean '0 candidate(s)'."""
          for i in range(6):
              _write(tmp_path, f"src/m{i}.ts", f"export const v{i} = {i};")
          monkeypatch.setattr(liveness.shutil, "which", _which_without("knip"))
      
          def fake_run(cmd, **kwargs):
              raise AssertionError(f"{cmd[0]} ran without a tsconfig.json")
      
          monkeypatch.setattr(liveness.subprocess, "run", fake_run)
          r = scan_dead_code(tmp_path).as_dict()
          ts_prune = [t for t in r["tools"] if t["tool"] == "ts-prune"]
          assert [t["status"] for t in ts_prune] == ["not_applicable"]
          assert "tsconfig.json" in ts_prune[0]["reason"]
          assert r["available"] is False
          assert r["candidate_count"] == 0
      
      
      def test_ts_prune_requires_tsconfig_runs_with_root_tsconfig(
          tmp_path: Path, monkeypatch,
      ) -> None:
          for i in range(6):
              _write(tmp_path, f"src/m{i}.ts", f"export const v{i} = {i};")
          _write(tmp_path, "tsconfig.json", '{"include": ["src"]}')
          monkeypatch.setattr(liveness.shutil, "which", _which_without("knip"))
          monkeypatch.setattr(
              liveness.subprocess, "run",
              lambda cmd, **kw: subprocess.CompletedProcess(
                  cmd, 0, stdout="src/m1.ts:1 - v1\n", stderr=""),
          )
          r = scan_dead_code(tmp_path).as_dict()
          ts_prune = next(t for t in r["tools"] if t["tool"] == "ts-prune")
          assert ts_prune["status"] == "ran"
          assert r["available"] is True
          assert r["candidate_count"] == 1
      
      
      def test_dominant_language_javascript_names_knip_when_absent(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """A JavaScript-dominant repository with a stray TypeScript subproject
          (its own nested tsconfig.json) is judged by its JavaScript: ts-prune does
          not run, and JavaScript liveness honest-degrades with knip named."""
          for i in range(6):
              _write(tmp_path, f"src/m{i}.js", f"export const v{i} = {i};")
          _write(tmp_path, "src/esm.mjs", "export const e = 1;")
          _write(tmp_path, "jsconfig.json", "{}")
          _write(tmp_path, "cdk/stack.ts", "export const c = 1;")
          _write(tmp_path, "cdk/tsconfig.json", "{}")
          monkeypatch.setattr(liveness.shutil, "which", _which_without("knip"))
      
          def fake_run(cmd, **kwargs):
              raise AssertionError(f"{cmd[0]} ran on a JavaScript-dominant repo")
      
          monkeypatch.setattr(liveness.subprocess, "run", fake_run)
          r = scan_dead_code(tmp_path).as_dict()
          assert r["available"] is False
          assert all(t["status"] != "ran" for t in r["tools"])
          typescript = [t for t in r["tools"] if t["language"] == "typescript"]
          assert [(t["tool"], t["status"]) for t in typescript] == [
              ("ts-prune", "not_applicable"),
          ]
          assert "1 TypeScript file(s) are not analysed" in typescript[0]["reason"]
          knip = [t for t in r["tools"] if t["tool"] == "knip"]
          assert len(knip) == 1
          assert knip[0]["language"] == "javascript"
          assert knip[0]["status"] == "honest_degrade"
          assert "knip" in knip[0]["reason"]
      
      
      def test_dominant_language_typescript_keeps_ts_prune_despite_some_js(
          tmp_path: Path, monkeypatch,
      ) -> None:
          for i in range(4):
              _write(tmp_path, f"src/m{i}.ts", f"export const v{i} = {i};")
          _write(tmp_path, "scripts/build.js", "module.exports = {};")
          _write(tmp_path, "tsconfig.json", "{}")
          monkeypatch.setattr(liveness.shutil, "which", _which_without("knip"))
          monkeypatch.setattr(
              liveness.subprocess, "run",
              lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0, stdout="", stderr=""),
          )
          r = scan_dead_code(tmp_path).as_dict()
          assert [(t["language"], t["tool"], t["status"]) for t in r["tools"]] == [
              ("typescript", "ts-prune", "ran"),
              ("javascript", "knip", "not_applicable"),
          ]
          js = r["tools"][1]
          assert "1 JavaScript file(s) are not analysed" in js["reason"]
      
      
      def test_dominant_language_follows_scope_not_sibling_subtree(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """A run scoped to a TypeScript package picks its tool from the in-scope
          files, not from a larger JavaScript sibling package elsewhere in the repo.
          The tool still runs repo-wide; only the language choice and candidates
          follow the scope."""
          for i in range(4):
              _write(tmp_path, f"packages/web/m{i}.ts", f"export const v{i} = {i};")
          for i in range(8):
              _write(tmp_path, f"packages/legacy/m{i}.js", f"export const v{i} = {i};")
          _write(tmp_path, "tsconfig.json", "{}")
          monkeypatch.setattr(liveness.shutil, "which", _which_without("knip"))
          calls = []
      
          def fake_run(cmd, **kwargs):
              calls.append(kwargs["cwd"])
              return subprocess.CompletedProcess(
                  cmd, 0, stdout="packages/web/m1.ts:1 - v1\n", stderr="")
      
          monkeypatch.setattr(liveness.subprocess, "run", fake_run)
          r = scan_dead_code(tmp_path, scope=tmp_path / "packages" / "web").as_dict()
          assert [(t["language"], t["tool"], t["status"]) for t in r["tools"]] == [
              ("typescript", "ts-prune", "ran"),
          ]
          assert calls == [str(tmp_path.resolve())]
          assert r["candidate_count"] == 1
      
      
      def test_dominant_language_counts_mts_and_cts_as_typescript(
          tmp_path: Path, monkeypatch,
      ) -> None:
          _write(tmp_path, "src/a.mts", "export const a = 1;")
          _write(tmp_path, "src/b.cts", "export const b = 1;")
          _write(tmp_path, "scripts/build.js", "module.exports = {};")
          _write(tmp_path, "tsconfig.json", "{}")
          monkeypatch.setattr(liveness.shutil, "which", _which_without("knip"))
          monkeypatch.setattr(
              liveness.subprocess, "run",
              lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0, stdout="", stderr=""),
          )
          r = scan_dead_code(tmp_path).as_dict()
          assert [(t["language"], t["tool"], t["status"]) for t in r["tools"]] == [
              ("typescript", "ts-prune", "ran"),
              ("javascript", "knip", "not_applicable"),
          ]
      
      
      def test_dominant_language_javascript_knip_present_is_available_not_run(
          tmp_path: Path, monkeypatch,
      ) -> None:
          _write(tmp_path, "index.js", "module.exports = {};")
          monkeypatch.setattr(liveness.shutil, "which", _which_without())
          r = scan_dead_code(tmp_path).as_dict()
          assert [(t["language"], t["tool"], t["status"]) for t in r["tools"]] == [
              ("javascript", "knip", "available_not_run"),
          ]
      
      
      def test_scorer_doc_names_every_dead_code_status() -> None:
          """Every `tools[].status` the scan can emit is named in the layer-scorer
          agent's guidance, so a new status never reaches the report unhandled (a
          skipped tool read as "no language tool present")."""
          import re
      
          lib_dir = Path(liveness.__file__).resolve().parent
          source = (lib_dir / "liveness_scan.py").read_text()
          emitted = set(re.findall(r'"(?:absent_)?status":\s*"(\w+)"', source))
          emitted |= set(re.findall(r'get\("absent_status",\s*"(\w+)"\)', source))
          assert {"not_applicable", "honest_degrade", "tool_absent"} <= emitted
          repo_root = lib_dir.parents[3]
          doc = (repo_root / "agents" / "assess-layer-scorer.md").read_text()
          missing = sorted(s for s in emitted if f"`{s}`" not in doc)
          assert not missing, f"assess-layer-scorer.md does not name: {missing}"
      
    • test_log_supersede.py 11.2 KB
      """log.md entry replacement and re-chain across core runs and finalize (#355).
      
      A core run on the same date and measured commit as the previous, never
      finalized run replaces that run's log entry instead of stacking a second one.
      Finalize fills the entry stamped with its run id, re-chains the log, and
      refuses while an earlier same-date entry still carries placeholders.
      """
      from __future__ import annotations
      
      import json
      import os
      import subprocess
      import sys
      from pathlib import Path
      
      import pytest
      
      from assess_core import build_run_context
      from assess_finalize import FinalizeValidationError, finalize_run
      from lib.badge import maturity_band
      from lib.wiki_writer import (
          LOG_PLACEHOLDER,
          read_log_entries,
          rewrite_log_entry,
          verify_log_chain,
      )
      
      DAY = "2026-09-18"
      
      
      def _git(repo: Path, *args: str) -> None:
          subprocess.run(
              ["git", "-C", str(repo), "-c", "user.email=t@example.com", "-c", "user.name=T", *args],
              check=True, capture_output=True, text=True, env=os.environ,
          )
      
      
      @pytest.fixture
      def repo(tmp_path: Path) -> Path:
          repo = tmp_path / "repo"
          (repo / "src").mkdir(parents=True)
          (repo / "src" / "hot.py").write_text("def hot(a):\n    return a\n", encoding="utf-8")
          _git(repo, "init", "-q")
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", "c1")
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 1, "loc": {"total": 2}, "ccn": {"max": 1, "mean": 1},
              "top_hotspots": [{"path": "src/hot.py", "loc": 2, "ccn": 1, "commits": 1}],
              "top_complex": [], "top_large": [],
          }), encoding="utf-8")
          return repo
      
      
      def _run(repo: Path) -> dict:
          return build_run_context(repo_root=repo, run_date=DAY, non_interactive=True)
      
      
      def _stage_finalize(assess_dir: Path, run_id: str) -> None:
          (assess_dir / ".cache").mkdir(exist_ok=True)
          (assess_dir / ".cache" / "finalize-input.json").write_text(json.dumps({
              "run_id": run_id, "score": 4.0, "denominator": 8,
              "maturity_label": maturity_band(4.0, 8),
              "top_action": "fixture action", "hotspot_actions": {},
          }), encoding="utf-8")
      
      
      def _day_headings(assess_dir: Path) -> int:
          log = (assess_dir / "log.md").read_text(encoding="utf-8")
          return sum(1 for line in log.splitlines() if line.startswith(f"## {DAY}"))
      
      
      def test_core_replaces_superseded_same_day_entry(repo: Path) -> None:
          assess_dir = repo / ".assess"
          first = _run(repo)
          second = _run(repo)
          log = (assess_dir / "log.md").read_text(encoding="utf-8")
          assert _day_headings(assess_dir) == 1
          assert first["run_id"] not in log
          assert second["run_id"] in log
          assert verify_log_chain(assess_dir) == (True, None)
          assert second["log_integrity"]["valid"] is True
      
          _stage_finalize(assess_dir, second["run_id"])
          finalize_run(assess_dir=assess_dir)
          assert _day_headings(assess_dir) == 1
          assert verify_log_chain(assess_dir) == (True, None)
      
          # A finalized entry is history: the next run appends, and discloses nothing.
          third = _run(repo)
          log = (assess_dir / "log.md").read_text(encoding="utf-8")
          assert _day_headings(assess_dir) == 2
          assert "fixture action" in log
          assert "History integrity broken" not in log
          assert third["log_integrity"]["valid"] is True
      
      
      def test_core_keeps_superseded_same_day_entry_on_new_commit(repo: Path) -> None:
          assess_dir = repo / ".assess"
          _run(repo)
          (repo / "src" / "warm.py").write_text("def warm(a):\n    return a\n", encoding="utf-8")
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", "c2")
          _run(repo)
          assert _day_headings(assess_dir) == 2
      
      
      def test_finalize_refuses_earlier_same_date_placeholder_entry(repo: Path) -> None:
          assess_dir = repo / ".assess"
          first = _run(repo)
          (repo / "src" / "warm.py").write_text("def warm(a):\n    return a\n", encoding="utf-8")
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", "c2")
          second = _run(repo)
          before = (assess_dir / "log.md").read_text(encoding="utf-8")
          _stage_finalize(assess_dir, second["run_id"])
          with pytest.raises(FinalizeValidationError, match=first["run_id"]):
              finalize_run(assess_dir=assess_dir)
          # Fail-closed: nothing written.
          assert (assess_dir / "log.md").read_text(encoding="utf-8") == before
          assert before.count(LOG_PLACEHOLDER) == 2
      
      
      def test_rewrite_log_entry_keeps_an_earlier_break_detectable(repo: Path) -> None:
          """Re-chaining after an edit must not bless an entry that was already broken."""
          assess_dir = repo / ".assess"
          for n in range(3):
              (repo / "src" / f"m{n}.py").write_text("x = 1\n", encoding="utf-8")
              _git(repo, "add", "-A")
              _git(repo, "commit", "-q", "-m", f"m{n}")
              _run(repo)
          log_path = assess_dir / "log.md"
          # An untampered log: rewriting the first entry re-chains the two after it.
          rewrite_log_entry(assess_dir, 0, read_log_entries(assess_dir)[0] + "\n")
          assert verify_log_chain(assess_dir) == (True, None)
          # Tamper with the middle entry only, then rewrite the first one.
          text = log_path.read_text(encoding="utf-8")
          cut = text.index("<!-- chain:") + len("<!-- chain:0123456789abcdef -->\n")
          log_path.write_text(
              text[:cut] + text[cut:].replace("**Files scored:**", "**Files scored (edited):**", 1),
              encoding="utf-8",
          )
          assert verify_log_chain(assess_dir) == (False, 2)
          entries = read_log_entries(assess_dir)
          rewrite_log_entry(assess_dir, 0, entries[0] + "\n")
          assert verify_log_chain(assess_dir) == (False, 2)
      
      
      def test_drop_entry_clears_earlier_same_date_placeholder_refusal(repo: Path) -> None:
          """The refusal names a supported recovery; following it lets finalize run
          and leaves a chain that verifies."""
          assess_dir = repo / ".assess"
          first = _run(repo)
          (repo / "src" / "warm.py").write_text("def warm(a):\n    return a\n", encoding="utf-8")
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", "c2")
          second = _run(repo)
          _stage_finalize(assess_dir, second["run_id"])
          with pytest.raises(FinalizeValidationError, match=f"--drop-entry {first['run_id']}"):
              finalize_run(assess_dir=assess_dir)
      
          script = Path(__file__).resolve().parents[1] / "scripts" / "assess_finalize.py"
          done = subprocess.run(
              [sys.executable, str(script), str(repo), "--drop-entry", first["run_id"]],
              capture_output=True, text=True,
          )
          assert done.returncode == 0, done.stderr
          log = (assess_dir / "log.md").read_text(encoding="utf-8")
          # The run is recorded as a tombstone, not erased: no heading, no placeholders.
          assert f"> Dropped run {first['run_id']} ({DAY})" in log
          assert log.count(LOG_PLACEHOLDER) == 1
          assert _day_headings(assess_dir) == 1
          assert verify_log_chain(assess_dir) == (True, None)
          finalize_run(assess_dir=assess_dir)
          assert _day_headings(assess_dir) == 1
          assert verify_log_chain(assess_dir) == (True, None)
      
          # A finalized entry is history: --drop-entry refuses it.
          refused = subprocess.run(
              [sys.executable, str(script), str(repo), "--drop-entry", second["run_id"]],
              capture_output=True, text=True,
          )
          assert refused.returncode == 1
          assert "finalized" in refused.stderr
      
      
      def test_finalize_refuses_stamped_log_without_this_runs_entry(repo: Path) -> None:
          """In a stamped log, a run whose entry is missing must not fall back to
          filling another run's unfilled entry."""
          assess_dir = repo / ".assess"
          other = _run(repo)
          before = (assess_dir / "log.md").read_text(encoding="utf-8")
          _stage_finalize(assess_dir, other["run_id"])
          ctx_path = assess_dir / "run-context.json"
          ctx = json.loads(ctx_path.read_text(encoding="utf-8"))
          ctx["run_id"] = "20260918000000-deadbeef"
          ctx_path.write_text(json.dumps(ctx), encoding="utf-8")
          fi = assess_dir / ".cache" / "finalize-input.json"
          fi.write_text(fi.read_text(encoding="utf-8").replace(other["run_id"], ctx["run_id"]), encoding="utf-8")
          with pytest.raises(FinalizeValidationError, match="no entry stamped"):
              finalize_run(assess_dir=assess_dir)
          assert (assess_dir / "log.md").read_text(encoding="utf-8") == before
      
      
      def test_core_replaces_superseded_same_day_entry_without_git(tmp_path: Path) -> None:
          """A target with no git has no commit to key on; two same-day runs still
          replace rather than stack, so finalize is not blocked."""
          root = tmp_path / "plain"
          (root / "src").mkdir(parents=True)
          (root / "src" / "hot.py").write_text("def hot(a):\n    return a\n", encoding="utf-8")
          first = build_run_context(repo_root=root, run_date=DAY, non_interactive=True)
          assert first["measured_commit"]["available"] is False
          second = build_run_context(repo_root=root, run_date=DAY, non_interactive=True)
          assess_dir = root / ".assess"
          assert _day_headings(assess_dir) == 1
          assert second["run_id"] in (assess_dir / "log.md").read_text(encoding="utf-8")
          assert verify_log_chain(assess_dir) == (True, None)
      
      
      _LEGACY_LOG = (
          "# Assess Log\n\n"
          "## 2026-05-01\n\n"
          "- **Files scored:** 80\n"
          "- **AI Readiness:** 5.0 / 8 (Solid)\n\n"
          "---\n"
      )
      
      
      def test_superseded_same_day_entry_keeps_pre_chain_history(repo: Path) -> None:
          """A pre-chain log's body and the first chained entry parse as one span;
          superseding must not remove it, or the legacy history goes with it."""
          assess_dir = repo / ".assess"
          (assess_dir / "log.md").write_text(_LEGACY_LOG, encoding="utf-8")
          first = _run(repo)
          _run(repo)
          log = (assess_dir / "log.md").read_text(encoding="utf-8")
          assert "## 2026-05-01" in log
          assert first["run_id"] in log
          assert _day_headings(assess_dir) == 2
          assert verify_log_chain(assess_dir) == (True, None)
      
      
      def test_drop_entry_refuses_span_holding_pre_chain_history(repo: Path) -> None:
          from assess_finalize import drop_unfinalized_entry
      
          assess_dir = repo / ".assess"
          (assess_dir / "log.md").write_text(_LEGACY_LOG, encoding="utf-8")
          first = _run(repo)
          before = (assess_dir / "log.md").read_text(encoding="utf-8")
          with pytest.raises(FinalizeValidationError, match="before the integrity chain"):
              drop_unfinalized_entry(assess_dir=assess_dir, run_id=first["run_id"])
          assert (assess_dir / "log.md").read_text(encoding="utf-8") == before
      
      
      def test_earlier_same_date_placeholder_without_run_id_does_not_block_finalize(repo: Path) -> None:
          """An unstamped earlier entry cannot be dropped by id; refusing on it would
          leave finalize with no way out, so it stays as history and finalize fills
          this run's entry."""
          from lib.wiki_writer import LogEntry, append_log_entry
      
          assess_dir = repo / ".assess"
          append_log_entry(assess_dir, LogEntry(
              run_date=DAY, files_scored=1, readiness_score=0.0,
              maturity_label=LOG_PLACEHOLDER, instructions_grade=None,
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="Deterministic ranker not yet wired (LLM picks Top 3)",
          ))
          ctx = _run(repo)
          _stage_finalize(assess_dir, ctx["run_id"])
          finalize_run(assess_dir=assess_dir)
          log = (assess_dir / "log.md").read_text(encoding="utf-8")
          assert log.count(LOG_PLACEHOLDER) == 1
          assert "fixture action" in log
          assert verify_log_chain(assess_dir) == (True, None)
      
    • test_no_contributions_scan.py 15.5 KB
      """The assess-pr no-contributions scan: extract the marked bash block and run it.
      
      The scan decides whether the end-of-run PR offer may target the upstream repo.
      It lives as bash inside skills/assess-pr/SKILL.md, so the test drives the exact
      text an agent would run rather than a paraphrase of it.
      """
      from __future__ import annotations
      
      import re
      import shutil
      import subprocess
      from pathlib import Path
      
      import pytest
      
      ASSESS_PR_SKILL = Path(__file__).resolve().parents[2] / "assess-pr" / "SKILL.md"
      START = "# no-contributions scan: start"
      END = "# no-contributions scan: end"
      STATEMENT = "This repository does not accept contributions. Please do not send a pull request."
      
      
      def _scan_block() -> str:
          lines = ASSESS_PR_SKILL.read_text(encoding="utf-8").splitlines()
          start = lines.index(START)
          end = lines.index(END)
          assert end - start >= 2, "scan block must hold at least one line of bash"
          return "\n".join(lines[start : end + 1]) + "\n"
      
      
      def _run_scan(repo_root: Path, var: str = "NO_CONTRIBUTIONS") -> str:
          script = f'REPO_ROOT="{repo_root}"\n{_scan_block()}printf %s "${var}"\n'
          result = subprocess.run(
              ["sh", "-c", script], capture_output=True, text=True, check=True
          )
          assert result.stderr == ""
          return result.stdout
      
      
      @pytest.mark.parametrize(
          "files, expected",
          [
              ({"README.md": f"# App\n{STATEMENT}\n"}, "1"),
              ({"README.md": "# App\n", "CONTRIBUTING.md": f"# Contributing\n{STATEMENT}\n"}, "1"),
              ({"README.md": "# App\nContributions are welcome. Please open a pull request.\n"}, "0"),
              ({"README.md": "# App\nWe are not accepting pull requests at this time.\n"}, "1"),
              ({"README.md": "# App\nPull requests are not accepted.\n"}, "1"),
              ({"README.md": "# App\nPRs welcome! See CONTRIBUTING.md.\n"}, "0"),
              ({"README.md": "# App\nWe cannot accept contributions.\n"}, "1"),
              ({"README.md": "# App\nSorry, we can't accept pull requests.\n"}, "1"),
              ({"README.md": "# App\nThe team is unable to accept external contributions.\n"}, "1"),
              ({"README.md": "# App\nWe don\u2019t accept pull requests.\n"}, "1"),
              ({"README.md": "# App\nThis project does not accept unsolicited pull requests.\n"}, "1"),
              ({"CONTRIBUTING.md": "Please do not open a pull request without first opening an issue.\n"}, "0"),
              ({"CONTRIBUTING.md": "Do not submit a PR until all tests pass locally.\n"}, "0"),
              ({"CONTRIBUTING.md": "Please don't open a PR directly against main.\n"}, "0"),
              ({"CONTRIBUTING.md": "PRs are not accepted without a linked issue.\n"}, "0"),
              ({"CONTRIBUTING.md": "Please do not open a pull request for trivial typo fixes.\n"}, "0"),
              ({"CONTRIBUTING.md": "Do not submit a PR with unrelated changes.\n"}, "0"),
              ({"CONTRIBUTING.md": "Do not open a pull request from a fork of a fork.\n"}, "0"),
              ({"CONTRIBUTING.md": "Do not open PRs to the release branch.\n"}, "0"),
              ({"CONTRIBUTING.md": "Do not open a pull request if you have not signed the CLA.\n"}, "0"),
              ({"README.md": "# App\nDo not open a pull request; open an issue instead.\n"}, "1"),
              ({"README.md": "# App\nPlease don't send PRs\n"}, "1"),
              ({"README.md": "# App\nWe are not currently accepting contributions.\n"}, "1"),
              ({"README.md": "# App\nWe are not accepting new contributions.\n"}, "1"),
              ({"README.md": "# App\nThis repo does not accept community contributions.\n"}, "1"),
              ({"README.md": "# App\nThis repo does not accept a pull request from anyone.\n"}, "1"),
              # Word boundary after the noun: "prs?" must not match the start of an
              # ordinary word once the modifier slot lets any word precede it.
              ({"README.md": "# App\nContributions welcome! The API does not accept a promise, only a value.\n"}, "0"),
              ({"README.md": "# App\nThis endpoint does not accept preflight requests.\n"}, "0"),
              ({"README.md": "# App\nWe do not accept private forks of the config.\n"}, "0"),
              ({"README.md": "# App\nThe daemon does not accept process signals.\n"}, "0"),
              ({"README.md": "# App\nThe loader does not accept project files.\n"}, "0"),
              ({"README.md": "# App\nThe CLI does not accept provided defaults.\n"}, "0"),
              ({"README.md": "# App\nWe do not accept prior versions of the schema.\n"}, "0"),
              ({"README.md": "# App\nDo not open private issues.\n"}, "0"),
              ({"README.md": "# App\nWe do not accept PRs.\n"}, "1"),
              ({"README.md": "# App\nWe are not accepting PRs right now.\n"}, "1"),
              ({"README.md": "# App\nWe do not accept PRs\n"}, "1"),
              ({}, "0"),
          ],
          ids=["readme", "contributing", "welcome", "not-accepting-prs", "prs-not-accepted",
               "prs-welcome", "cannot", "cant", "unable-to", "curly-apostrophe", "unsolicited",
               "cond-without", "cond-until", "cond-directly", "cond-accepted-without", "cond-for", "cond-with", "cond-from", "cond-to", "cond-if",
               "imperative-semicolon", "imperative-eol", "not-currently-accepting", "not-accepting-new",
               "community-contributions", "singular-pull-request",
               "fp-promise", "fp-preflight", "fp-private", "fp-process", "fp-project", "fp-provide",
               "fp-prior", "fp-imperative-private", "accept-prs-dot", "accepting-prs-right-now",
               "accept-prs-eol", "no-docs"],
      )
      def test_scan_sets_no_contributions(tmp_path: Path, files: dict[str, str], expected: str) -> None:
          for name, body in files.items():
              (tmp_path / name).write_text(body, encoding="utf-8")
          assert _run_scan(tmp_path) == expected
      
      
      def test_scan_keeps_the_matching_file_and_statement(tmp_path: Path) -> None:
          (tmp_path / "README.md").write_text("# App\n", encoding="utf-8")
          (tmp_path / "CONTRIBUTING.md").write_text(f"# Contributing\n{STATEMENT}\n", encoding="utf-8")
          assert _run_scan(tmp_path, "NO_CONTRIBUTIONS_SOURCE") == "CONTRIBUTING.md"
          assert _run_scan(tmp_path, "NO_CONTRIBUTIONS_STATEMENT") == STATEMENT
      
      
      def test_scan_reads_github_contributing_with_its_relative_path(tmp_path: Path) -> None:
          (tmp_path / ".github").mkdir()
          (tmp_path / ".github" / "CONTRIBUTING.md").write_text(f"{STATEMENT}\n", encoding="utf-8")
          assert _run_scan(tmp_path) == "1"
          assert _run_scan(tmp_path, "NO_CONTRIBUTIONS_SOURCE") == ".github/CONTRIBUTING.md"
      
      
      def test_scan_reads_docs_contributing_with_its_relative_path(tmp_path: Path) -> None:
          # GitHub also recognises docs/CONTRIBUTING.md; a refusal stated only there
          # must still suppress the fork-to-upstream offer.
          (tmp_path / "README.md").write_text("# App\n", encoding="utf-8")
          (tmp_path / "docs").mkdir()
          (tmp_path / "docs" / "CONTRIBUTING.md").write_text(f"{STATEMENT}\n", encoding="utf-8")
          assert _run_scan(tmp_path) == "1"
          assert _run_scan(tmp_path, "NO_CONTRIBUTIONS_SOURCE") == "docs/CONTRIBUTING.md"
      
      
      def test_phase_2_variant_is_scoped_to_read_only_targets() -> None:
          text = ASSESS_PR_SKILL.read_text(encoding="utf-8")
          phase2 = text.split("## Phase 2", 1)[1].split("## Step 5", 1)[0]
          bullet = next(line for line in phase2.splitlines() if "no-contributions" in line)
          tail = bullet.split("offer the fork variant instead.", 1)[1]
          assert "read-only" in tail
      
      
      def test_no_contributions_flow_never_references_the_upstream_pr_step() -> None:
          step5 = _step5()
          flow = step5.split("(no-contributions flow,", 1)[1].split("\n\n", 1)[0]
          assert "steps 1-2" not in flow
          assert "gh pr create --repo <owner>/<repo>" not in flow
          # The PR is created on the fork's own endpoint, never via a base-repo lookup.
          assert "repos/$FORK_SLUG/pulls" in flow
      
      
      def test_reusing_the_current_fork_needs_push_access() -> None:
          # A READ clone of someone else's fork is also IS_FORK=true; it must fork
          # again rather than push to a repo the user cannot write to.
          flow = _step5().split("(no-contributions flow,", 1)[1].split("\n\n", 1)[0]
          assert "`IS_OWN_FORK=true` and `CAN_PUSH=1`" in flow
      
      
      def _own_fork_block() -> str:
          lines = _step5().splitlines()
          start = next(i for i, line in enumerate(lines) if line.startswith("VIEWER_LC="))
          end = next(i for i in range(start, len(lines)) if lines[i] == "fi")
          return "\n".join(lines[start : end + 1]) + "\n"
      
      
      @pytest.mark.parametrize(
          "viewer, owner, is_fork, expected",
          [
              ("alice", "alice", "true", "true"),
              ("Alice", "alice", "true", "true"),
              # Push-capable collaborator on another user's fork: not their fork.
              ("bob", "alice", "true", "false"),
              ("alice", "alice", "false", "false"),
              # gh api user failed: never assume ownership.
              ("", "alice", "true", "false"),
          ],
          ids=["own", "own-case", "collaborator", "not-a-fork", "no-viewer"],
      )
      def test_is_own_fork_compares_viewer_with_fork_owner(
          tmp_path: Path, viewer: str, owner: str, is_fork: str, expected: str
      ) -> None:
          fake = tmp_path / "gh"
          body = f"echo '{{\"login\": \"{viewer}\"}}'" if viewer else "exit 1"
          fake.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8")
          fake.chmod(0o755)
          push_info = f'{{"isFork": {is_fork}, "owner": {{"login": "{owner}"}}}}'
          script = f"IS_FORK={is_fork}\nPUSH_INFO='{push_info}'\n{_own_fork_block()}printf %s \"$IS_OWN_FORK\"\n"
          result = subprocess.run(
              ["sh", "-c", script], capture_output=True, text=True, check=True,
              env={"PATH": f"{tmp_path}:/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin"},
          )
          assert result.stdout == expected
      
      
      def test_reusing_the_current_fork_needs_the_viewer_to_own_it() -> None:
          step5 = _step5()
          assert "owner" in step5.split("PUSH_INFO=", 1)[1].split("\n", 1)[0]
          flow = step5.split("(no-contributions flow,", 1)[1].split("\n\n", 1)[0]
          assert "any clone of someone else's fork, whatever the permission" in flow
          assert "skipping its fork step" not in step5
      
      
      def test_fork_pr_is_based_on_what_was_assessed() -> None:
          # A pre-existing fork's default branch can differ from the assessed
          # checkout; the PR must not carry the commits in between.
          flow = _step5().split("(no-contributions flow,", 1)[1].split("\n\n", 1)[0]
          pulls = flow.index("repos/$FORK_SLUG/pulls")
          for needle in ("BASE_SHA=", "assess/base-<YYYY-MM-DD>", "tell the user"):
              assert needle in flow[:pulls], needle
          assert flow.index("BASE_SHA=") < flow.index("FORK_BRANCH=assess/base-")
      
      
      def test_fork_pr_url_check_is_host_agnostic() -> None:
          flow = _step5().split("(no-contributions flow,", 1)[1].split("\n\n", 1)[0]
          assert "https://github.com/$FORK_SLUG" not in flow
          assert "/$FORK_SLUG/pull/<number>" in flow
      
      
      def test_fork_pr_body_file_is_written_before_it_is_used() -> None:
          flow = _step5().split("(no-contributions flow,", 1)[1].split("\n\n", 1)[0]
          assert flow.index("<body-file>") < flow.index("body=@<body-file>")
          assert "temp file" in flow[: flow.index("body=@<body-file>")]
      
      
      def test_fork_slug_is_derived_not_guessed() -> None:
          step5 = _step5()
          assert "<fork-owner>" not in step5
          flow = step5.split("(no-contributions flow,", 1)[1].split("\n\n", 1)[0]
          assert "FORK_SLUG=" in flow
          assert "repos/$FORK_SLUG/actions/permissions" in flow
      
      
      def test_fork_clone_of_a_no_contributions_repo_stays_in_the_fork() -> None:
          # In a clone of the user's own fork, viewerPermission is ADMIN (CAN_PUSH=1)
          # and a bare `gh pr create` targets the parent: the statement must still win.
          step5 = _step5()
          assert "isFork" in step5.split("PUSH_INFO=", 1)[1].split("\n", 1)[0]
          assert "IS_FORK=" in step5
          direct = next(line for line in step5.splitlines() if line.startswith("- `CAN_PUSH=1`"))
          assert "IS_FORK=true" in direct and "no-contributions flow" in direct
          flow_head = step5.split("(no-contributions flow,", 1)[1].split("\n", 1)[0]
          assert "IS_FORK=true" in flow_head
      
      
      def test_scan_leaves_source_empty_without_a_statement(tmp_path: Path) -> None:
          (tmp_path / "README.md").write_text("# App\nPRs welcome!\n", encoding="utf-8")
          assert _run_scan(tmp_path, "NO_CONTRIBUTIONS_SOURCE") == ""
          assert _run_scan(tmp_path, "NO_CONTRIBUTIONS_STATEMENT") == ""
      
      
      def test_push_capable_user_is_told_about_the_statement() -> None:
          text = ASSESS_PR_SKILL.read_text(encoding="utf-8")
          step5 = text.split("## Step 5", 1)[1].split("## Step 6", 1)[0]
          direct = next(line for line in step5.splitlines() if line.startswith("- `CAN_PUSH=1`"))
          assert "NO_CONTRIBUTIONS=1" in direct and "NO_CONTRIBUTIONS_SOURCE" in direct
      
      
      def _step5() -> str:
          text = ASSESS_PR_SKILL.read_text(encoding="utf-8")
          return text.split("## Step 5", 1)[1].split("## Step 6", 1)[0]
      
      
      def test_no_contributions_offer_needs_a_github_permission() -> None:
          # CAN_PUSH is 0 both for READ/TRIAGE and when gh returned nothing; only the
          # former can fork, so the no-contributions bullet must name the permission.
          bullet = next(line for line in _step5().splitlines()
                        if line.startswith("- `CAN_PUSH=0`") and "NO_CONTRIBUTIONS=1" in line)
          assert "`READ` / `TRIAGE`" in bullet
          no_remote = next(line for line in _step5().splitlines() if "`$PUSH_INFO` empty" in line)
          assert "every PR offer" in no_remote
      
      
      def test_pr_body_template_is_shared_by_every_flow() -> None:
          step5 = _step5()
          flows = [m.start() for m in re.finditer(r"If the user \*\*selected the PR offer\*\*", step5)]
          assert len(flows) == 3
          footer = step5.index("plugin reference at the bottom")
          assert footer > flows[-1]
          footer_line = step5[: footer].rsplit("\n", 1)[1]
          assert not re.match(r"\s*\d+\.", footer_line), "template must not be a step of one flow"
          for name in ("direct", "fork", "no-contributions"):
              assert name in step5[step5.rfind("\n", 0, footer) : step5.index("\n", footer)]
      
      
      def test_each_flow_numbers_its_steps_once() -> None:
          step5 = _step5()
          for block in re.split(r"If the user \*\*selected the PR offer\*\*", step5)[1:]:
              numbers = [int(n) for n in re.findall(r"^(\d+)\. ", block.split("\n\n", 1)[0], re.M)]
              assert numbers == list(range(1, len(numbers) + 1)), numbers
      
      
      def test_scan_sits_in_step_5_before_the_offer_text() -> None:
          text = ASSESS_PR_SKILL.read_text(encoding="utf-8")
          step5 = text.split("## Step 5", 1)[1].split("## Step 6", 1)[0]
          assert START in step5 and END in step5
          assert step5.index(END) < step5.index("Interpret the result")
      
      
      def test_step_5_replaces_upstream_offer_when_flag_is_set() -> None:
          text = ASSESS_PR_SKILL.read_text(encoding="utf-8")
          step5 = text.split("## Step 5", 1)[1].split("## Step 6", 1)[0]
          lowered = step5.lower()
          assert step5.count("NO_CONTRIBUTIONS") >= 3
          for phrase in ("inside your fork", "fork's default branch", "share the link"):
              assert phrase in lowered, phrase
          assert re.search(r"disabl[a-z]* actions", lowered)
          # The ordinary fork-to-upstream flow stays for READ/TRIAGE with no statement.
          assert "gh pr create --repo <owner>/<repo>" in step5
          phase2 = text.split("## Phase 2", 1)[1].split("## Step 5", 1)[0]
          assert "no-contributions" in phase2.lower()
      
      
      @pytest.mark.skipif(shutil.which("zsh") is None, reason="zsh not installed")
      @pytest.mark.parametrize("body, expected", [(STATEMENT, "1"), ("PRs welcome!", "0")])
      def test_scan_runs_under_zsh(tmp_path: Path, body: str, expected: str) -> None:
          # Agents often run the block in the user's zsh, where an unbraced
          # "$var[" is a subscript, not a variable followed by a bracket class.
          (tmp_path / "README.md").write_text(f"# App\n{body}\n", encoding="utf-8")
          script = f'REPO_ROOT="{tmp_path}"\n{_scan_block()}printf %s "$NO_CONTRIBUTIONS"\n'
          result = subprocess.run(["zsh", "-c", script], capture_output=True, text=True, check=True)
          assert result.stderr == ""
          assert result.stdout == expected
      
    • test_ownership_parser.py 14.5 KB
      """Contract suite for the ownership-map parser.
      
      The parser turns the two declaration formats - GitHub ``CODEOWNERS`` (glob ->
      owner) and a boundary-declaring markdown doc (``ARCHITECTURE.md`` / a seam-map
      ``README.md``) - into ``{declared_boundary: {matched_file_paths}}`` maps, and
      flags the globs that already match zero files. These tests pin that behaviour
      plus the two contracts the structure-drift signals depend on: deterministic,
      byte-identical output run to run, and honest degradation (missing / malformed
      input never crashes the assessment).
      
      Fixtures build small repos in ``tmp_path``. CODEOWNERS resolution is against the
      *tracked* file set, so a fixture that asserts on matched files commits them; a
      fixture that only exercises parsing of patterns need not. Ambient git config is
      neutralised process-wide by the package ``conftest.py``.
      """
      from __future__ import annotations
      
      import os
      import subprocess
      from pathlib import Path
      
      from lib.ownership_parser import (
          find_empty_globs,
          is_glob,
          parse_architecture_md,
          parse_codeowners,
          parse_ownership,
      )
      
      
      def _git(repo: Path, *args: str) -> None:
          subprocess.run(["git", "-C", str(repo), *args],
                         check=True, capture_output=True, text=True,
                         env={**os.environ})
      
      
      def _init_repo(tmp_path: Path) -> Path:
          repo = tmp_path / "repo"
          repo.mkdir()
          _git(repo, "init", "-q")
          _git(repo, "config", "user.email", "dev@example.com")
          _git(repo, "config", "user.name", "Dev")
          return repo
      
      
      def _write(repo: Path, rel: str, text: str = "x\n") -> None:
          p = repo / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      def _commit_all(repo: Path, message: str = "c") -> None:
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", message)
      
      
      def _matched_strs(files: set[Path]) -> set[str]:
          return {p.as_posix() for p in files}
      
      
      # --- 1. CODEOWNERS parsing ---------------------------------------------------
      
      def test_codeowners_multi_owner_comments_and_globs(tmp_path: Path) -> None:
          """A CODEOWNERS with multi-owner lines, comments, and globs parses cleanly.
      
          The parser keeps the glob pattern (the declared boundary), drops comment and
          blank lines, and ignores the owner tokens. Patterns are resolved against the
          tracked file set: ``*.js`` claims the two committed JS files, ``docs/`` claims
          everything under ``docs``, and ``src/**/*.py`` claims the nested python file.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "a.js")
          _write(repo, "b.js")
          _write(repo, "src/pkg/mod.py")
          _write(repo, "docs/guide.md")
          _write(repo, "docs/api/ref.md")
          _write(repo, "README.md")
          _write(repo, "CODEOWNERS", "\n".join([
              "# top comment",
              "",
              "*.js   @frontend @web-team",
              "docs/  @docs-team",
              "src/**/*.py  @backend",
              "  # indented comment",
          ]) + "\n")
          _commit_all(repo)
      
          owners = parse_codeowners(repo)
      
          assert set(owners) == {"*.js", "docs/", "src/**/*.py"}
          assert _matched_strs(owners["*.js"]) == {"a.js", "b.js"}
          assert _matched_strs(owners["docs/"]) == {"docs/guide.md", "docs/api/ref.md"}
          assert _matched_strs(owners["src/**/*.py"]) == {"src/pkg/mod.py"}
      
      
      def test_codeowners_anchored_and_bare_pattern_depth(tmp_path: Path) -> None:
          """A leading ``/`` anchors to root; a bare name matches at any depth.
      
          ``/config.yml`` matches only the root file, not the nested one. A bare
          ``Makefile`` (no slash, unanchored) matches at any depth - GitHub semantics.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "config.yml")
          _write(repo, "sub/config.yml")
          _write(repo, "Makefile")
          _write(repo, "tools/Makefile")
          _write(repo, "CODEOWNERS", "/config.yml @a\nMakefile @b\n")
          _commit_all(repo)
      
          owners = parse_codeowners(repo)
          assert _matched_strs(owners["/config.yml"]) == {"config.yml"}
          assert _matched_strs(owners["Makefile"]) == {"Makefile", "tools/Makefile"}
      
      
      def test_codeowners_duplicate_pattern_is_unioned(tmp_path: Path) -> None:
          """The same pattern on two lines unions its match set rather than overwriting."""
          repo = _init_repo(tmp_path)
          _write(repo, "x.py")
          _write(repo, "CODEOWNERS", "*.py @a\n*.py @b\n")
          _commit_all(repo)
      
          owners = parse_codeowners(repo)
          assert set(owners) == {"*.py"}
          assert _matched_strs(owners["*.py"]) == {"x.py"}
      
      
      def test_codeowners_respects_gitignore(tmp_path: Path) -> None:
          """An ignored file never counts toward a glob's match set.
      
          ``tracked_files`` is the file universe, so a ``*.py`` glob over a repo with a
          gitignored ``secret.py`` claims only the tracked python file.
          """
          repo = _init_repo(tmp_path)
          _write(repo, ".gitignore", "secret.py\n")
          _write(repo, "kept.py")
          _write(repo, "secret.py")  # ignored, never tracked
          _write(repo, "CODEOWNERS", "*.py @a\n")
          _commit_all(repo)
      
          owners = parse_codeowners(repo)
          assert _matched_strs(owners["*.py"]) == {"kept.py"}
      
      
      def test_codeowners_in_github_dir_is_found(tmp_path: Path) -> None:
          """``.github/CODEOWNERS`` is honoured - GitHub's standard location."""
          repo = _init_repo(tmp_path)
          _write(repo, "a.py")
          _write(repo, ".github/CODEOWNERS", "*.py @a\n")
          _commit_all(repo)
      
          owners = parse_codeowners(repo)
          assert _matched_strs(owners["*.py"]) == {"a.py"}
      
      
      # --- 2. ARCHITECTURE.md parsing ----------------------------------------------
      
      def test_architecture_md_module_extraction(tmp_path: Path) -> None:
          """Section headers name modules; their path references resolve to files.
      
          Two ``##`` sections each declare a module owning a directory and naming files
          in inline code. The parser attributes each section's references to that
          section's header (keyed ``<doc>::<header>``) and resolves directory and bare
          references to the tracked files.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/api/server.py")
          _write(repo, "src/api/routes.py")
          _write(repo, "src/db/models.py")
          _write(repo, "ARCHITECTURE.md", "\n".join([
              "# System",
              "",
              "## API layer",
              "The API module owns `src/api/` and exposes the HTTP surface.",
              "",
              "## Data layer",
              "The data module owns `src/db/models.py`.",
          ]) + "\n")
          _commit_all(repo)
      
          modules = parse_architecture_md(repo)
          api = modules["ARCHITECTURE.md::API layer"]
          data = modules["ARCHITECTURE.md::Data layer"]
          assert _matched_strs(api) == {"src/api/server.py", "src/api/routes.py"}
          assert _matched_strs(data) == {"src/db/models.py"}
      
      
      def test_architecture_md_ignores_fenced_code_paths(tmp_path: Path) -> None:
          """Paths inside a fenced code block are samples, not boundary declarations.
      
          A fenced listing of a path the module does *not* own must not be attributed
          to it - only the inline-code reference in prose counts.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/real.py")
          _write(repo, "examples/sample.py")
          _write(repo, "DESIGN.md", "\n".join([
              "## Core",
              "The core module owns `src/real.py`.",
              "",
              "```",
              "examples/sample.py  # illustrative only",
              "```",
          ]) + "\n")
          _commit_all(repo)
      
          modules = parse_architecture_md(repo)
          core = modules["DESIGN.md::Core"]
          assert _matched_strs(core) == {"src/real.py"}
          assert all("sample.py" not in str(p) for files in modules.values() for p in files)
      
      
      def test_architecture_readme_only_when_it_declares_boundaries(tmp_path: Path) -> None:
          """A generic README is skipped; a seam-declaring README is parsed.
      
          A README without ownership/seam vocabulary contributes no module. A README
          whose prose declares module ownership does, so a repo carrying its boundary
          map in a README (rather than ARCHITECTURE.md) is still read.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "lib/core.py")
          _write(repo, "plain/README.md", "Just a project. Run `make`.\n")
          _write(repo, "lib/README.md", "\n".join([
              "## Module reference",
              "The core module owns `lib/core.py` - this seam is owned by design.",
          ]) + "\n")
          _commit_all(repo)
      
          modules = parse_architecture_md(repo)
          keys = set(modules)
          assert any(k.startswith("lib/README.md::") for k in keys)
          assert not any(k.startswith("plain/README.md::") for k in keys)
      
      
      def test_architecture_wikilink_references_resolve(tmp_path: Path) -> None:
          """A ``[[wikilink]]`` path reference resolves to its tracked file."""
          repo = _init_repo(tmp_path)
          _write(repo, "docs/payments.md")
          _write(repo, "ARCHITECTURE.md", "\n".join([
              "## Payments",
              "The payments module owns [[docs/payments.md]].",
          ]) + "\n")
          _commit_all(repo)
      
          modules = parse_architecture_md(repo)
          assert _matched_strs(modules["ARCHITECTURE.md::Payments"]) == {"docs/payments.md"}
      
      
      # --- 3. Empty-glob detection -------------------------------------------------
      
      def test_find_empty_globs_flags_zero_match_patterns(tmp_path: Path) -> None:
          """A glob matching no tracked file is reported; a matching one is not.
      
          ``*.py`` matches the committed file; ``legacy/**`` matches nothing (the
          directory is gone). Only the latter surfaces, with its declared source.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "a.py")
          _write(repo, "CODEOWNERS", "*.py @a\nlegacy/** @b\n")
          _commit_all(repo)
      
          owners = parse_codeowners(repo)
          empties = find_empty_globs(owners)
          assert empties == [{"pattern": "legacy/**", "declared_in": "CODEOWNERS"}]
      
      
      def test_find_empty_globs_is_sorted(tmp_path: Path) -> None:
          """Empty globs are returned sorted by pattern for deterministic output."""
          owners = {
              "z/**": set(),
              "a/**": set(),
              "m.py": {Path("m.py")},  # matches - excluded
              "k/**": set(),
          }
          empties = find_empty_globs(owners)
          assert [e["pattern"] for e in empties] == ["a/**", "k/**", "z/**"]
      
      
      def test_is_glob_classification() -> None:
          """Wildcard and directory patterns are globs; a literal file path is not."""
          assert is_glob("*.py")
          assert is_glob("src/**/*.ts")
          assert is_glob("docs/")  # trailing slash = directory
          assert is_glob("a[bc].py")
          assert not is_glob("README.md")
          assert not is_glob("src/main.py")
      
      
      # --- 4. Graceful degradation -------------------------------------------------
      
      def test_no_ownership_map_degrades(tmp_path: Path) -> None:
          """A repo with no CODEOWNERS and no boundary doc reports no ownership map."""
          repo = _init_repo(tmp_path)
          _write(repo, "a.py")
          _commit_all(repo)
      
          assert parse_codeowners(repo) == {}
          assert parse_architecture_md(repo) == {}
      
          summary = parse_ownership(repo)
          assert summary["available"] is False
          assert summary["reason"] == "no ownership map"
          assert summary["codeowners_globs"] == []
          assert summary["architecture_modules"] == []
          assert summary["empty_globs"] == []
      
      
      def test_malformed_codeowners_line_is_skipped(tmp_path: Path) -> None:
          """A blank-ish / comment-only file parses to an empty map, never raises.
      
          Comment and blank lines carry no pattern; a file of only those yields an
          empty (but available-elsewhere) parse rather than crashing.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "a.py")
          _write(repo, "CODEOWNERS", "# only comments\n\n   \n")
          _commit_all(repo)
      
          assert parse_codeowners(repo) == {}
      
      
      def test_non_git_directory_resolves_via_filesystem(tmp_path: Path) -> None:
          """Outside git, the glob resolver falls back to a filesystem walk.
      
          No git history means ``tracked_files`` is None; the parser walks the tree
          (with the same excludes + symlink guard) so a CODEOWNERS still resolves.
          """
          plain = tmp_path / "plain"
          plain.mkdir()
          (plain / "a.py").write_text("x\n", encoding="utf-8")
          (plain / "CODEOWNERS").write_text("*.py @a\n", encoding="utf-8")
      
          owners = parse_codeowners(plain)
          assert _matched_strs(owners["*.py"]) == {"a.py"}
      
      
      # --- 5. Determinism ----------------------------------------------------------
      
      def test_parse_ownership_is_byte_identical_across_runs(tmp_path: Path) -> None:
          """Two parses of one repo serialize to identical output.
      
          Sets are sorted at the boundary, so no iteration order leaks. Serialising the
          full summary twice and asserting equality pins the determinism contract the
          structure-drift signals rely on.
          """
          import json
      
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "src/b.py")
          _write(repo, "docs/x.md")
          _write(repo, "CODEOWNERS", "src/** @a\ndocs/ @b\nghost/** @c\n")
          _write(repo, "ARCHITECTURE.md", "## Core\nowns `src/`\n")
          _commit_all(repo)
      
          first = json.dumps(parse_ownership(repo), sort_keys=True)
          second = json.dumps(parse_ownership(repo), sort_keys=True)
          assert first == second
      
          summary = parse_ownership(repo)
          assert summary["available"] is True
          assert {e["pattern"] for e in summary["empty_globs"]} == {"ghost/**"}
      
      
      # --- 6. Integration: this repo's own seam map --------------------------------
      
      def test_integration_parses_lib_readme_seam_declaration() -> None:
          """Parsing this repo finds the lib README's co-change seam declaration.
      
          ``skills/assess/scripts/lib/README.md`` declares the module-ownership seam
          map (the ``assess_core.py -> lib`` seam and the wider co-change seams). Its
          boundary-declaring prose admits it as an architecture doc, and its seam
          section names ``doc_graph.py`` as a load-bearing module, which resolves to
          the real tracked file. This is the dogfood guard that the parser reads a
          genuine, human-written ownership declaration on real data.
          """
          repo_root = Path(__file__).resolve().parents[3]  # repo top
          modules = parse_architecture_md(repo_root)
      
          lib_readme = "skills/assess/scripts/lib/README.md"
          lib_sections = {k: v for k, v in modules.items() if k.startswith(lib_readme + "::")}
          assert lib_sections, "lib README should be parsed as a boundary declaration"
      
          # The seam map names doc_graph.py; it resolves to the real module file.
          resolved = {p.as_posix() for files in lib_sections.values() for p in files}
          assert "skills/assess/scripts/lib/doc_graph.py" in resolved
      
      
      def test_extract_path_refs_suffixes_default_and_widened() -> None:
          """Slash-free spans count only with a listed suffix: the default keeps
          `.md` / `.py` (so prose naming `.mdx` is no stale-reference finding), and
          the doc graph widens it to every doc extension."""
          from lib.ownership_parser import _extract_path_refs
          seg = "we render `.mdx` pages; see `guide.mdx` and `notes.md`"
          assert _extract_path_refs(seg) == {"notes.md"}
          assert _extract_path_refs(seg, (".md", ".mdx")) == {".mdx", "guide.mdx", "notes.md"}
      
    • test_promissory_markers.py 14.3 KB
      """Tests for the promissory-marker scan (stale TODOs, suppressions, skips).
      
      Fixtures build synthetic git histories in tmp dirs with explicit author dates
      so survived-touches counts are deterministic. Expected values are hand-computed
      in each test's comments so the contract is auditable.
      """
      from __future__ import annotations
      
      import os
      import subprocess
      from pathlib import Path
      
      from lib.keyhole_signals import FINDING_ORDER, integrate
      from lib.promissory_markers import (
          FAMILY_PATTERNS,
          MarkerScan,
          scan_promissory_markers,
      )
      
      _HUMAN = ("Dev One", "dev@example.com")
      _AGENT = ("Claude", "claude[bot]@users.noreply.github.com")
      
      
      def _git(repo: Path, *args: str, env: dict | None = None) -> None:
          full_env = {**os.environ, **(env or {})}
          subprocess.run(["git", "-C", str(repo), *args],
                         check=True, capture_output=True, text=True, env=full_env)
      
      
      def _init_repo(repo: Path) -> None:
          repo.mkdir(parents=True, exist_ok=True)
          _git(repo, "init", "-q")
          _git(repo, "config", "user.name", _HUMAN[0])
          _git(repo, "config", "user.email", _HUMAN[1])
      
      
      def _commit(
          repo: Path,
          files: dict[str, str],
          message: str = "change",
          *,
          day: int = 1,
          author: tuple[str, str] | None = None,
      ) -> None:
          """Commit files with a deterministic author date (2024-01-<day>)."""
          for rel, text in files.items():
              p = repo / rel
              p.parent.mkdir(parents=True, exist_ok=True)
              p.write_text(text, encoding="utf-8")
          _git(repo, "add", "-A")
          name, email = author or _HUMAN
          date = f"2024-01-{day:02d}T12:00:00"
          _git(
              repo, "commit", "-q", "-m", message,
              env={
                  "GIT_AUTHOR_NAME": name, "GIT_AUTHOR_EMAIL": email,
                  "GIT_COMMITTER_NAME": name, "GIT_COMMITTER_EMAIL": email,
                  "GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date,
              },
          )
      
      
      def _scan(repo: Path, **kw) -> MarkerScan:
          scan = scan_promissory_markers(repo, **kw)
          assert scan.available, scan.reason
          return scan
      
      
      # ---------------------------------------------------------------------------
      # Detection + classification
      # ---------------------------------------------------------------------------
      
      def test_detects_all_four_families(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          _init_repo(repo)
          _commit(repo, {
              "app.py": "# TODO fix this\nx = 1  # noqa: E501\n",
              "legacy.java": "// @Deprecated use NewThing\nclass A {}\n",
              "app_test.py": "import pytest\n@pytest.mark.skip\ndef test_x():\n    pass\n",
          }, day=1)
          scan = _scan(repo)
          families = {m.family for m in scan.markers}
          assert families == {"todo", "suppression", "deprecation", "disabled_test"}
      
      
      def test_string_literal_todo_dropped_but_comment_kept(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          _init_repo(repo)
          _commit(repo, {
              "app.py": 'msg = "TODO is a word"\n# TODO real one\n',
          }, day=1)
          scan = _scan(repo)
          todos = [m for m in scan.markers if m.family == "todo"]
          assert len(todos) == 1
          assert todos[0].line == 2
      
      
      def test_case_sensitive_word_boundary() -> None:
          """A Dart toDouble() call must not match the todo family (regression:
          case-insensitive matching flagged every toDouble in a Flutter repo)."""
          import re
          assert not re.search(FAMILY_PATTERNS["todo"], "x = (y as num).toDouble()")
      
      
      def test_linked_vs_bare_todo(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          _init_repo(repo)
          _commit(repo, {
              "app.py": "# TODO(#123) tracked\n# TODO untracked\n# TODO JIRA-42 also tracked\n",
          }, day=1)
          scan = _scan(repo)
          s = scan.summary()
          assert s["todo_linked"] == 2
          assert s["todo_bare"] == 1
      
      
      def test_justified_suppression_counts_as_linked(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          _init_repo(repo)
          _commit(repo, {
              "a.go": "return nil //nolint:nilerr // error conveyed via response\n",
              "b.go": "return nil //nolint:nilerr\n",
          }, day=1)
          scan = _scan(repo)
          by_path = {m.path: m for m in scan.markers if m.family == "suppression"}
          assert by_path["a.go"].linked is True
          assert by_path["b.go"].linked is False
      
      
      # One justified form per recognised syntax (issue #335), a bare suppression,
      # and a bare TODO. Each file's marker line stays put while a second line
      # changes on every commit, so every marker survives the later edits.
      _JUSTIFIED_FORMS = {
          "src/sim.js": (
              "const pick = () => words[0]; // eslint-disable-line "
              "sonarjs/pseudo-random -- simulator only, not security-sensitive"
          ),
          "src/cli.js": "/* eslint-disable no-console -- CLI entry point prints by design */",
          "src/url.py": "URL = 1  # noqa: E501  # long URL kept on one line",
          "src/h.go": "x := run() //nolint:errcheck // error conveyed via response status",
          "src/G.java": '@SuppressWarnings("unchecked") // generic array creation is safe here',
      }
      _BARE_FORMS = {
          "src/bare.js": "console.log(1); // eslint-disable-line no-console",
          "src/todo.py": "# TODO tidy this up",
      }
      
      
      def _aged_marker_repo(repo: Path, lines: dict[str, str], *, edits: int) -> None:
          _init_repo(repo)
          for i in range(edits + 1):
              _commit(repo, {p: f"{t}\nv = {i}\n" for p, t in lines.items()}, day=1 + i)
      
      
      def test_justified_not_stale_counts_each_recognised_form(tmp_path: Path) -> None:
          """Five justified suppressions survive 6 edits: all five are counted in
          ``families.suppression.justified`` and none is stale. The bare suppression
          and the bare TODO of the same age stay stale."""
          repo = tmp_path / "repo"
          _aged_marker_repo(repo, {**_JUSTIFIED_FORMS, **_BARE_FORMS}, edits=6)
          summary = _scan(repo).summary()
          suppression = summary["families"]["suppression"]
          assert suppression["total"] == 6
          assert suppression["justified"] == 5
          assert suppression["stale"] == 1
          assert set(summary["stale_by_file"]) == {"src/bare.js", "src/todo.py"}
          assert all(
              m["path"] not in _JUSTIFIED_FORMS for m in summary["top_offenders"]
          )
      
      
      def test_justified_not_stale_keeps_file_out_of_unactioned_intent(
          tmp_path: Path,
      ) -> None:
          repo = tmp_path / "repo"
          _aged_marker_repo(repo, {**_JUSTIFIED_FORMS, **_BARE_FORMS}, edits=6)
          result = integrate(
              repo_root=repo, complexity_stats={}, doc_staleness={},
              dead_code={}, observability={}, structure={},
              promissory_markers=_scan(repo).summary(),
          )
          findings = {f["name"]: f for f in result["derived_findings"]}
          assert findings["unactioned_intent"]["paths"] == ["src/bare.js", "src/todo.py"]
          assert not {a["path"] for a in result["attention"]} & set(_JUSTIFIED_FORMS)
      
      
      def test_linked_markers_of_other_families_still_go_stale(tmp_path: Path) -> None:
          """Only a justified suppression is exempt. A ticketed TODO, an expired
          dated deprecation and a ticketed skip still age: the promise can go stale
          while the reference stays, and the Layer 5 cap reads disabled_test.stale."""
          repo = tmp_path / "repo"
          _aged_marker_repo(repo, {
              "a.py": "# TODO(#123) tracked",
              "d.py": "# DEPRECATED: use v2, deadline 2019-06-01",
              "s.test.js": "it.skip('see JIRA-42', () => {});",
          }, edits=6)
          summary = _scan(repo).summary()
          fams = summary["families"]
          assert set(summary["stale_by_file"]) == {"a.py", "d.py", "s.test.js"}
          assert fams["todo"]["linked"] == fams["todo"]["stale"] >= 1
          assert fams["deprecation"]["linked"] == fams["deprecation"]["stale"] == 1
          assert fams["disabled_test"]["linked"] == fams["disabled_test"]["stale"] == 1
          assert all(row["justified"] == 0 for row in fams.values())
      
      
      def test_bare_suppression_rule_names_with_hyphens_are_not_justified() -> None:
          """A hyphenated rule name is not a ``-- reason`` separator."""
          from lib.promissory_markers import JUSTIFIED_SUPPRESSION_RE
          assert not JUSTIFIED_SUPPRESSION_RE.search(
              "x; // eslint-disable-line @typescript-eslint/no-explicit-any"
          )
          assert not JUSTIFIED_SUPPRESSION_RE.search("/* eslint-disable no-console */")
          # Code after the block comment closes is not the directive's reason.
          assert not JUSTIFIED_SUPPRESSION_RE.search(
              '/* eslint-disable no-console */ const s = "a -- b";'
          )
          assert JUSTIFIED_SUPPRESSION_RE.search(
              "/* eslint-disable no-console */ // CLI prints by design"
          )
          assert JUSTIFIED_SUPPRESSION_RE.search(
              "// eslint-disable-next-line no-alert -- confirm is the UX here"
          )
      
      
      def test_generated_and_prose_exclusions(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          _init_repo(repo)
          _commit(repo, {
              # codegen boilerplate: never debt
              "model.g.dart": "// ignore_for_file: type=lint\n",
              # syntactic marker in prose: a code example, not debt
              "docs/guide.md": "Use t.Skip(\"reason\") to skip.\n",
              # prose TODO: real (docs carry intent too)
              "docs/plan.md": "TODO write the rollout section\n",
          }, day=1)
          scan = _scan(repo)
          paths_by_family = {
              fam: {m.path for m in scan.markers if m.family == fam}
              for fam in ("suppression", "disabled_test", "todo")
          }
          assert paths_by_family["suppression"] == set()
          assert paths_by_family["disabled_test"] == set()
          assert paths_by_family["todo"] == {"docs/plan.md"}
      
      
      # ---------------------------------------------------------------------------
      # Aging: survived touches
      # ---------------------------------------------------------------------------
      
      def _busy_repo_with_marker(repo: Path, *, edits_after: int) -> None:
          """Marker lands on day 2; `edits_after` later commits touch the same file.
      
          Three other files get two commits each so churn_is_degenerate() stays
          False (aging_reliable True) without inflating the marker file's count.
          """
          _init_repo(repo)
          _commit(repo, {"app.py": "x = 0\n", "a.py": "a", "b.py": "b", "c.py": "c"}, day=1)
          _commit(repo, {"app.py": "x = 0\n# FIXME handle zero\ny = 1\n"}, day=2)
          for i in range(edits_after):
              _commit(repo, {"app.py": f"x = {i + 1}\n# FIXME handle zero\ny = {i}\n"},
                      day=3 + i)
          _commit(repo, {"a.py": "a2", "b.py": "b2", "c.py": "c2"}, day=20)
      
      
      def test_survived_touches_counts_later_commits(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          _busy_repo_with_marker(repo, edits_after=6)
          scan = _scan(repo)
          fixme = [m for m in scan.markers if m.family == "todo"][0]
          # 6 edits after the introducing commit; the introducing commit itself and
          # the day-1 commit don't count.
          assert fixme.survived_touches == 6
          assert fixme.path in scan.stale_by_file()
          assert scan.stale_by_file()["app.py"]["max_survived"] == 6
      
      
      def test_fresh_marker_is_not_stale(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          _busy_repo_with_marker(repo, edits_after=1)
          scan = _scan(repo)
          assert scan.stale_by_file() == {}
          assert scan.summary()["total_stale"] == 0
      
      
      def test_degenerate_history_marks_aging_unreliable(tmp_path: Path) -> None:
          """Squashed-import shape (every file exactly one commit, many files): the
          scan still reports markers but flags aging as carrying no information."""
          repo = tmp_path / "repo"
          _init_repo(repo)
          files = {f"f{i}.py": f"# TODO item {i}\n" for i in range(12)}
          _commit(repo, files, day=1)
          scan = _scan(repo)
          assert scan.markers  # detection still works
          assert scan.aging_reliable is False
      
      
      # ---------------------------------------------------------------------------
      # Authorship
      # ---------------------------------------------------------------------------
      
      def test_agent_vs_human_introduction(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          _init_repo(repo)
          _commit(repo, {"by_human.py": "# TODO human wrote this\n"}, day=1)
          _commit(repo, {"by_agent.py": "# TODO agent wrote this\n"}, day=2,
                  author=_AGENT)
          scan = _scan(repo)
          by_path = {m.path: m for m in scan.markers}
          assert by_path["by_human.py"].agent_introduced is False
          assert by_path["by_agent.py"].agent_introduced is True
      
      
      # ---------------------------------------------------------------------------
      # Degrade contract + keyhole finding
      # ---------------------------------------------------------------------------
      
      def test_non_git_dir_degrades(tmp_path: Path) -> None:
          scan = scan_promissory_markers(tmp_path)
          assert scan.available is False
          assert "git" in scan.reason
      
      
      def test_unactioned_intent_finding_in_integrate(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          _busy_repo_with_marker(repo, edits_after=6)
          summary = _scan(repo).summary()
          result = integrate(
              repo_root=repo, complexity_stats={}, doc_staleness={},
              dead_code={}, observability={}, structure={},
              promissory_markers=summary,
          )
          findings = {f["name"]: f for f in result["derived_findings"]}
          assert "unactioned_intent" in findings
          assert findings["unactioned_intent"]["paths"] == ["app.py"]
      
      
      def test_unactioned_intent_action_states_stale_threshold(tmp_path: Path) -> None:
          """The finding says how many edits a marker must survive to count as stale,
          so a reader can weigh a 6-edit suppression against a 65-edit TODO."""
          repo = tmp_path / "repo"
          _busy_repo_with_marker(repo, edits_after=6)
          summary = scan_promissory_markers(repo, stale_touches=4).summary()
          result = integrate(
              repo_root=repo, complexity_stats={}, doc_staleness={},
              dead_code={}, observability={}, structure={},
              promissory_markers=summary,
          )
          findings = {f["name"]: f for f in result["derived_findings"]}
          assert "survived 4 or more edits" in findings["unactioned_intent"]["action"]
          md = result["findings_markdown"]
          section = md.split("### unactioned_intent", 1)[1].split("### ", 1)[0]
          assert "survived 4 or more edits" in section
      
      
      def test_unactioned_intent_silent_without_reliable_aging(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          _init_repo(repo)
          files = {f"f{i}.py": f"# TODO item {i}\n" for i in range(12)}
          _commit(repo, files, day=1)
          summary = _scan(repo).summary()
          assert summary["aging_reliable"] is False
          result = integrate(
              repo_root=repo, complexity_stats={}, doc_staleness={},
              dead_code={}, observability={}, structure={},
              promissory_markers=summary,
          )
          findings = {f["name"]: f for f in result["derived_findings"]}
          assert findings["unactioned_intent"]["paths"] == []
      
      
      def test_finding_order_contains_unactioned_intent() -> None:
          assert "unactioned_intent" in FINDING_ORDER
      
    • test_raw_source.py 15.1 KB
      """Tests for raw-source subtree detection (issue #225).
      
      The pure classifier in ``lib.raw_source`` decides, from three per-doc graph
      signals, whether a directory subtree is a dump of raw, machine-extracted source
      documents that should be excluded from the headline read-side metrics. These
      tests pin the threshold contract so the detector behaves identically regardless
      of which LLM is driving the surrounding assessment.
      """
      from __future__ import annotations
      
      import pytest
      
      from lib.raw_source import (
          RAW_TREE_ISOLATION_DENSITY,
          RAW_TREE_MACHINE_DENSITY,
          RAW_TREE_MIN_FILES,
          WORKING_NOTES_INDEX_SHARE,
          WORKING_NOTES_LOW_INDEGREE_DENSITY,
          WORKING_NOTES_MIN_FILES,
          WORKING_NOTES_NAME_DENSITY,
          _name_key,
          classify_raw_trees,
          classify_working_notes_trees,
      )
      
      
      def _signal(in_degree: int = 0, out_degree: int = 0, machine_links: int = 0) -> dict:
          return {
              "in_degree": in_degree,
              "out_degree": out_degree,
              "machine_links": machine_links,
          }
      
      
      def _raw_tree(prefix: str, n: int, machine_share: float = 1.0) -> dict[str, dict]:
          """A subtree of ``n`` link-isolated docs; ``machine_share`` of them carry a
          machine-extracted (non-navigational) link."""
          signals: dict[str, dict] = {}
          machine_count = round(n * machine_share)
          for i in range(n):
              signals[f"{prefix}/doc-{i:03d}.md"] = _signal(
                  machine_links=1 if i < machine_count else 0,
              )
          return signals
      
      
      def test_no_docs_returns_empty() -> None:
          assert classify_raw_trees({}) == []
      
      
      def test_large_isolated_machine_tree_detected() -> None:
          signals = _raw_tree("sar-export", RAW_TREE_MIN_FILES + 2)
          trees = classify_raw_trees(signals)
          assert len(trees) == 1
          assert trees[0]["path"] == "sar-export"
          assert trees[0]["file_count"] == RAW_TREE_MIN_FILES + 2
          assert len(trees[0]["docs"]) == RAW_TREE_MIN_FILES + 2
      
      
      def test_below_min_files_not_detected() -> None:
          signals = _raw_tree("sar-export", RAW_TREE_MIN_FILES - 1)
          assert classify_raw_trees(signals) == []
      
      
      def test_isolated_but_no_machine_links_not_detected() -> None:
          # A folder of genuinely standalone-but-curated notes: link-isolated, but
          # none carry the machine-extraction fingerprint. Must NOT be excluded.
          signals = _raw_tree("notes", RAW_TREE_MIN_FILES + 5, machine_share=0.0)
          assert classify_raw_trees(signals) == []
      
      
      def test_well_linked_machine_tree_not_detected() -> None:
          # Files carry machine links but are also internally navigable (in/out edges).
          signals: dict[str, dict] = {}
          for i in range(RAW_TREE_MIN_FILES + 4):
              signals[f"corpus/doc-{i:03d}.md"] = _signal(
                  in_degree=2, out_degree=2, machine_links=1,
              )
          assert classify_raw_trees(signals) == []
      
      
      def test_entry_point_excluded_from_isolation_numerator() -> None:
          # An entry doc in the subtree is not counted as isolated; with enough
          # isolated machine docs around it the tree still qualifies.
          signals = _raw_tree("dump", RAW_TREE_MIN_FILES + 4)
          entry = "dump/index.md"
          signals[entry] = _signal(out_degree=5, machine_links=0)
          trees = classify_raw_trees(signals, entries={entry})
          assert len(trees) == 1
          assert trees[0]["path"] == "dump"
      
      
      def test_outermost_subtree_is_kept() -> None:
          # Two qualifying batch subtrees nested under a qualifying parent: only the
          # outermost ("export") is reported, not its children.
          signals: dict[str, dict] = {}
          signals.update(_raw_tree("export/batch-1", RAW_TREE_MIN_FILES + 1))
          signals.update(_raw_tree("export/batch-2", RAW_TREE_MIN_FILES + 1))
          trees = classify_raw_trees(signals)
          assert [t["path"] for t in trees] == ["export"]
          assert trees[0]["file_count"] == 2 * (RAW_TREE_MIN_FILES + 1)
      
      
      def test_two_independent_raw_trees_both_reported() -> None:
          signals: dict[str, dict] = {}
          signals.update(_raw_tree("sar-export", RAW_TREE_MIN_FILES + 1))
          signals.update(_raw_tree("disclosure-dump", RAW_TREE_MIN_FILES + 1))
          trees = classify_raw_trees(signals)
          assert sorted(t["path"] for t in trees) == ["disclosure-dump", "sar-export"]
      
      
      def test_root_level_docs_never_excluded() -> None:
          # Root-level isolated machine docs (no enclosing subtree) are never excluded
          # so a whole-repo false positive can't zero out the metrics.
          signals = {
              f"doc-{i:03d}.md": _signal(machine_links=1)
              for i in range(RAW_TREE_MIN_FILES + 5)
          }
          assert classify_raw_trees(signals) == []
      
      
      def test_thresholds_are_tunable() -> None:
          # A smaller tree is detected once the min-files threshold is lowered.
          signals = _raw_tree("small-dump", 4)
          assert classify_raw_trees(signals) == []
          trees = classify_raw_trees(signals, min_files=3)
          assert [t["path"] for t in trees] == ["small-dump"]
      
      
      def test_machine_density_threshold_boundary() -> None:
          # Exactly at the machine-density threshold qualifies; just below does not.
          n = 20
          at = _raw_tree("a", n, machine_share=RAW_TREE_MACHINE_DENSITY)
          assert [t["path"] for t in classify_raw_trees(at)] == ["a"]
          below = _raw_tree("b", n, machine_share=RAW_TREE_MACHINE_DENSITY - 0.1)
          assert classify_raw_trees(below) == []
      
      
      def test_isolation_density_threshold() -> None:
          # A subtree where too many docs are linked (below the isolation density)
          # does not qualify even with machine links everywhere.
          n = 20
          linked = round(n * (1 - RAW_TREE_ISOLATION_DENSITY) + 1)
          signals: dict[str, dict] = {}
          for i in range(n):
              is_linked = i < linked
              signals[f"mix/doc-{i:03d}.md"] = _signal(
                  in_degree=1 if is_linked else 0,
                  out_degree=1 if is_linked else 0,
                  machine_links=1,
              )
          assert classify_raw_trees(signals) == []
      
      
      # --- Working-notes trees (issue #366) ---------------------------------------
      
      
      def _wn_signal(sources: list[str]) -> dict:
          return {"in_degree": len(sources), "inbound_sources": sources}
      
      
      def _notes_tree(prefix: str, n: int, stem: str = "plan_{i:02d}") -> dict[str, dict]:
          """``n`` pattern-named notes each linked once from ``<prefix>/backlog.md``,
          and the backlog index linked once from the README."""
          signals = {
              f"{prefix}/{stem.format(i=i)}.md": _wn_signal([f"{prefix}/backlog.md"])
              for i in range(1, n + 1)
          }
          signals[f"{prefix}/backlog.md"] = _wn_signal(["README.md"])
          return signals
      
      
      def test_working_notes_tree_counts_the_index_too() -> None:
          signals = _notes_tree("notes", 50)
          signals["README.md"] = _wn_signal([])
          trees = classify_working_notes_trees(signals)
          assert [(t["path"], t["file_count"]) for t in trees] == [("notes", 51)]
          assert "notes/backlog.md" in trees[0]["docs"]
      
      
      def test_working_notes_date_and_ticket_names_match() -> None:
          dated = {
              f"journal/2026-01-{d:02d}-standup.md": _wn_signal(["journal/log.md"])
              for d in range(1, 29)
          }
          tickets = {
              f"tickets/PROJ-{i}.md": _wn_signal(["tickets/board.md"]) for i in range(100, 130)
          }
          trees = classify_working_notes_trees({**dated, **tickets})
          assert [t["path"] for t in trees] == ["journal", "tickets"]
      
      
      def test_varied_cross_linked_wiki_is_not_working_notes() -> None:
          names = [f"topic{chr(97 + i % 26)}{chr(97 + i // 26)}" for i in range(50)]
          words = ["auth", "billing", "cache", "deploy", "events"] * 10
          stems = [f"{w}{n}" for w, n in zip(words, names)]
          signals = {
              f"wiki/{s}.md": _wn_signal([f"wiki/{stems[(i + k) % 50]}.md" for k in (1, 7, 13)])
              for i, s in enumerate(stems)
          }
          assert classify_working_notes_trees(signals) == []
      
      
      def test_small_curated_wiki_does_not_match() -> None:
          names = "architecture billing caching deploy events glossary logging metrics onboarding"
          stems = names.split()
          signals = {
              f"docs/{s}.md": _wn_signal([f"docs/{stems[(i + 1) % 9]}.md", f"docs/{stems[(i + 4) % 9]}.md"])
              for i, s in enumerate(stems)
          }
          assert classify_working_notes_trees(signals) == []
      
      
      def test_working_notes_below_size_threshold_not_classified() -> None:
          signals = _notes_tree("notes", WORKING_NOTES_MIN_FILES - 2)
          assert classify_working_notes_trees(signals) == []
      
      
      def test_pattern_named_notes_without_an_index_hub_not_classified() -> None:
          # Same names, but every note is linked from a different source: no index
          # holds the inbound links, so the index leg fails.
          signals = {
              f"notes/plan_{i:02d}.md": _wn_signal([f"docs/page{i}.md"]) for i in range(40)
          }
          assert classify_working_notes_trees(signals) == []
      
      
      def test_heavily_linked_pattern_names_not_classified() -> None:
          # Pattern names under one index, but each page has several inbound links:
          # a curated series (release notes cross-linked), not working notes.
          signals = {
              f"notes/plan_{i:02d}.md": _wn_signal(
                  ["notes/index.md", f"notes/plan_{(i + 1) % 40:02d}.md", f"notes/plan_{(i + 2) % 40:02d}.md"]
              )
              for i in range(40)
          }
          assert classify_working_notes_trees(signals) == []
      
      
      def test_working_notes_nested_tree_keeps_curated_siblings() -> None:
          # A notes tree inside docs/ must not pull its curated siblings with it.
          signals = _notes_tree("docs/notes", 50)
          signals["docs/guide.md"] = _wn_signal(["README.md"])
          trees = classify_working_notes_trees(signals)
          assert [t["path"] for t in trees] == ["docs/notes"]
      
      
      def test_prefix_named_indexed_section_is_not_working_notes() -> None:
          # A curated how-to section: same-prefixed pages each linked once from the
          # section README. Passes in-degree and index; the name leg must fail it,
          # because a shared word is not a sequence.
          topics = [f"how-to-{w}" for w in (
              "deploy rotate-keys restore scale debug profile migrate upgrade rollback "
              "tag release audit onboard offboard backup patch seed index cache trace"
          ).split()]
          signals = {f"docs/how-to/{t}.md": _wn_signal(["docs/how-to/README.md"]) for t in topics}
          signals["docs/how-to/README.md"] = _wn_signal(["README.md"])
          assert classify_working_notes_trees(signals) == []
      
      
      def _indexed_section(prefix: str, stems: list[str]) -> dict[str, dict]:
          """Pages each linked once from ``<prefix>/README.md``, linked from the root."""
          signals = {f"{prefix}/{s}.md": _wn_signal([f"{prefix}/README.md"]) for s in stems}
          signals[f"{prefix}/README.md"] = _wn_signal(["README.md"])
          return signals
      
      
      def test_dotted_release_pages_are_not_working_notes() -> None:
          signals = _indexed_section("releases", [f"release-2.{i}.1" for i in range(30)])
          assert classify_working_notes_trees(signals) == []
      
      
      def test_v_prefixed_version_pages_are_not_working_notes() -> None:
          signals = _indexed_section("releases", [f"v1.{i}.0" for i in range(30)])
          assert classify_working_notes_trees(signals) == []
      
      
      def test_titled_decision_records_are_not_working_notes() -> None:
          adrs = _indexed_section("docs/adr", [f"adr-{i:04d}-decision-{i}x" for i in range(1, 25)])
          rfcs = _indexed_section("docs/rfc", [f"rfc-{i:03d}-proposal" for i in range(1, 25)])
          assert classify_working_notes_trees(adrs) == []
          assert classify_working_notes_trees(rfcs) == []
      
      
      @pytest.mark.parametrize(
          ("rel", "key"),
          [
              ("notes/plan_07.md", "plan"),
              ("notes/plan-07.md", "plan"),
              ("tickets/PROJ-123.md", "proj"),
              ("tickets/gh-42.md", "gh"),
              ("journal/2026-01-31-standup.md", "<date>"),
              ("releases/release-2.1.0.md", None),
              ("releases/v1.2.3.md", None),
              ("docs/adr/adr-0001-use-postgres.md", None),
              ("docs/rfc/rfc-042-streaming.md", None),
              ("docs/how-to/how-to-deploy.md", None),
              ("docs/adr/0001-use-postgres.md", None),
          ],
      )
      def test_name_key_families(rel: str, key: str | None) -> None:
          assert _name_key(rel) == key
      
      
      def test_mixed_hyphen_series_count_as_separate_families() -> None:
          # plan-/spike-/retro-/audit- are four families: over the three-prefix
          # ceiling, so no three of them reach the name density.
          stems = [f"{w}-{i:02d}" for w in ("plan", "spike", "retro", "audit") for i in range(1, 7)]
          signals = _indexed_section("notes", stems)
          assert classify_working_notes_trees(signals) == []
      
      
      def test_curated_sibling_citing_one_note_stays_in_headline() -> None:
          signals = _notes_tree("docs/notes", 50)
          signals["docs/notes/plan_01.md"] = _wn_signal(["docs/notes/backlog.md", "docs/guide.md"])
          signals["docs/guide.md"] = _wn_signal(["README.md"])
          trees = classify_working_notes_trees(signals)
          assert [t["path"] for t in trees] == ["docs/notes"]
      
      
      def test_notes_split_by_period_keep_their_index_in_the_tree() -> None:
          signals = {
              f"notes/{y}/plan_{i:02d}.md": _wn_signal(["notes/backlog.md"])
              for y in (2025, 2026) for i in range(25)
          }
          signals["notes/backlog.md"] = _wn_signal(["README.md"])
          trees = classify_working_notes_trees(signals)
          assert [(t["path"], t["file_count"]) for t in trees] == [("notes", 51)]
      
      
      def test_sparsely_linked_notes_pile_is_not_working_notes() -> None:
          # One stray link into an otherwise unlinked pile: the few edges that exist
          # are concentrated, but no index covers the tree, so it stays counted.
          signals = {f"notes/topic-{i}.md": _wn_signal([]) for i in range(1, 26)}
          signals["notes/topic-1.md"] = _wn_signal(["README.md"])
          assert classify_working_notes_trees(signals) == []
          signals["notes/topic-2.md"] = _wn_signal(["docs/guide.md"])
          assert classify_working_notes_trees(signals) == []
      
      
      def test_non_absorbing_subdirectory_shields_its_curated_page() -> None:
          # docs/team refuses to absorb docs/team/notes because of onboarding.md;
          # docs must not then treat onboarding.md as nested and absorb it anyway.
          signals = {
              f"docs/team/notes/plan_{i:02d}.md": _wn_signal(["docs/index.md"]) for i in range(1, 51)
          }
          signals["docs/index.md"] = _wn_signal(["README.md"])
          signals["docs/team/onboarding.md"] = _wn_signal(["README.md"])
          trees = classify_working_notes_trees(signals)
          assert [(t["path"], t["file_count"]) for t in trees] == [("docs/team/notes", 50)]
      
      
      def test_curated_subdirectory_below_the_floor_stays_counted() -> None:
          # docs/ holds 50 notes directly and docs/guides/ (9 pages and a README,
          # under the size floor, so it never qualifies). docs clears every leg over
          # all 61 docs, but the guides carry no name family and are no index into
          # the notes: only the notes and their index leave the headline.
          signals = {f"docs/plan_{i:02d}.md": _wn_signal(["docs/index.md"]) for i in range(1, 51)}
          signals["docs/index.md"] = _wn_signal(["README.md"])
          guides = "architecture billing caching deploy events glossary logging metrics onboarding".split()
          for g in guides:
              signals[f"docs/guides/{g}.md"] = _wn_signal(["docs/guides/README.md"])
          signals["docs/guides/README.md"] = _wn_signal(["docs/index.md"])
          trees = classify_working_notes_trees(signals)
          assert [(t["path"], t["file_count"]) for t in trees] == [("docs", 51)]
          assert not any(r.startswith("docs/guides/") for r in trees[0]["docs"])
      
      
      def test_working_notes_thresholds_are_precision_first() -> None:
          assert WORKING_NOTES_MIN_FILES >= 10
          assert 0.5 < WORKING_NOTES_NAME_DENSITY <= 1.0
          assert 0.5 < WORKING_NOTES_LOW_INDEGREE_DENSITY <= 1.0
          assert 0.5 < WORKING_NOTES_INDEX_SHARE <= 1.0
      
    • test_review_reality.py 9.9 KB
      """Tests for lib/review_reality.py.
      
      GitHub is faked with a `gh` shell script first on PATH that logs every call and
      answers from JSON files, so the tests run the real subprocess path through
      lib/gh_cli.py, including the 403 mapping.
      """
      from __future__ import annotations
      
      import json
      import os
      import stat
      import subprocess
      import sys
      from pathlib import Path
      
      import pytest
      
      sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
      
      from lib.review_reality import scan_review_reality  # noqa: E402
      
      FAKE_GH = """#!/bin/sh
      echo "$*" >> "$FAKE_GH/log"
      serve() { [ -f "$FAKE_GH/$1" ] && cat "$FAKE_GH/$1" && exit 0; }
      case "$1:$*" in
        auth:*) [ -f "$FAKE_GH/noauth" ] || exit 0 ;;
        pr:*) serve prs.json ;;
        api:*rules/branches*) serve branchrules.json ;;
        api:*/protection*) serve protection.json ;;
        api:*users/*) serve "user-$(echo "$2" | sed 's|.*/||')" ;;
        api:*repos/acme/widget) serve apirepo.json ;;
      esac
      cat "$FAKE_GH/fail" >&2
      exit 1
      """
      
      PERSON = {"login": "Alice", "is_bot": False}
      REVIEWER = {"login": "bob", "is_bot": False}
      
      
      def _git(repo: Path, *args: str) -> None:
          env = {**os.environ, "GIT_CONFIG_GLOBAL": "/dev/null"}
          subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, env=env)
      
      
      def _pr(n: int, *, merger=PERSON, reviews=(), comments=(), state="APPROVED",
              merged_at="2026-09-01T00:00:00Z") -> dict:
          return {
              "number": n, "title": f"SECRET-TITLE-{n}", "reviewDecision": "",
              "author": PERSON, "mergedBy": merger, "mergedAt": merged_at,
              "reviews": [{"author": a, "state": state} for a in reviews],
              "comments": [{"author": a, "body": "SECRET-BODY"} for a in comments],
          }
      
      
      @pytest.fixture
      def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
          repo = tmp_path / "repo"
          repo.mkdir()
          _git(repo, "init", "-q", "-b", "main")
          _git(repo, "remote", "add", "origin", "https://github.com/acme/widget.git")
          bindir, ghdir = tmp_path / "bin", tmp_path / "gh"
          bindir.mkdir()
          ghdir.mkdir()
          gh = bindir / "gh"
          gh.write_text(FAKE_GH)
          gh.chmod(gh.stat().st_mode | stat.S_IEXEC)
          (ghdir / "log").write_text("")
          (ghdir / "fail").write_text("gh: Not Found (HTTP 404)\n")
          monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ['PATH']}")
          monkeypatch.setenv("FAKE_GH", str(ghdir))
      
          class World:
              root = repo
      
              def serve(self, name: str, doc: object) -> None:
                  (ghdir / name).write_text(json.dumps(doc))
      
              def fail_with(self, line: str) -> None:
                  (ghdir / "fail").write_text(line + "\n")
      
              def calls(self) -> list[str]:
                  return (ghdir / "log").read_text().splitlines()
      
          w = World()
          w.serve("apirepo.json", {"full_name": "acme/widget", "default_branch": "main"})
          return w
      
      
      def _require_by_ruleset(world) -> None:
          world.serve("branchrules.json", [
              {"type": "pull_request", "parameters": {"required_approving_review_count": 1}},
          ])
      
      
      def test_review_reality_unreviewed_required_review_is_hollow(world) -> None:
          _require_by_ruleset(world)
          world.serve("prs.json", [_pr(n) for n in range(10)])
          block = scan_review_reality(world.root)
          del block["oldest_merged_days_ago"]
          assert block == {
              "available": True, "merged_count": 10, "reviewed_share": 0.0,
              "approved_share": 0.0, "bot_review_share": 0.0, "self_merged_share": 1.0,
              "review_required": True, "hollow_required_review": True,
              "required_approval_bypassed": True,
          }
          # The ruleset already said yes: the protection read is skipped.
          assert not any("/protection" in c for c in world.calls())
      
      
      def test_review_reality_mixed_sample_counts_each_share(world) -> None:
          _require_by_ruleset(world)
          bot = {"login": "reviewbot", "is_bot": True, "type": "Bot"}
          actions = {"login": "github-actions", "is_bot": True, "type": "Bot"}
          human = {"login": "carol", "is_bot": False, "type": "User"}
          prs = [
              _pr(0, merger=REVIEWER, comments=[bot]),
              _pr(1, merger=REVIEWER, comments=[bot]),
              _pr(2, merger=REVIEWER, comments=[bot]),
              _pr(3, merger=REVIEWER, comments=[actions]),
              _pr(4, comments=[actions]),
              _pr(5, comments=[human]),
              _pr(6), _pr(7),
              _pr(8, reviews=[{"login": "alice"}]),  # the author's own review, case aside
              _pr(9, reviews=[REVIEWER]),
          ]
          world.serve("prs.json", prs)
          block = scan_review_reality(world.root)
          assert block["reviewed_share"] == 0.1
          assert block["bot_review_share"] == 0.3
          assert block["self_merged_share"] == 0.6
          assert block["hollow_required_review"] is True
      
      
      def test_review_reality_half_reviewed_is_not_hollow(world) -> None:
          world.serve("branchrules.json", [])
          world.serve("protection.json", {"required_pull_request_reviews": {
              "required_approving_review_count": 2}})
          world.serve("prs.json", [_pr(n, reviews=[REVIEWER] if n < 5 else []) for n in range(10)])
          block = scan_review_reality(world.root)
          assert block["review_required"] is True
          assert block["reviewed_share"] == 0.5
          assert block["hollow_required_review"] is False
      
      
      def test_review_reality_no_requirement_is_never_hollow(world) -> None:
          world.serve("branchrules.json", [])
          world.fail_with("gh: Branch not protected (HTTP 404)")
          world.serve("prs.json", [_pr(n) for n in range(3)])
          block = scan_review_reality(world.root)
          assert block["review_required"] is False
          assert block["hollow_required_review"] is False
      
      
      def test_review_reality_refused_protection_leaves_requirement_unknown(world) -> None:
          world.serve("branchrules.json", [])
          world.fail_with("gh: Resource not accessible by integration (HTTP 403)")
          world.serve("prs.json", [_pr(n) for n in range(3)])
          block = scan_review_reality(world.root)
          assert block["available"] is True
          assert block["review_required"] is None
          assert block["hollow_required_review"] is None
      
      
      def test_review_reality_403_degrades_to_unavailable(world) -> None:
          world.fail_with("gh: Resource not accessible by personal access token (HTTP 403)")
          block = scan_review_reality(world.root)
          assert block["available"] is False
          assert block["reason"].startswith("no_access")
          assert "hollow_required_review" not in block
          assert world.calls()
      
      
      def test_review_reality_no_remote_never_calls_gh(world) -> None:
          _git(world.root, "remote", "remove", "origin")
          block = scan_review_reality(world.root)
          assert block["available"] is False and block["reason"]
          assert world.calls() == []
      
      
      def test_review_reality_probes_unmarked_comment_logins_once(world) -> None:
          _require_by_ruleset(world)
          world.serve("user-reviewbot%5Bbot%5D", {"login": "reviewbot[bot]", "type": "Bot"})
          # "dave[bot]" is absent from the fake, so the probe 404s: dave is a person.
          prs = [
              _pr(0, comments=[{"login": "reviewbot"}]),
              _pr(1, comments=[{"login": "reviewbot"}, {"login": "dave"}]),
              _pr(2, comments=[{"login": "dave"}]),
              _pr(3, comments=[{"login": "github-actions"}]),
          ]
          world.serve("prs.json", prs)
          block = scan_review_reality(world.root)
          assert block["bot_review_share"] == 0.5
          probes = [c for c in world.calls() if "users/" in c]
          assert sorted(probes) == ["api users/dave%5Bbot%5D", "api users/reviewbot%5Bbot%5D"]
      
      
      def test_review_reality_unknown_commenter_type_withholds_bot_share(world) -> None:
          _require_by_ruleset(world)
          world.fail_with("gh: Resource not accessible (HTTP 403)")
          world.serve("prs.json", [_pr(0, comments=[{"login": "mystery"}])])
          block = scan_review_reality(world.root)
          assert block["available"] is True
          assert block["bot_review_share"] is None
      
      
      def test_review_reality_writes_no_title_or_login(world) -> None:
          _require_by_ruleset(world)
          bot = {"login": "reviewbot", "is_bot": True, "type": "Bot"}
          world.serve("prs.json", [_pr(0, merger=REVIEWER, reviews=[REVIEWER], comments=[bot])])
          text = json.dumps(scan_review_reality(world.root)).lower()
          for secret in ("secret", "alice", "bob", "reviewbot"):
              assert secret not in text
      
      
      def test_review_reality_bot_comment_reviews_are_not_approvals(world) -> None:
          # An AI reviewer leaves a COMMENTED review on every PR; nobody approves.
          _require_by_ruleset(world)
          world.serve("prs.json", [_pr(n, reviews=[REVIEWER], state="COMMENTED") for n in range(10)])
          block = scan_review_reality(world.root)
          assert block["reviewed_share"] == 1.0
          assert block["approved_share"] == 0.0
          assert block["hollow_required_review"] is False
          assert block["required_approval_bypassed"] is True
      
      
      def test_review_reality_small_sample_withholds_both_flags(world) -> None:
          _require_by_ruleset(world)
          world.serve("prs.json", [_pr(n) for n in range(4)])
          block = scan_review_reality(world.root)
          assert block["reviewed_share"] == 0.0
          assert block["hollow_required_review"] is None
          assert block["required_approval_bypassed"] is None
      
      
      def test_review_reality_reports_age_of_oldest_merge() -> None:
          from datetime import datetime, timezone
      
          from lib.review_reality import summarize
      
          now = datetime(2026, 9, 19, tzinfo=timezone.utc)
          prs = [_pr(0, merged_at="2026-09-18T12:00:00Z"), _pr(1, merged_at="2026-08-20T00:00:00Z"),
                 _pr(2, merged_at=None)]
          assert summarize(prs, True, now=now)["oldest_merged_days_ago"] == 30
          assert summarize([_pr(0, merged_at=None)], True, now=now)["oldest_merged_days_ago"] is None
      
      
      def test_review_reality_comment_without_author_is_unknown(world) -> None:
          _require_by_ruleset(world)
          bot = {"login": "reviewbot", "is_bot": True, "type": "Bot"}
          # A confirmed bot comment still counts; an authorless comment alone is unknown.
          world.serve("prs.json", [_pr(0, comments=[None, bot]), _pr(1, comments=[None])])
          assert scan_review_reality(world.root)["bot_review_share"] is None
          world.serve("prs.json", [_pr(0, comments=[None, bot]), _pr(1)])
          assert scan_review_reality(world.root)["bot_review_share"] == 0.5
      
    • test_scope.py 11.3 KB
      """Monorepo scoping for `/assess <path>` (task assess-obey-thyself.19).
      
      A scoped run confines every signal to a subtree: the complexity stats, the doc
      graph, the churn axis, the badge, the wiki, and the artifact directory all
      describe the scope and carry no signal from a sibling directory. The key
      invariant guarded here is default-preservation: a run with no scope is
      byte-for-byte the pre-scope behaviour.
      """
      from __future__ import annotations
      
      import importlib.util
      import json
      import sys
      import types
      from pathlib import Path
      
      import pytest
      
      import assess_core
      from assess_core import build_run_context, resolve_scope
      from lib.badge import fallback_badge, score_badge
      from lib.doc_graph import build_doc_graph, discover_doc_files
      from lib.git_churn import git_churn_scores
      from lib.wiki_writer import HotspotEntry, write_index
      
      _SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "complexity-treemap.py"
      
      
      _STATS_SHAPE = {
          "files_scored": 0, "loc": {}, "ccn": {},
          "top_hotspots": [], "top_complex": [], "top_large": [],
      }
      
      
      # --------------------------------------------------------------------------
      # Two-service fixture: a monorepo with two sibling service subtrees, each with
      # its own complex code file and its own docs, committed to git so churn and
      # tracked-file filters have real history to read.
      # --------------------------------------------------------------------------
      def _two_service_repo(git_repo) -> tuple[Path, object]:
          repo, commit = git_repo
          for svc, fn in (("service-a", "alpha"), ("service-b", "beta")):
              d = repo / svc
              d.mkdir()
              # A deliberately branchy function so lizard/scc would score it a hotspot,
              # plus an unused helper (dead-code candidate) and a TODO (promissory
              # marker) so a scoped run has real sibling signal to exclude.
              (d / "app.py").write_text(
                  f"# TODO: refactor {svc}\n"
                  f"def {fn}(x):\n"
                  + "".join(f"    if x == {i}:\n        return {i}\n" for i in range(12))
                  + "    return -1\n\n"
                  f"def _unused_{fn}():\n    return 0\n",
                  encoding="utf-8",
              )
              (d / "README.md").write_text(f"# {svc}\n\nDocs for {svc}.\n", encoding="utf-8")
          commit("two services")
          return repo, commit
      
      
      # --------------------------------------------------------------------------
      # resolve_scope
      # --------------------------------------------------------------------------
      def test_resolve_scope_none_is_whole_repo(tmp_path: Path) -> None:
          assert resolve_scope(tmp_path, None) == (None, None, "")
      
      
      def test_resolve_scope_computes_slug(tmp_path: Path) -> None:
          sub = tmp_path / "services" / "api"
          sub.mkdir(parents=True)
          scope_abs, scope_rel, slug = resolve_scope(tmp_path, Path("services/api"))
          assert scope_abs == sub.resolve()
          assert scope_rel == "services/api"
          assert slug == "services-api"  # separators hyphenated for a flat dir name
      
      
      def test_resolve_scope_rejects_missing_path(tmp_path: Path) -> None:
          with pytest.raises(ValueError, match="does not exist"):
              resolve_scope(tmp_path, Path("nope"))
      
      
      def test_resolve_scope_rejects_outside_repo(tmp_path: Path) -> None:
          outside = tmp_path.parent / "elsewhere"
          outside.mkdir()
          with pytest.raises(ValueError, match="not under repo root"):
              resolve_scope(tmp_path, outside)
      
      
      # --------------------------------------------------------------------------
      # git_churn scope
      # --------------------------------------------------------------------------
      def test_git_churn_scope_isolates_subtree(git_repo) -> None:
          repo, _ = _two_service_repo(git_repo)
          scoped = git_churn_scores(repo, scope=repo / "service-a")
          assert scoped, "expected churn for the scoped subtree"
          assert all("service-a" in str(p) for p in scoped)
          assert not any("service-b" in str(p) for p in scoped)
      
      
      def test_git_churn_no_scope_sees_whole_repo(git_repo) -> None:
          repo, _ = _two_service_repo(git_repo)
          full = git_churn_scores(repo)
          assert any("service-a" in str(p) for p in full)
          assert any("service-b" in str(p) for p in full)
      
      
      # --------------------------------------------------------------------------
      # doc_graph scope
      # --------------------------------------------------------------------------
      def test_doc_graph_scope_excludes_sibling_docs(git_repo) -> None:
          repo, _ = _two_service_repo(git_repo)
          scoped = discover_doc_files(repo, scope=repo / "service-a")
          names = {str(p.relative_to(repo)) for p in scoped}
          assert "service-a/README.md" in names
          assert "service-b/README.md" not in names
      
      
      def test_build_doc_graph_scope_confines_docs(git_repo) -> None:
          repo, _ = _two_service_repo(git_repo)
          full = build_doc_graph(repo)
          scoped = build_doc_graph(repo, scope=repo / "service-a")
          assert full.doc_count > scoped.doc_count
          scoped_paths = set(scoped.graph.nodes) if scoped.graph is not None else set()
          assert not any("service-b" in n for n in scoped_paths)
      
      
      # --------------------------------------------------------------------------
      # treemap collect scope (lizard/scc stubbed - no heavy deps in the test env)
      # --------------------------------------------------------------------------
      def _load_treemap():
          for name in ("lizard", "matplotlib", "matplotlib.pyplot", "squarify", "numpy"):
              sys.modules.setdefault(name, types.ModuleType(name))
          sys.modules["matplotlib"].pyplot = sys.modules["matplotlib.pyplot"]
          spec = importlib.util.spec_from_file_location("complexity_treemap_scope", _SCRIPT)
          mod = importlib.util.module_from_spec(spec)
          spec.loader.exec_module(mod)
          return mod
      
      
      def test_treemap_collect_filters_to_scope(tmp_path: Path) -> None:
          mod = _load_treemap()
          a = (tmp_path / "service-a" / "app.py")
          b = (tmp_path / "service-b" / "app.py")
          a.parent.mkdir(parents=True)
          b.parent.mkdir(parents=True)
          a.write_text("x = 1\n")
          b.write_text("y = 2\n")
      
          def fake_lizard(root, **kw):
              return {a.resolve(): (10, 5.0, [5.0]), b.resolve(): (20, 9.0, [9.0])}
      
          def fake_scc(root, **kw):
              return {}
      
          mod.lizard_scores = fake_lizard
          mod.scc_scores = fake_scc
      
          files, *_ = mod.collect(tmp_path, by="complexity", scope=tmp_path / "service-a")
          paths = {f[0] for f in files}
          assert a.resolve() in paths
          assert b.resolve() not in paths
      
      
      # --------------------------------------------------------------------------
      # badge + wiki labelling
      # --------------------------------------------------------------------------
      def test_badge_labels_scope() -> None:
          scoped = score_badge(4.0, "Basic", scope="services/api")
          assert scoped["label"] == "AI-readiness (services/api)"
          assert score_badge(4.0, "Basic")["label"] == "AI-readiness"  # default unchanged
          assert fallback_badge(1, 0, scope="services/api")["label"] == "AI-readiness (services/api)"
      
      
      def test_write_index_scope_note(tmp_assess_dir: Path) -> None:
          entries = [HotspotEntry(path="service-a/app.py", first_flagged="2026-07-07",
                                  last_seen="2026-07-07", status="new", ccn=9, loc=20)]
          write_index(tmp_assess_dir, entries, last_updated="2026-07-07", scope="service-a")
          scoped = (tmp_assess_dir / "index.md").read_text(encoding="utf-8")
          assert "_Scope: `service-a`_" in scoped
      
          write_index(tmp_assess_dir, entries, last_updated="2026-07-07")
          assert "_Scope:" not in (tmp_assess_dir / "index.md").read_text(encoding="utf-8")
      
      
      # --------------------------------------------------------------------------
      # assess_core end-to-end: scoped run routes artifacts and isolates signal
      # --------------------------------------------------------------------------
      def _seed_scoped_stats(repo: Path, slug: str, hotspot_path: str) -> None:
          d = repo / ".assess" / slug
          d.mkdir(parents=True)
          (d / "complexity-stats.json").write_text(json.dumps({
              **_STATS_SHAPE,
              "files_scored": 1,
              "top_hotspots": [{"path": hotspot_path, "loc": 20, "ccn": 9,
                                "commits_12mo": 1}],
          }))
      
      
      def test_scoped_run_routes_artifacts_and_records_scope(git_repo) -> None:
          repo, _ = _two_service_repo(git_repo)
          _seed_scoped_stats(repo, "service-a", "service-a/app.py")
      
          ctx = build_run_context(repo_root=repo, run_date="2026-07-07",
                                  scope=Path("service-a"))
      
          # Scope recorded in the bus for the report/badge/wiki.
          assert ctx["scope"] == "service-a"
          assert ctx["scope_slug"] == "service-a"
      
          # Artifacts land under .assess/<slug>/, not the repo-root .assess/.
          scoped_dir = repo / ".assess" / "service-a"
          assert (scoped_dir / "run-context.json").exists()
          assert (scoped_dir / "index.md").exists()
          assert (scoped_dir / "log.md").exists()
          assert not (repo / ".assess" / "run-context.json").exists()
      
          # The only hotspot is the scoped one; the sibling never appears.
          hotspot_paths = [h["path"] for h in ctx["stats_summary"]["top_hotspots"]]
          assert hotspot_paths == ["service-a/app.py"]
          blob = (scoped_dir / "run-context.json").read_text(encoding="utf-8")
          assert "service-b" not in blob
      
          # The scoped wiki index names the scope and never the sibling.
          index = (scoped_dir / "index.md").read_text(encoding="utf-8")
          assert "_Scope: `service-a`_" in index
          assert "service-b" not in index
      
          # The behaviour block's change-coupling saw the single commit that touched
          # both services, but the sibling is confined out: no service-b co-change.
          behaviour = ctx.get("behaviour", {})
          for pair in behaviour.get("change_coupling_pairs", []):
              assert "service-b" not in json.dumps(pair)
          # And the dead-code / marker scans carry no sibling candidate.
          assert "service-b" not in json.dumps(ctx.get("dead_code", {}))
      
      
      def test_scoped_run_doc_graph_has_no_sibling_signal(git_repo) -> None:
          repo, _ = _two_service_repo(git_repo)
          _seed_scoped_stats(repo, "service-a", "service-a/app.py")
          ctx = build_run_context(repo_root=repo, run_date="2026-07-07",
                                  scope=Path("service-a"))
          dg = ctx["doc_graph"]
          if dg.get("available"):
              # Whatever docs the scoped graph found, none is the sibling's.
              serialized = json.dumps(dg)
              assert "service-b" not in serialized
      
      
      def test_root_run_unchanged_by_scope_support(git_repo) -> None:
          """The key invariant: a no-scope run is byte-identical to pre-scope."""
          repo, _ = _two_service_repo(git_repo)
          (repo / ".assess").mkdir()
          (repo / ".assess" / "complexity-stats.json").write_text(json.dumps(_STATS_SHAPE))
      
          ctx = build_run_context(repo_root=repo, run_date="2026-07-07")
          assert ctx["scope"] is None
          assert ctx["scope_slug"] is None
          # Artifacts stay at the repo-root .assess/.
          assert (repo / ".assess" / "run-context.json").exists()
          assert not (repo / ".assess" / "service-a").exists()
      
      
      def test_invalid_scope_raises_valueerror(git_repo) -> None:
          repo, _ = _two_service_repo(git_repo)
          with pytest.raises(ValueError):
              build_run_context(repo_root=repo, run_date="2026-07-07",
                                scope=Path("no-such-dir"))
      
      
      def test_cli_invalid_scope_exits_nonzero(git_repo, capsys) -> None:
          repo, _ = _two_service_repo(git_repo)
          (repo / ".assess").mkdir()
          (repo / ".assess" / "complexity-stats.json").write_text(json.dumps(_STATS_SHAPE))
          rc = assess_core.main([str(repo), "--scope", "no-such-dir"])
          assert rc == 2
          assert "error" in capsys.readouterr().err.lower()
      
    • test_scorer_evidence.py 7.3 KB
      """Contract for the layer scorer's structured evidence (issue #361).
      
      The scorer returns an ``evidence`` list beside its prose; the orchestrator
      re-checks it with ``lib.evidence_check`` between scoring and report writing, and
      the findings step cites only verified entries. These tests pin the three halves:
      
      1. The scorer definition states the evidence schema with one example per kind,
         and every example is true of this repository (the deterministic half of a
         dry run on this repo: an ``evidence`` list and an empty ``evidence_rejected``).
      2. ``skills/assess/SKILL.md`` runs the check after scoring and before finalize.
      3. ``skills/assess-findings/SKILL.md`` restricts existence and wiring claims to
         verified entries.
      """
      from __future__ import annotations
      
      import json
      import re
      from pathlib import Path
      
      from lib.evidence_check import KINDS, check_evidence
      
      REPO_ROOT = Path(__file__).resolve().parents[3]
      SCORER = REPO_ROOT / "agents" / "assess-layer-scorer.md"
      ASSESS_SKILL = REPO_ROOT / "skills" / "assess" / "SKILL.md"
      FINDINGS_SKILL = REPO_ROOT / "skills" / "assess-findings" / "SKILL.md"
      
      
      def _schema_examples() -> list:
          text = SCORER.read_text(encoding="utf-8")
          headings = re.findall(r"^#{2,4} Evidence schema$", text, flags=re.M)
          assert len(headings) == 1, "scorer must carry exactly one Evidence schema heading"
          after = text[re.search(r"^#{2,4} Evidence schema$", text, flags=re.M).end():]
          block = re.search(r"^```json\n(.*?)^```$", after, flags=re.M | re.S)
          assert block, "no json block under the Evidence schema heading"
          return json.loads(block.group(1))
      
      
      def test_scorer_return_section_names_evidence_list():
          text = SCORER.read_text(encoding="utf-8")
          # The return list only, not the Evidence schema subsection that follows it,
          # so deleting the bullet fails this test even though the schema prose stays.
          section = text.split("## What you return", 1)[1].split("### Evidence schema", 1)[0]
          bullets = [line for line in section.splitlines() if line.startswith("- ")]
          assert any("`evidence`" in line for line in bullets)
      
      
      def test_scorer_schema_has_one_example_per_kind():
          examples = _schema_examples()
          assert sorted(e["kind"] for e in examples) == sorted(KINDS)
          assert all(isinstance(e.get("layer"), int) and 0 <= e["layer"] <= 8 for e in examples)
      
      
      def test_scorer_schema_examples_verify_against_this_repo():
          result = check_evidence(REPO_ROOT, _schema_examples())
          assert result["evidence_rejected"] == []
          assert len(result["evidence"]) == len(KINDS)
      
      
      def test_orchestrator_checks_evidence_between_scoring_and_finalize():
          text = ASSESS_SKILL.read_text(encoding="utf-8")
          window = text.split("## Step 3: Score the Layers\n", 1)[1].split("## Step 7.5", 1)[0]
          assert "evidence_check" in window
          assert "evidence_rejected" in window
      
      
      def test_findings_cites_only_verified_evidence():
          assert "cite only verified" in FINDINGS_SKILL.read_text(encoding="utf-8").lower()
      
      
      def test_orchestrator_runs_the_check_by_script_path():
          # An /assess run's cwd is the target repo, where `python -m lib.evidence_check`
          # fails with "No module named lib" and exits 1 - the same code as "an entry was
          # rejected". The call must name the script by path, like every sibling call.
          text = ASSESS_SKILL.read_text(encoding="utf-8")
          window = text.split("## Step 3: Score the Layers\n", 1)[1].split("## Step 7.5", 1)[0]
          assert '"${CLAUDE_SKILL_DIR}/scripts/lib/evidence_check.py"' in window
          assert "-m lib.evidence_check" not in window
          assert "<!-- chat-replace:evidence-check -->" in window
      
      
      def _step4_check_paragraph() -> str:
          text = ASSESS_SKILL.read_text(encoding="utf-8")
          step4 = text.split("## Step 4: Write the Report\n", 1)[1].split("## Step 7.5", 1)[0]
          return next(p for p in step4.split("\n\n") if "evidence_check.py" in p)
      
      
      def test_step4_names_the_check_by_the_substituted_skill_dir():
          # Step 2's shell variables do not survive to Step 4 (Step 3's subagent sits
          # in between), so the check must not lean on one. ${CLAUDE_SKILL_DIR} is
          # substituted into the skill text before the model reads it, so the call
          # carries an absolute path and needs no re-resolution in the shell.
          para = _step4_check_paragraph()
          assert '"${CLAUDE_SKILL_DIR}/scripts/lib/evidence_check.py"' in para
          assert "SKILL_DIR=" not in para
          assert "$SKILL_DIR" not in para
          assert "CLAUDE_PLUGIN_ROOT" not in para
          assert "as in Step 2" not in para
      
      
      def test_step4_hands_rejected_entries_on_to_the_findings_step():
          # The findings step renders the refuted-claims gap from `evidence_rejected`,
          # so the orchestrator must pass the list on rather than drop it.
          para = _step4_check_paragraph()
          assert "removed from the report input" not in para
          assert "`evidence_rejected`" in para and "handed on" in para
      
      
      def _evidence_cell_rule() -> str:
          text = FINDINGS_SKILL.read_text(encoding="utf-8")
          return next(p for p in text.split("\n\n") if "`(unverified)`" in p)
      
      
      def test_evidence_cell_marks_only_unoffered_evidence_unverified():
          # No entries offered is an honest limit of the run: keep the note, flag it.
          rule = _evidence_cell_rule()
          unverified = next(s for s in rule.split(". ") if "`(unverified)`" in s)
          assert "offered no" in unverified
          assert "rejected" not in unverified and "held" not in unverified
      
      
      def test_evidence_cell_gives_na_layer_no_unverified_marker():
          # An N/A layer is scored with no entries by design (the scorer skips
          # `na_layers`), so it must not fall into the no-entries-offered state.
          rule = _evidence_cell_rule()
          na = next(s for s in rule.split(". ") if "N/A layer" in s)
          assert "`(unverified)`" not in na
          assert "archetype rule" in na and "no marker" in na
      
      
      def test_evidence_cell_rule_sits_beside_archetype_rule():
          # The two rules govern the same cell; kept apart they contradicted each other.
          text = FINDINGS_SKILL.read_text(encoding="utf-8")
          paras = text.split("\n\n")
          archetype = next(i for i, p in enumerate(paras) if "Archetype-aware Status" in p)
          assert "`(unverified)`" in paras[archetype + 1]
      
      
      def test_evidence_cell_renders_refuted_claims_as_a_gap():
          # Entries offered and all rejected were checked and found false: repeating
          # the note, even flagged, restates the claim that just failed.
          rule = _evidence_cell_rule()
          assert "no verified evidence - N claim(s) rejected" in rule
          assert "drop the scorer's note" in rule
          assert "`reason`" in rule
      
      
      def test_evidence_cell_strikes_refuted_claims_when_only_some_entries_fail():
          # The common case is neither empty state: a layer cites three entries, two
          # hold, one is refuted. Rendering the note verbatim publishes the refuted claim.
          rule = _evidence_cell_rule()
          partial = next(s for s in rule.split(". ") if "only some" in s)
          assert "strike the refuted claims" in partial
          assert "N of M claim(s) rejected" in partial
          assert "`(unverified)`" not in partial
      
      
      def test_cite_only_verified_exempts_only_the_empty_evidence_cell_states():
          # Exempting the whole Evidence cell let a partly refuted note through verbatim.
          text = FINDINGS_SKILL.read_text(encoding="utf-8")
          para = next(p for p in text.split("\n\n") if "cite only verified evidence" in p)
          cell = next(s for s in para.split(". ") if "Evidence cell" in s)
          assert "exempt only in its two empty states" in cell
          assert "some verified entries" in cell
      
    • test_self_architecture.py 4.1 KB
      """Executable architecture contract for the /assess deterministic core.
      
      `CLAUDE.md` states the layering: the deterministic core in
      `skills/assess/scripts/lib/` does all the data work, and the orchestrator
      scripts in `skills/assess/scripts/` (assess_core, assess_finalize, ...) call
      into it and assemble `run-context.json`. Dependencies point **inward**: a lib
      module may import other lib modules and third-party libraries, but it must never
      import an orchestrator. That keeps the core independently testable and reusable -
      the property the decomposition-parity test relies on.
      
      This was a convention enforced only by review (the L4 "Partial" the tool's own
      self-assessment flagged). This test makes it a contract: an `ast` scan over every
      module in `lib/` asserts none imports an orchestrator. The forbidden set is
      *derived* from disk - every `.py` directly under `scripts/` (not in `lib/`) - so
      a newly added orchestrator is forbidden automatically, with no edit here.
      
      Pure stdlib (`ast`, `pathlib`); no import side effects, so it is safe and fast.
      """
      from __future__ import annotations
      
      import ast
      from pathlib import Path
      
      # tests/ -> assess/ ; the deterministic core and its orchestrators live under scripts/.
      SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
      LIB_DIR = SCRIPTS_DIR / "lib"
      
      
      def _orchestrator_modules() -> set[str]:
          """Importable module names of the orchestrator layer: every ``*.py`` directly
          under ``scripts/`` (excluding the ``lib/`` package). Hyphenated run-only
          scripts (``complexity-treemap.py``) are kept for completeness - their stems
          are not valid identifiers, so they can never appear as an import anyway."""
          return {p.stem for p in SCRIPTS_DIR.glob("*.py")}
      
      
      def _lib_modules() -> list[Path]:
          """Every Python module in the deterministic core, recursively (includes the
          ``test_pressure/`` subpackage)."""
          return sorted(LIB_DIR.rglob("*.py"))
      
      
      def _imported_names(tree: ast.AST) -> set[str]:
          """Collect the dotted module path of every ``import`` / ``from ... import``
          in a parsed module (absolute imports only; relative imports stay in-package
          by construction and cannot reach an orchestrator)."""
          names: set[str] = set()
          for node in ast.walk(tree):
              if isinstance(node, ast.Import):
                  for alias in node.names:
                      names.add(alias.name)
              elif isinstance(node, ast.ImportFrom):
                  # level > 0 is a relative import (``from . import x``) - in-package,
                  # never an orchestrator. Only absolute imports can cross the boundary.
                  if node.level == 0 and node.module:
                      names.add(node.module)
          return names
      
      
      def test_fixture_paths_resolve() -> None:
          """Guard against a path bug making the boundary test vacuously pass."""
          orchestrators = _orchestrator_modules()
          libs = _lib_modules()
          assert LIB_DIR.is_dir(), f"lib package not found at {LIB_DIR}"
          assert "assess_core" in orchestrators, "expected assess_core.py among the orchestrators"
          assert libs, "expected at least one module in the deterministic core"
      
      
      def test_core_does_not_import_orchestrators() -> None:
          """No `lib/` module may import an orchestrator script - dependencies point
          inward. A failure means the deterministic core grew an upward dependency."""
          orchestrators = _orchestrator_modules()
          violations: list[str] = []
      
          for module in _lib_modules():
              tree = ast.parse(module.read_text(encoding="utf-8"), filename=str(module))
              for imported in _imported_names(tree):
                  # Match an orchestrator stem appearing as any dotted component, so
                  # both ``import assess_core`` and ``import scripts.assess_core`` are
                  # caught.
                  hit = next((o for o in orchestrators if o in imported.split(".")), None)
                  if hit:
                      rel = module.relative_to(LIB_DIR.parent)
                      violations.append(f"{rel} imports orchestrator '{hit}' (via '{imported}')")
      
          assert not violations, (
              "deterministic core must not import the orchestrator layer "
              "(dependencies point inward - see CLAUDE.md /assess architecture):\n  "
              + "\n  ".join(violations)
          )
      
    • test_self_dogfood.py 13.1 KB
      """The capstone self-check: /assess's deterministic core must obey the very
      invariants the assess-obey-thyself marathon added (tasks 2-16).
      
      Every prior task in the marathon hardened one property of the emitted wiki -
      a provenance stamp, a contradiction flag, an exclusion disclosure, a mutation
      cap, a deterministic badge, a versioned action contract, an orphan-free hotspot
      set, a verifiable log chain, decline-marker provenance. This test runs the
      *deterministic* pipeline (``build_run_context`` - never the LLM finalize, which
      CI cannot drive with no network/model) against a small, hermetic, purpose-built
      fixture repo and asserts the output honours each invariant. If a future change
      regresses one of them, this build goes red: the tool would no longer obey its
      own rules.
      
      Why a built fixture, not the live repo tree: the fixture is fast, deterministic,
      and hermetic (the live ``.assess/`` drifts run to run and would make the test
      flaky). It is a real ``build_run_context`` run over a real git repo - the same
      code path a user's ``/assess`` drives - just over a controlled tree that pins
      every signal the invariants read.
      """
      from __future__ import annotations
      
      import json
      import os
      import re
      import subprocess
      from pathlib import Path
      
      import pytest
      
      import assess_core
      from assess_core import build_run_context
      from assess_finalize import _write_actions_contract
      from lib.keyhole_signals import FINDING_MODE_VALUES, mode_for_finding
      from lib.wiki_writer import (
          hotspot_page_source_path,
          hotspot_page_status,
          is_retired_status,
          verify_log_chain,
      )
      
      RUN_ID_RE = re.compile(r"^\d{14}-[0-9a-f]{8}$")
      
      # Two source files that the seeded stats name as top hotspots. They must exist
      # on disk so the no-orphan invariant (task 9) is exercised, not vacuously true.
      _HOT_ONE = "src/hot_one.py"
      _HOT_TWO = "src/hot_two.py"
      
      
      def _git(repo: Path, *args: str) -> None:
          subprocess.run(
              ["git", "-C", str(repo), *args],
              check=True, capture_output=True, text=True, env=os.environ,
          )
      
      
      def _build_dogfood_repo(root: Path, fixtures_dir: Path) -> Path:
          """A small software repo with every signal the invariants read pinned:
          a committed instruction file, two on-disk hotspot files, a seeded stats
          sidecar naming them, and a provenance-carrying decline marker.
          """
          repo = root / "repo"
          (repo / "src").mkdir(parents=True)
          _git(repo, "init", "-q")
          _git(repo, "config", "user.email", "test@example.com")
          _git(repo, "config", "user.name", "Test")
      
          # Committed instruction file so Layer 0 has a real surface to grade.
          (repo / "CLAUDE.md").write_text(
              (fixtures_dir / "good_instructions.md").read_text(encoding="utf-8"),
              encoding="utf-8",
          )
          (repo / "pyproject.toml").write_text("[project]\nname='dogfood'\n", encoding="utf-8")
      
          # Enough code files that archetype classifies the repo as software.
          for i in range(12):
              (repo / "src" / f"mod_{i}.py").write_text(
                  f"def f{i}(x):\n    return x + {i}\n", encoding="utf-8"
              )
          # The two named hotspots, present on disk (no-orphan invariant).
          (repo / _HOT_ONE).write_text(
              "def tangled(a, b):\n" + "    a = a + b\n" * 40 + "    return a\n",
              encoding="utf-8",
          )
          (repo / _HOT_TWO).write_text(
              "def sprawl(a):\n" + "    a += 1\n" * 30 + "    return a\n",
              encoding="utf-8",
          )
      
          assess_dir = repo / ".assess"
          assess_dir.mkdir()
          (assess_dir / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 14,
              "loc": {"total": 900},
              "ccn": {"max": 34, "mean": 6},
              "top_hotspots": [
                  {"path": _HOT_ONE, "loc": 620, "ccn": 34, "commits": 8},
                  {"path": _HOT_TWO, "loc": 540, "ccn": 22, "commits": 4},
              ],
              "top_complex": [{"path": _HOT_ONE, "ccn": 34}],
              "top_large": [{"path": _HOT_ONE, "loc": 620}],
          }), encoding="utf-8")
      
          # Decline marker with full provenance (task 12): declined_by / declined_at /
          # plugin_version / reason all present.
          (assess_dir / ".no-mutmut").write_text(json.dumps({
              "declined_by": "ben",
              "declined_at": "2026-07-01",
              "plugin_version": "1.55.5",
              "reason": "bounded mutation pass declined for the fixture",
          }), encoding="utf-8")
      
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", "dogfood fixture")
          return repo
      
      
      @pytest.fixture(scope="module")
      def dogfood_run(tmp_path_factory, request) -> dict:
          """Run the deterministic pipeline once; every test asserts one invariant on
          the shared result. Module-scoped so the (git-backed) pipeline runs a single
          time - the output is a pure function of the pinned tree, so sharing is safe.
          """
          fixtures_dir = Path(request.fspath).parent / "fixtures"
          root = tmp_path_factory.mktemp("dogfood")
          repo = _build_dogfood_repo(root, fixtures_dir)
          ctx = build_run_context(
              repo_root=repo, run_date="2026-07-07", non_interactive=True,
          )
          assess_dir = repo / ".assess"
          on_disk = json.loads((assess_dir / "run-context.json").read_text(encoding="utf-8"))
          return {"repo": repo, "assess_dir": assess_dir, "ctx": ctx, "on_disk": on_disk}
      
      
      # --- task 2: run_id + artifact_schema_version provenance stamps ---------------
      
      def test_run_id_and_schema_version_stamped(dogfood_run: dict) -> None:
          ctx = dogfood_run["ctx"]
          assert RUN_ID_RE.match(ctx["run_id"]), ctx["run_id"]
          assert ctx["artifact_schema_version"] == assess_core.ARTIFACT_SCHEMA_VERSION
          # The stamp is persisted, not just returned in-process.
          assert dogfood_run["on_disk"]["run_id"] == ctx["run_id"]
          assert (
              dogfood_run["on_disk"]["artifact_schema_version"]
              == assess_core.ARTIFACT_SCHEMA_VERSION
          )
      
      
      def test_run_id_propagates_to_badge(dogfood_run: dict) -> None:
          """The run_id stamped in run-context is the same id carried on the artifact
          every consumer reads first - proving the stamp is one run, not per-writer."""
          badge = json.loads((dogfood_run["assess_dir"] / "badge.json").read_text(encoding="utf-8"))
          assert badge["run_id"] == dogfood_run["ctx"]["run_id"]
      
      
      # --- task 3: archetype.override_contradicts_signals is a real bool ------------
      
      def test_archetype_override_contradicts_signals_present(dogfood_run: dict) -> None:
          arch = dogfood_run["ctx"]["archetype"]
          assert arch["available"] is True
          assert "override_contradicts_signals" in arch
          assert isinstance(arch["override_contradicts_signals"], bool)
          # No override marker in the fixture, so the flag is False (not merely present).
          assert arch["override_contradicts_signals"] is False
      
      
      # --- task 7: excluded_by_config disclosure block ------------------------------
      
      def test_excluded_by_config_block_present(dogfood_run: dict) -> None:
          block = dogfood_run["ctx"]["excluded_by_config"]
          assert set(block) >= {"dirs", "patterns", "affected_finding_paths", "count"}
          assert isinstance(block["dirs"], list)
          assert isinstance(block["patterns"], list)
          # count is the honest tally of suppressed finding paths - never out of sync.
          assert block["count"] == len(block["affected_finding_paths"])
      
      
      # --- task 8: mutation_not_run_cap on a default read-only run ------------------
      
      def test_mutation_not_run_cap_applies_and_caps_layer6(dogfood_run: dict) -> None:
          cap = dogfood_run["ctx"]["mutation_not_run_cap"]
          assert cap["applies"] is True
          assert cap["mutation_run"] is False
          assert cap["max_layer6_band"] == "Partial"
          assert cap["annotation"]  # a non-empty annotation the LLM must carry
      
      
      # --- task 15: deterministic badge (findings count, not an LLM score) ----------
      
      def test_badge_is_deterministic_findings_count(dogfood_run: dict) -> None:
          badge = json.loads((dogfood_run["assess_dir"] / "badge.json").read_text(encoding="utf-8"))
          assert "findings" in badge["message"]
          # The shipped badge never bakes in an LLM-derived layered score.
          assert "/8" not in badge["message"]
          assert badge["link"] == "./assess-report.md"
      
      
      # --- task 16: actions.json v2 (schema 2, status, derived mode) ----------------
      
      def test_actions_json_v2_schema_status_and_derived_mode(dogfood_run: dict) -> None:
          """actions.json is written by the deterministic ``_write_actions_contract``
          (the writer finalize calls). Drive it directly - CI has no LLM to author the
          Top 3 - and assert the v2 shape: schema 2, a lifecycle ``status`` per action,
          and a ``mode`` derived from each action's finding type."""
          assess_dir = dogfood_run["assess_dir"]
          run_id = dogfood_run["ctx"]["run_id"]
          actions = [
              {"rank": 1, "action": "Characterize the src/hot_one.py seam",
               "done_when": "seam mapped", "scope_fence": "read only",
               "finding": "hidden_coupling"},
              {"rank": 2, "action": "Verify and retire the aged marker",
               "done_when": "marker resolved", "scope_fence": "one file",
               "finding": "unactioned_intent"},
              {"rank": 3, "action": "Split the accreted module",
               "done_when": "module under budget", "scope_fence": "no API change",
               "finding": "accretion_ratchet"},
          ]
          _write_actions_contract(assess_dir, actions, run_id=run_id)
          payload = json.loads((assess_dir / "actions.json").read_text(encoding="utf-8"))
      
          assert payload["schema"] == 2
          assert payload["run_id"] == run_id
          entries = payload["actions"]
          assert len(entries) == 3
          for entry in entries:
              assert entry["status"] == "pending"  # fresh contract, no carry-forward
              assert entry["mode"] in FINDING_MODE_VALUES
              # The mode is *derived* from the finding type, not free text.
              assert entry["mode"] == mode_for_finding(entry.get("finding"))
          # The derivation is discriminating: distinct finding types earn distinct modes.
          modes = {e["finding"]: e["mode"] for e in entries}
          assert modes["hidden_coupling"] == "characterize_first"
          assert modes["unactioned_intent"] == "verify_then_retire"
          assert modes["accretion_ratchet"] == "refactor_safe"
      
      
      # --- task 9: no active hotspot page references a path absent from disk ---------
      
      def test_no_orphan_hotspot_pages(dogfood_run: dict) -> None:
          assess_dir = dogfood_run["assess_dir"]
          repo = dogfood_run["repo"]
          pages = sorted((assess_dir / "hotspots").glob("*.md"))
          assert pages, "expected at least one hotspot page (invariant must not be vacuous)"
          active_referenced = 0
          orphans: list[str] = []
          for page in pages:
              content = page.read_text(encoding="utf-8")
              path = hotspot_page_source_path(content)
              if path is None:
                  continue
              if is_retired_status(hotspot_page_status(content)):
                  continue
              active_referenced += 1
              if not (repo / path).exists():
                  orphans.append(path)
          assert active_referenced >= 1
          assert orphans == []
      
      
      # --- task 11: log.md integrity chain verifies ---------------------------------
      
      def test_log_chain_verifies(dogfood_run: dict) -> None:
          # The run-context claim...
          integrity = dogfood_run["ctx"]["log_integrity"]
          assert integrity["valid"] is True
          assert integrity["broken_at_entry"] is None
          # ...and the artifact itself, recomputed independently.
          valid, broken_at = verify_log_chain(dogfood_run["assess_dir"])
          assert valid is True
          assert broken_at is None
      
      
      # --- task 12: decline-marker provenance shape ---------------------------------
      
      def test_decline_marker_provenance_shape(dogfood_run: dict) -> None:
          markers = dogfood_run["ctx"]["decline_markers"]
          assert markers, "fixture writes one decline marker"
          mutmut = next((m for m in markers if m["tool"] == "mutmut"), None)
          assert mutmut is not None
          assert set(mutmut) >= {
              "path", "tool", "declined_by", "declined_at", "version", "reason", "reoffer",
          }
          # Provenance is populated (a legacy marker would carry None here).
          assert mutmut["declined_by"] == "ben"
          assert mutmut["declined_at"] == "2026-07-01"
          assert mutmut["version"] == "1.55.5"
          assert isinstance(mutmut["reoffer"], bool)
      
      
      def test_log_chain_verifies_after_finalize(dogfood_run: dict, tmp_path: Path) -> None:
          """Finalize fills the run's log entry in place; the chain must still verify.
      
          Finalize runs on a copy so the shared module fixture stays unfinalized for
          the other invariants. Before #355 finalize rewrote the entry text but kept
          its old chain marker, so every finalized log failed verification and the
          next core run disclosed a broken history.
          """
          import shutil
      
          from assess_finalize import finalize_run
          from lib.badge import maturity_band
      
          assess_dir = tmp_path / ".assess"
          shutil.copytree(dogfood_run["assess_dir"], assess_dir)
          assert verify_log_chain(assess_dir) == (True, None)
          (assess_dir / ".cache").mkdir(exist_ok=True)
          (assess_dir / ".cache" / "finalize-input.json").write_text(json.dumps({
              "run_id": dogfood_run["ctx"]["run_id"],
              "score": 4.0,
              "denominator": 8,
              "maturity_label": maturity_band(4.0, 8),
              "top_action": "dogfood finalize action",
              "hotspot_actions": {},
          }), encoding="utf-8")
          finalize_run(assess_dir=assess_dir)
          log = (assess_dir / "log.md").read_text(encoding="utf-8")
          assert "dogfood finalize action" in log
          assert "(LLM fills in)" not in log
          assert verify_log_chain(assess_dir) == (True, None)
      
    • test_sibling_tests.py 12.7 KB
      """Contract tests for ``lib/sibling_tests``: the one sibling-test resolver.
      
      The hotspot page (``assess_core._has_sibling_test``), the E2 test-to-code map
      (``keyhole_signals._find_sibling_test``), and the focus signal
      (``test_focus.compute_test_focus``) must answer "does this file have a test
      file?" the same way in one run. These tests pin that agreement on a fixture set
      spanning every naming idiom, so a convention added to one consumer and not the
      others fails here.
      """
      from __future__ import annotations
      
      from pathlib import Path
      
      import assess_core
      from lib import keyhole_signals as ks
      from lib import sibling_tests as tc
      from lib.test_focus import compute_test_focus, mutation_scope
      
      # Co-located fixtures: source -> test file beside it or in an adjacent dir.
      COLOCATED = {
          "go/foo.go": "go/foo_test.go",
          "ts/bar.ts": "ts/bar.test.ts",
          "ng/baz.ts": "ng/baz.spec.ts",
          "rb/qux.rb": "rb/qux_spec.rb",
          "py/mod.py": "py/test_mod.py",
          "java/Foo.java": "java/FooTest.java",
          "cs/Bar.cs": "cs/BarTests.cs",
          "js/e.js": "js/__tests__/e.test.js",
          "js/f.js": "js/__tests__/f.js",
          "rb2/g.rb": "rb2/spec/g_spec.rb",
          "scripts/tree-map.py": "scripts/test_tree_map.py",
      }
      # Sources with no test file anywhere.
      UNTESTED = ["java/Lonely.java", "py/alone.py", "ts/solo.ts"]
      
      
      def _touch(root: Path, rel: str) -> None:
          p = root / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text("x\n", encoding="utf-8")
      
      
      def _fixture(root: Path) -> None:
          for src, test in COLOCATED.items():
              _touch(root, src)
              _touch(root, test)
          for src in UNTESTED:
              _touch(root, src)
      
      
      def test_three_resolvers_agree_on_colocated_fixtures(tmp_path: Path) -> None:
          """Every co-located idiom (including FooTest.java / BarTests.cs) is credited
          by all three consumers, and every untested source by none of them."""
          _fixture(tmp_path)
          sources = [*COLOCATED, *UNTESTED]
          focus: dict[str, str] = {}
          for start in range(0, len(sources), 10):  # the focus block reads a top 10
              block = compute_test_focus(sources[start:start + 10], None, None,
                                         repo_root=tmp_path)
              focus.update({e["path"]: e["test_signal"] for e in block["entries"]})
      
          for src in sources:
              expected = src in COLOCATED
              assert assess_core._has_sibling_test(tmp_path, src) is expected, src
              found = ks._find_sibling_test(tmp_path, src)
              assert (found is not None) is expected, src
              if expected:
                  assert found == tmp_path / COLOCATED[src]
              assert focus[src] == ("sibling_test_only" if expected else "unsupported"), src
      
      
      def test_resolvers_agree_that_a_test_file_is_test_evidence(tmp_path: Path) -> None:
          """A hot file that is itself a test: the hotspot page and the focus signal
          both credit it, and E2 maps it to no sibling (it is not a source)."""
          _fixture(tmp_path)
          for test in COLOCATED.values():
              assert tc.is_test_path(test), test
              assert assess_core._has_sibling_test(tmp_path, test) is True
              assert ks._find_sibling_test(tmp_path, test) is None
              block = compute_test_focus([test], None, None, repo_root=tmp_path)
              assert block["entries"][0]["test_signal"] == "sibling_test_only"
      
      
      def test_hotspot_page_and_focus_agree_on_mirrored_and_flat_trees(tmp_path: Path) -> None:
          """Beyond co-location the hotspot page reads the same tree probe as the
          focus signal: a mirrored tests/ tree credits in both, and a flat-tree match
          on a bare name two hot files share credits neither."""
          for rel in ("src/pkg/view.py", "tests/src/pkg/test_view.py",
                      "a/mod.py", "b/mod.py", "tests/test_mod.py"):
              _touch(tmp_path, rel)
          hot = ["src/pkg/view.py", "a/mod.py", "b/mod.py"]
          shared = tc.shared_name_keys(hot)
          assert shared == frozenset({"mod.py"})
          block = compute_test_focus(hot, None, None, repo_root=tmp_path)
          focus = {e["path"]: e["test_signal"] == "sibling_test_only" for e in block["entries"]}
          for src in hot:
              assert assess_core._has_sibling_test(tmp_path, src, shared) is focus[src], src
          assert focus == {"src/pkg/view.py": True, "a/mod.py": False, "b/mod.py": False}
      
      
      def test_missing_source_is_unknown_not_credited(tmp_path: Path) -> None:
          """A stale path whose test outlived it: unknown on the hotspot page, no
          sibling for E2, unsupported in the focus block."""
          _touch(tmp_path, "src/gone.test.ts")
          assert assess_core._has_sibling_test(tmp_path, "src/gone.ts") is None
          assert ks._find_sibling_test(tmp_path, "src/gone.ts") is None
          block = compute_test_focus(["src/gone.ts"], None, None, repo_root=tmp_path)
          assert block["entries"][0]["test_signal"] == "unsupported"
      
      
      def test_sibling_test_names_cover_every_builder_and_hyphen_fold() -> None:
          names = tc.sibling_test_names("Foo-bar.java")
          for stem in ("Foo-bar", "Foo_bar"):
              assert f"{stem}Test.java" in names
              assert f"{stem}Tests.java" in names
              assert f"{stem}_spec.java" in names
              assert f"test_{stem}.java" in names
          assert len(names) == 2 * len(tc.TEST_SIBLING_BUILDERS)
      
      
      # Parallel test trees that neither co-locate nor mirror the source path: the
      # issue's fixture, the reported case where the test tree drops a directory, and
      # the Dart layout (``lib/`` stripped, tests under ``test/unit``).
      PARALLEL = {
          "app/functions/foo.js": "app/unit-tests/functions/foo.test.js",
          "app/functions/hmrc/hmrcVatReturnPost.js":
              "app/unit-tests/functions/hmrcVatReturnPost.test.js",
          "shop/lib/features/crm/repositories/customer_repository.dart":
              "shop/test/unit/customer_repository_test.dart",
      }
      
      
      def test_parallel_tree_basename_credits_the_three_layouts(tmp_path: Path) -> None:
          """A conventionally named test anywhere under a shared directory credits the
          source in the focus block and on the hotspot page, and enters the mutation
          scope; a source with no test anywhere stays unsupported."""
          for src, test in PARALLEL.items():
              _touch(tmp_path, src)
              _touch(tmp_path, test)
          _touch(tmp_path, "app/functions/bar.js")
          hot = [*PARALLEL, "app/functions/bar.js"]
          block = compute_test_focus(hot, None, None, repo_root=tmp_path)
          by_path = {e["path"]: e["test_signal"] for e in block["entries"]}
          assert by_path == {**{p: "sibling_test_only" for p in PARALLEL},
                             "app/functions/bar.js": "unsupported"}
          assert mutation_scope(block) == list(PARALLEL)
          for src in PARALLEL:
              assert tc.sibling_test_match(tmp_path, src) == tc.MATCH_BASENAME, src
              assert assess_core._has_sibling_test(tmp_path, src) is True, src
              assert ks._find_sibling_test(tmp_path, src) is None, src  # E2: co-located only
          assert assess_core._has_sibling_test(tmp_path, "app/functions/bar.js") is False
      
      
      def test_parallel_tree_basename_common_name_goes_to_the_closest_source(
          tmp_path: Path,
      ) -> None:
          """Two sources sharing a basename (``index.js``): the test credits the one
          source with the strictly closest common ancestor, and a tie credits none,
          across the whole repository rather than only the hot files."""
          for rel in ("web/pages/index.js", "api/index.js",
                      "web/unit-tests/pages/index.test.js",
                      "svc/a/util.js", "svc/b/util.js", "svc/unit-tests/util.test.js"):
              _touch(tmp_path, rel)
          assert assess_core._has_sibling_test(tmp_path, "web/pages/index.js") is True
          assert assess_core._has_sibling_test(tmp_path, "api/index.js") is False
          # svc/a and svc/b tie on svc/: the test cannot say which util.js it tests.
          assert assess_core._has_sibling_test(tmp_path, "svc/a/util.js") is False
          assert assess_core._has_sibling_test(tmp_path, "svc/b/util.js") is False
      
      
      def test_parallel_tree_basename_needs_a_shared_directory(tmp_path: Path) -> None:
          """A bare-name match whose only common ancestor is the repository root
          carries no path relationship, and a test under an excluded tree
          (``node_modules``, ``tests/fixtures``) is never evidence."""
          for rel in ("pkg/sub/deep/mod.py", "other/tests/test_mod.py",
                      "app/lib/widget.js", "app/node_modules/x/widget.test.js",
                      "tool/src/cfg.py", "tool/tests/fixtures/test_cfg.py"):
              _touch(tmp_path, rel)
          for src in ("pkg/sub/deep/mod.py", "app/lib/widget.js", "tool/src/cfg.py"):
              assert tc.sibling_test_match(tmp_path, src) is None, src
      
      
      def test_parallel_tree_basename_reads_git_index_when_present(tmp_path: Path) -> None:
          """In a git repository the index is the tracked file list: an untracked
          stray test file is not evidence, a committed one is."""
          import subprocess
      
          for rel in ("app/functions/foo.js", "app/unit-tests/functions/foo.test.js",
                      "app/functions/baz.js"):
              _touch(tmp_path, rel)
          git = ["git", "-C", str(tmp_path), "-c", "user.email=t@example.com",
                 "-c", "user.name=t"]
          subprocess.run([*git, "init", "-q"], check=True)
          subprocess.run([*git, "add", "-A"], check=True)
          subprocess.run([*git, "commit", "-q", "-m", "init"], check=True)
          _touch(tmp_path, "app/unit-tests/functions/baz.test.js")  # untracked
          index = tc.build_test_index(tmp_path)
          assert tc.has_sibling_test(tmp_path, "app/functions/foo.js", index=index) is True
          assert tc.has_sibling_test(tmp_path, "app/functions/baz.js", index=index) is False
      
      
      def test_parallel_tree_basename_truncated_index_credits_nothing(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """A walk cut off at the index cap can drop a rival source as readily as a
          test, so a truncated index is empty: it fails closed rather than credit a
          source on evidence the complete index calls a tie."""
          for rel in ("svc/a/util.js", "svc/a-tests/util.test.js", "svc/b/util.js"):
              _touch(tmp_path, rel)
          # Complete index: svc/a and svc/b tie on svc/, so neither is credited.
          assert tc.sibling_test_match(tmp_path, "svc/a/util.js") is None
          # Cap at two files: the walk keeps svc/a and the test but drops svc/b.
          monkeypatch.setattr(tc, "MAX_INDEX_FILES", 2)
          index = tc.build_test_index(tmp_path)
          assert index == tc.TestIndex()
          assert tc.sibling_test_match(tmp_path, "svc/a/util.js", index) is None
      
      
      def test_parallel_tree_basename_index_is_built_only_when_a_probe_reads_it(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """A coverage report that records every hot file never reaches the test-file
          probe, so the repository index is not built; a caller's index is reused."""
          from lib import test_focus as tf
      
          for rel in ("app/functions/foo.js", "app/unit-tests/functions/foo.test.js"):
              _touch(tmp_path, rel)
          built: list[Path] = []
          real = tf.build_test_index
          monkeypatch.setattr(tf, "build_test_index",
                              lambda root: built.append(root) or real(root))
          coverage = {"_overall": 0.9, "per_file": {"app/functions/foo.js": 0.9}}
          compute_test_focus(["app/functions/foo.js"], coverage, None, repo_root=tmp_path)
          assert built == []
          shared = tc.build_test_index(tmp_path)
          block = compute_test_focus(["app/functions/foo.js"], None, None,
                                     repo_root=tmp_path, index=shared)
          assert built == []
          assert block["entries"][0]["test_signal"] == "sibling_test_only"
          compute_test_focus(["app/functions/foo.js"], None, None, repo_root=tmp_path)
          assert built == [tmp_path]
      
      
      def test_parallel_tree_basename_skips_tracked_files_deleted_from_disk(
          tmp_path: Path,
      ) -> None:
          """A tracked file deleted from the working tree (deletion not yet staged)
          is not evidence: ``git ls-files`` still lists it, the index does not."""
          import subprocess
      
          for rel in ("app/functions/foo.js", "app/unit-tests/functions/foo.test.js"):
              _touch(tmp_path, rel)
          git = ["git", "-C", str(tmp_path), "-c", "user.email=t@example.com",
                 "-c", "user.name=t"]
          subprocess.run([*git, "init", "-q"], check=True)
          subprocess.run([*git, "add", "-A"], check=True)
          subprocess.run([*git, "commit", "-q", "-m", "init"], check=True)
          (tmp_path / "app/unit-tests/functions/foo.test.js").unlink()
          assert tc.build_test_index(tmp_path).tests_by_name == {}
          assert tc.sibling_test_match(tmp_path, "app/functions/foo.js") is None
      
      
      def test_parallel_tree_basename_unreadable_subtree_credits_nothing(
          tmp_path: Path,
      ) -> None:
          """A walk that cannot read a directory may have missed a rival source, so
          it yields an empty index, the same fail-closed rule as truncation."""
          import os
      
          for rel in ("svc/a/util.js", "svc/a-tests/util.test.js", "svc/b/util.js"):
              _touch(tmp_path, rel)
          locked = tmp_path / "svc/b"
          locked.chmod(0)
          try:
              if os.access(locked, os.R_OK):
                  import pytest
                  pytest.skip("running with privileges that ignore file modes")
              assert tc.build_test_index(tmp_path) == tc.TestIndex()
          finally:
              locked.chmod(0o755)
      
    • test_smoke.py 374 B
      """Smoke test: verify the test harness runs and lib is importable."""
      from __future__ import annotations
      
      from lib import __version__
      
      
      def test_lib_importable() -> None:
          assert __version__ == "0.1.0"
      
      
      def test_fixtures_dir_exists(fixtures_dir):
          # fixtures dir doesn't need contents yet; just verify the fixture works
          assert fixtures_dir.parent.name == "tests"
      
    • test_stats_diff.py 3 KB
      """Tests for stats sidecar diff."""
      from __future__ import annotations
      
      from pathlib import Path
      
      import pytest
      
      from lib.stats_diff import (
          diff_stats,
          hotspot_commits,
          load_stats,
      )
      
      
      def test_hotspot_commits_reads_commits_then_legacy_churn() -> None:
          """`commits` is the current field; `churn` is the legacy producer name a
          seeded/older prior snapshot may still carry (issue #47, observation 5)."""
          assert hotspot_commits({"commits": 12}) == 12
          assert hotspot_commits({"churn": 7}) == 7        # legacy fallback
          assert hotspot_commits({"commits": 3, "churn": 99}) == 3  # commits wins
          assert hotspot_commits({}) == 0
          assert hotspot_commits({"commits": None, "churn": None}) == 0
      
      
      @pytest.fixture
      def prior_stats(fixtures_dir: Path) -> dict:
          return load_stats(fixtures_dir / "prior_stats.json")
      
      
      @pytest.fixture
      def current_stats(fixtures_dir: Path) -> dict:
          return load_stats(fixtures_dir / "current_stats.json")
      
      
      def test_load_stats_returns_dict(fixtures_dir: Path) -> None:
          stats = load_stats(fixtures_dir / "prior_stats.json")
          assert stats["files_scored"] == 100
      
      
      def test_load_stats_missing_returns_none(tmp_path: Path) -> None:
          assert load_stats(tmp_path / "nope.json") is None
      
      
      def test_diff_identifies_graduated(prior_stats: dict, current_stats: dict) -> None:
          diff = diff_stats(prior=prior_stats, current=current_stats)
          graduated_paths = {h.path for h in diff.graduated}
          assert "src/legacy/parser.go" in graduated_paths
      
      
      def test_diff_identifies_regressed(prior_stats: dict, current_stats: dict) -> None:
          diff = diff_stats(prior=prior_stats, current=current_stats)
          regressed_paths = {h.path for h in diff.regressed}
          assert "src/api/handler.go" in regressed_paths
          # Regression must capture the delta
          handler = next(h for h in diff.regressed if h.path == "src/api/handler.go")
          assert handler.ccn_delta == 4  # 32 - 28
          assert handler.commits_delta == 7  # 15 - 8
      
      
      def test_diff_identifies_new(prior_stats: dict, current_stats: dict) -> None:
          diff = diff_stats(prior=prior_stats, current=current_stats)
          new_paths = {h.path for h in diff.new}
          assert "src/new/feature.go" in new_paths
      
      
      def test_diff_identifies_persistent(prior_stats: dict, current_stats: dict) -> None:
          diff = diff_stats(prior=prior_stats, current=current_stats)
          persistent_paths = {h.path for h in diff.persistent}
          assert "src/util/helpers.go" in persistent_paths
      
      
      def test_diff_no_prior_means_all_new(current_stats: dict) -> None:
          diff = diff_stats(prior=None, current=current_stats)
          assert len(diff.graduated) == 0
          assert len(diff.regressed) == 0
          assert len(diff.persistent) == 0
          assert len(diff.new) == len(current_stats["top_hotspots"])
      
      
      def test_diff_summary_counts(prior_stats: dict, current_stats: dict) -> None:
          diff = diff_stats(prior=prior_stats, current=current_stats)
          summary = diff.summary()
          assert summary["graduated"] == 1
          assert summary["regressed"] == 1
          assert summary["new"] == 1
          assert summary["persistent"] == 1
      
    • test_structure_drift.py 35.2 KB
      """Contract suite for the Tier 0 path-existence structure-drift signal.
      
      Tier 0 is the zero-threshold cut: a declared ownership pattern (a CODEOWNERS glob
      or an ARCHITECTURE.md path reference) that matches *zero* tracked files on disk -
      a boundary the filesystem has left behind. These tests pin the enumerate-both-
      sides behaviour, the two contracts the signal inherits from the parser
      (deterministic byte-identical output, honest degradation when no map exists), and
      the excluded-only-matches-as-empty rule.
      
      Fixtures build small repos in ``tmp_path``; resolution is against the *tracked*
      file set, so they commit their files. Ambient git config is neutralised
      process-wide by the package ``conftest.py``.
      """
      from __future__ import annotations
      
      import json
      import os
      import subprocess
      from pathlib import Path
      
      from lib import ownership_parser, structure_drift
      from lib import structure_graph as sg
      from lib.structure_drift import (
          SEAM_ALLOWLIST,
          apply_seam_allowlist,
          cochange_grouping_relation,
          compute_grouping_disagreement,
          detect_grouping_disagreement,
          detect_path_existence_drift,
          human_grouping_relation,
          static_grouping_relation,
      )
      
      FIXTURES = Path(__file__).parent / "fixtures" / "structure_drift"
      
      
      def _copy_fixture(repo: Path, name: str, dest: str) -> None:
          """Copy a structure_drift fixture into a repo at a given relative path."""
          target = repo / dest
          target.parent.mkdir(parents=True, exist_ok=True)
          target.write_text((FIXTURES / name).read_text(encoding="utf-8"),
                            encoding="utf-8")
      
      
      def _git(repo: Path, *args: str) -> None:
          subprocess.run(["git", "-C", str(repo), *args],
                         check=True, capture_output=True, text=True,
                         env={**os.environ})
      
      
      def _init_repo(tmp_path: Path) -> Path:
          repo = tmp_path / "repo"
          repo.mkdir()
          _git(repo, "init", "-q")
          _git(repo, "config", "user.email", "dev@example.com")
          _git(repo, "config", "user.name", "Dev")
          return repo
      
      
      def _write(repo: Path, rel: str, text: str = "x\n") -> None:
          p = repo / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      def _commit_all(repo: Path, message: str = "c") -> None:
          _git(repo, "add", "-A")
          _git(repo, "commit", "-q", "-m", message)
      
      
      def _patterns(result: dict) -> list[str]:
          return [e["pattern"] for e in result["empty_ownership_patterns"]]
      
      
      # --- 1. CODEOWNERS empty-glob detection --------------------------------------
      
      def test_empty_codeowners_glob_is_flagged(tmp_path: Path) -> None:
          """A CODEOWNERS glob matching no tracked file surfaces as drift.
      
          ``*.py`` matches the committed file (no drift); ``legacy/**`` matches nothing
          (the directory is gone) and is the sole empty pattern, attributed to
          ``CODEOWNERS``.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "a.py")
          _write(repo, "CODEOWNERS", "*.py @a\nlegacy/** @b\n")
          _commit_all(repo)
      
          result = detect_path_existence_drift(repo)
          assert result["available"] is True
          assert result["tier_0_available"] is True
          assert result["empty_ownership_patterns"] == [
              {"pattern": "legacy/**", "declared_in": "CODEOWNERS", "owners": []}
          ]
      
      
      def test_all_valid_codeowners_yields_no_findings(tmp_path: Path) -> None:
          """When every glob matches at least one file, there is no drift.
      
          The map is still available; the empty list is empty and coverage is full.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "docs/guide.md")
          _write(repo, "CODEOWNERS", "src/** @a\ndocs/ @b\n")
          _commit_all(repo)
      
          result = detect_path_existence_drift(repo)
          assert result["available"] is True
          assert result["empty_ownership_patterns"] == []
          assert result["total_patterns"] == 2
          assert result["matched_patterns"] == 2
          assert result["coverage_ratio"] == 1.0
      
      
      def test_mixed_state_reports_only_the_empty_pattern(tmp_path: Path) -> None:
          """A repo with both live and stale globs reports only the stale one.
      
          Coverage reflects the split: two of three declared patterns match.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "docs/x.md")
          _write(repo, "CODEOWNERS", "src/** @a\ndocs/ @b\nghost/** @c\n")
          _commit_all(repo)
      
          result = detect_path_existence_drift(repo)
          assert _patterns(result) == ["ghost/**"]
          assert result["total_patterns"] == 3
          assert result["matched_patterns"] == 2
          assert result["coverage_ratio"] == 0.667
      
      
      # --- 2. ARCHITECTURE.md stale-reference detection ----------------------------
      
      def test_architecture_stale_module_ref_to_deleted_dir(tmp_path: Path) -> None:
          """A stale ARCHITECTURE.md path reference to a missing dir surfaces as drift.
      
          The doc declares two boundaries: a live one owning ``src/api/`` (matches) and
          a stale one owning ``src/legacy/`` (deleted, matches nothing). Only the stale
          reference is flagged, attributed to its ``<doc>::<header>`` boundary.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/api/server.py")
          _write(repo, "ARCHITECTURE.md", "\n".join([
              "## API layer",
              "The API module owns `src/api/`.",
              "",
              "## Legacy",
              "The legacy module owns `src/legacy/`.",
          ]) + "\n")
          _commit_all(repo)
      
          # The parser normalises a reference's trailing punctuation, so the prose
          # ``src/legacy/`` is captured as ``src/legacy`` - that normalised form is the
          # reported pattern.
          result = detect_path_existence_drift(repo)
          assert result["empty_ownership_patterns"] == [
              {"pattern": "src/legacy", "declared_in": "ARCHITECTURE.md::Legacy",
               "owners": []}
          ]
      
      
      def test_codeowners_and_architecture_empties_merge_and_sort(tmp_path: Path) -> None:
          """Empties from both sources merge into one list sorted by (pattern, source).
      
          A stale CODEOWNERS glob and a stale architecture reference both appear,
          ordered by pattern then declaring source - the deterministic merge key.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "CODEOWNERS", "src/** @a\nzzz/** @b\n")
          _write(repo, "ARCHITECTURE.md", "\n".join([
              "## Core",
              "owns `src/`",
              "## Ghost",
              "owns `aaa/gone/`",
          ]) + "\n")
          _commit_all(repo)
      
          # Architecture prose ``aaa/gone/`` normalises to ``aaa/gone``; the merge then
          # sorts the two empties by (pattern, source).
          result = detect_path_existence_drift(repo)
          assert _patterns(result) == ["aaa/gone", "zzz/**"]
      
      
      # --- 3. Excluded-only patterns count as empty --------------------------------
      
      def test_pattern_matching_only_excluded_files_is_empty(tmp_path: Path) -> None:
          """A glob whose only matches sit under an excluded dir reports as drift.
      
          ``node_modules`` is a built-in exclude, so a ``node_modules/**`` glob resolves
          to an empty set even though files exist there - the excluded tree is not part
          of the navigable repo a contributor reasons over.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "node_modules/dep/index.js")
          _write(repo, "CODEOWNERS", "src/** @a\nnode_modules/** @vendor\n")
          _commit_all(repo)
      
          result = detect_path_existence_drift(repo)
          assert _patterns(result) == ["node_modules/**"]
      
      
      # --- 4. Graceful degradation -------------------------------------------------
      
      def test_no_ownership_map_degrades(tmp_path: Path) -> None:
          """A repo with no CODEOWNERS and no boundary doc reports no ownership map."""
          repo = _init_repo(tmp_path)
          _write(repo, "a.py")
          _commit_all(repo)
      
          result = detect_path_existence_drift(repo)
          assert result["available"] is False
          assert result["reason"] == "no ownership map"
          assert result["tier_0_available"] is False
          assert result["empty_ownership_patterns"] == []
          assert result["total_patterns"] == 0
          assert result["coverage_ratio"] == 0.0
      
      
      # --- 5. Determinism ----------------------------------------------------------
      
      def test_output_is_byte_identical_across_runs(tmp_path: Path) -> None:
          """Two runs over one repo serialize to identical output.
      
          Sets are sorted at the boundary, so no iteration order leaks. Serialising the
          full block twice and asserting equality pins the determinism contract.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "src/b.py")
          _write(repo, "CODEOWNERS", "src/** @a\nghost/** @b\nz/** @c\n")
          _write(repo, "ARCHITECTURE.md", "## Core\nowns `src/` and `dead/`\n")
          _commit_all(repo)
      
          first = json.dumps(detect_path_existence_drift(repo), sort_keys=True)
          second = json.dumps(detect_path_existence_drift(repo), sort_keys=True)
          assert first == second
      
      
      # --- 6. Integration: this repo's own seam map --------------------------------
      
      def test_integration_lib_readme_seam_paths_are_not_false_positives() -> None:
          """This repo's lib README seam declaration does not false-positive.
      
          ``skills/assess/scripts/lib/README.md`` names ``doc_graph.py`` and the
          ``skills/assess/...`` seam directories as load-bearing boundaries; they all
          resolve to real tracked paths, so none of them appears in
          ``empty_ownership_patterns``. This is the dogfood guard that Tier 0 reads a
          genuine, human-written ownership map on real data without manufacturing drift.
          """
          repo_root = Path(__file__).resolve().parents[3]  # repo top
          result = detect_path_existence_drift(repo_root)
      
          assert result["available"] is True
          seam_doc = "skills/assess/scripts/lib/README.md"
          offenders = [
              e for e in result["empty_ownership_patterns"]
              if e["declared_in"].startswith(seam_doc + "::")
              and "doc_graph.py" in e["pattern"]
          ]
          assert offenders == [], offenders
      
      
      # =====================================================================
      # Tier 1 - equivalence-relation grouping disagreement
      # =====================================================================
      
      def _p(name: str) -> Path:
          return Path(name)
      
      
      # --- 7. Relation construction from a grouping --------------------------------
      
      def test_human_relation_emits_all_same_group_pairs() -> None:
          """A boundary owning n files contributes its n*(n-1)/2 canonical pairs.
      
          Two boundaries each owning two files yield two intra-group pairs; the cross-
          boundary pairs are absent (different groups), and every pair is canonical
          (``file_a < file_b``).
          """
          ownership = {
              "A": {_p("f1.py"), _p("f2.py")},
              "B": {_p("f3.py"), _p("f4.py")},
          }
          rel = human_grouping_relation(ownership)
          assert rel == {
              (_p("f1.py"), _p("f2.py")),
              (_p("f3.py"), _p("f4.py")),
          }
      
      
      def test_singleton_group_contributes_no_pair() -> None:
          """A boundary owning one file declares no co-membership, so adds no pair."""
          assert human_grouping_relation({"solo": {_p("only.py")}}) == set()
      
      
      def test_cochange_relation_filters_by_support_threshold() -> None:
          """Only co-change pairs at or above the support threshold enter the relation.
      
          The pair below ``threshold_pct`` is dropped; the survivor is canonicalised
          regardless of the order ``change_coupling`` listed its files.
          """
          pairs = [
              {"file_a": "b.py", "file_b": "a.py", "co_change_count": 9, "support_pct": 12.0},
              {"file_a": "c.py", "file_b": "d.py", "co_change_count": 2, "support_pct": 1.0},
          ]
          rel = cochange_grouping_relation(pairs, threshold_pct=5.0)
          assert rel == {(_p("a.py"), _p("b.py"))}
      
      
      # --- 8. Split-vs-fuse disagreement -------------------------------------------
      
      def test_split_and_fuse_are_directional_set_differences() -> None:
          """human-static and static-human capture the two disagreement directions.
      
          ``human`` groups (f1,f2); ``static`` instead groups (f2,f3). The pair the
          human declares but static splits is (f1,f2); the pair static fuses but the
          human splits is (f2,f3); they share no agreement.
          """
          human = {(_p("f1"), _p("f2"))}
          static = {(_p("f2"), _p("f3"))}
          d = compute_grouping_disagreement(human, static, cochange_rel=set())
          assert d["human_grouped_static_splits"] == [{"file_a": "f1", "file_b": "f2"}]
          assert d["human_grouped_static_splits_count"] == 1
          assert d["human_split_static_fuses"] == [{"file_a": "f2", "file_b": "f3"}]
          assert d["human_static_agree"] == []
      
      
      def test_agreement_is_the_intersection() -> None:
          """A pair both lenses group lands in the agree set, not either difference."""
          human = {(_p("f1"), _p("f2")), (_p("f3"), _p("f4"))}
          static = {(_p("f1"), _p("f2"))}
          d = compute_grouping_disagreement(human, static, cochange_rel=set())
          assert d["human_static_agree"] == [{"file_a": "f1", "file_b": "f2"}]
          assert d["human_grouped_static_splits"] == [{"file_a": "f3", "file_b": "f4"}]
      
      
      # --- 9. THE label-permutation invariance test (the hard gate) ----------------
      
      def test_disagreement_is_invariant_to_community_relabeling() -> None:
          """Relabeling / reordering the communities must not change any metric.
      
          Communities ``[A:{f1,f2}, B:{f3,f4}]`` and the relabelled, reordered
          ``[X:{f3,f4}, Y:{f1,f2}]`` are the SAME partition - same co-membership
          relation. The disagreement against a fixed human grouping must be byte-
          identical. This is the correctness property of the whole tier: the metric
          carries pairs, never community labels.
          """
          human = {(_p("f1"), _p("f2"))}
      
          static_a = {(_p("f1"), _p("f2")), (_p("f3"), _p("f4"))}
          # Same partition, communities swapped and relabelled - identical relation.
          static_b = {(_p("f3"), _p("f4")), (_p("f1"), _p("f2"))}
      
          d_a = compute_grouping_disagreement(human, static_a, cochange_rel=set())
          d_b = compute_grouping_disagreement(human, static_b, cochange_rel=set())
          assert json.dumps(d_a, sort_keys=True) == json.dumps(d_b, sort_keys=True)
      
      
      def test_static_relation_invariant_to_community_order(tmp_path: Path) -> None:
          """``static_grouping_relation`` ignores community order and labels.
      
          Building the relation from communities in one order and from the reversed
          list yields the identical pair set - the relation never records which
          community a pair came from. Uses bare module names that resolve to no file,
          so the relation is exercised purely as set math (empty here), the invariance
          holding trivially and by construction.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "a.py")
          _commit_all(repo)
          comms = [{"lib.x", "lib.y"}, {"lib.z", "lib.w"}]
          forward = static_grouping_relation(repo, comms)
          reverse = static_grouping_relation(repo, list(reversed(comms)))
          assert forward == reverse
      
      
      # --- 10. Seam allowlist ------------------------------------------------------
      
      def test_seam_allowlist_drops_the_allowlisted_pair() -> None:
          """A pair straddling an allowlisted seam is subtracted from the denominator.
      
          The lib<->tests seam pair is removed from the disagreement list and its count
          drops to zero; a genuine off-seam disagreement is untouched.
          """
          seam_pair = {
              "file_a": "skills/assess/scripts/lib/foo.py",
              "file_b": "skills/assess/tests/test_foo.py",
          }
          off_seam = {"file_a": "src/a.py", "file_b": "src/b.py"}
          disagreement = {
              "human_grouped_never_cochange": [seam_pair, off_seam],
              "human_grouped_never_cochange_count": 2,
          }
          filtered = apply_seam_allowlist(disagreement)
          assert filtered["human_grouped_never_cochange"] == [off_seam]
          assert filtered["human_grouped_never_cochange_count"] == 1
      
      
      def test_seam_allowlist_only_removes_never_adds() -> None:
          """An allowlist can only shrink a list (correct-by-construction denominator)."""
          disagreement = {
              "human_split_but_cochange": [{"file_a": "x/a.py", "file_b": "y/b.py"}],
              "human_split_but_cochange_count": 1,
          }
          filtered = apply_seam_allowlist(disagreement)
          assert len(filtered["human_split_but_cochange"]) <= 1
      
      
      # --- 11. Top-level callable: degradation + determinism -----------------------
      
      def test_tier1_degrades_with_no_ownership_map(tmp_path: Path) -> None:
          """No CODEOWNERS and no boundary doc -> available:False, all lists empty."""
          repo = _init_repo(tmp_path)
          _write(repo, "a.py")
          _commit_all(repo)
      
          result = detect_grouping_disagreement(repo)
          assert result["available"] is False
          assert result["reason"] == "no ownership map"
          assert result["tier_1_available"] is False
          assert result["human_grouped_static_splits"] == []
          assert result["human_grouped_static_splits_count"] == 0
      
      
      def test_tier1_is_byte_identical_across_runs(tmp_path: Path) -> None:
          """Two runs over one repo serialize identically (no set order leaks)."""
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "src/b.py")
          _write(repo, "CODEOWNERS", "src/** @a\n")
          _commit_all(repo)
      
          first = json.dumps(
              detect_grouping_disagreement(repo, communities=[], coupling_pairs=[]),
              sort_keys=True,
          )
          second = json.dumps(
              detect_grouping_disagreement(repo, communities=[], coupling_pairs=[]),
              sort_keys=True,
          )
          assert first == second
      
      
      def test_tier1_human_only_groups_codeowners_files(tmp_path: Path) -> None:
          """With no static / co-change lens, a multi-file boundary self-disagrees.
      
          A CODEOWNERS glob grouping two files declares them co-grouped; with empty
          static and co-change relations, that pair is ``human_grouped_static_splits``
          AND ``human_grouped_never_cochange`` (no lens corroborates it) and never
          ``agree``.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "pkg/a.py")
          _write(repo, "pkg/b.py")
          _write(repo, "CODEOWNERS", "pkg/** @team\n")
          _commit_all(repo)
      
          result = detect_grouping_disagreement(repo, communities=[], coupling_pairs=[])
          assert result["available"] is True
          pair = {"file_a": "pkg/a.py", "file_b": "pkg/b.py"}
          assert pair in result["human_grouped_static_splits"]
          assert pair in result["human_grouped_never_cochange"]
          assert result["human_static_agree"] == []
      
      
      # --- 12. Integration: this repo, zero false positives after allowlist --------
      
      def test_tier1_integration_no_false_positives_after_allowlist() -> None:
          """On this repo the documented seams don't surface as Tier 1 disagreement.
      
          The lib README declares the ``skills/assess/scripts/lib`` <-> ``skills/assess/
          tests`` seam; any co-change pair straddling it must be absorbed by the seam
          allowlist, never reported as ``human_split_but_cochange``. This is the dogfood
          guard that the allowlist matches the README's stated seams.
          """
          repo_root = Path(__file__).resolve().parents[3]
          result = detect_grouping_disagreement(repo_root)
          assert result["available"] in (True, False)
          if not result["available"]:
              return
          for row in result["human_split_but_cochange"]:
              a, b = row["file_a"], row["file_b"]
              lib_test_seam = (
                  (a.startswith("skills/assess/scripts/lib")
                   and b.startswith("skills/assess/tests"))
                  or (b.startswith("skills/assess/scripts/lib")
                      and a.startswith("skills/assess/tests"))
              )
              assert not lib_test_seam, row
      
      
      # =====================================================================
      # 13. Tier 0 determinism, isolated from the rest of the block
      # =====================================================================
      #
      # Tasks 9/10 pin determinism of the *serialised block* (tests 5 and 14).
      # These isolate the three primitives that feed it: the CODEOWNERS parse, the
      # empty-set ordering, and the filesystem walk - each independently reproducible
      # so a regression in any one is localised rather than read off the merged block.
      
      def test_codeowners_parse_is_identical_across_two_reads(tmp_path: Path) -> None:
          """Parsing one CODEOWNERS twice yields identical glob->match maps.
      
          ``parse_codeowners`` resolves each glob against the tracked file set; the
          same repo must produce the same map on every call (no walk-order or
          set-iteration nondeterminism leaks into the resolved paths).
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "src/b.py")
          _copy_fixture(repo, "codeowners_mixed", "CODEOWNERS")
          _commit_all(repo)
      
          first = ownership_parser.parse_codeowners(repo)
          second = ownership_parser.parse_codeowners(repo)
          # Compare as sorted POSIX strings so the dict-of-sets equality is on the
          # resolved relation, not on set object identity.
          norm = {p: sorted(f.as_posix() for f in files)
                  for p, files in first.items()}
          norm2 = {p: sorted(f.as_posix() for f in files)
                   for p, files in second.items()}
          assert norm == norm2
      
      
      def test_empty_patterns_emit_in_sorted_order_regardless_of_declaration(
          tmp_path: Path,
      ) -> None:
          """Empty patterns sort by (pattern, source), independent of file order.
      
          Three stale globs are declared in reverse-alphabetical order in the file;
          the reported list is sorted ascending - the merge key, not the line order,
          fixes the output sequence.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "CODEOWNERS",
                 "src/** @keep\nzzz/** @c\nmmm/** @b\naaa/** @a\n")
          _commit_all(repo)
      
          result = detect_path_existence_drift(repo)
          assert _patterns(result) == ["aaa/**", "mmm/**", "zzz/**"]
      
      
      def test_filesystem_walk_match_set_is_reproducible(tmp_path: Path) -> None:
          """A glob's match set is the sorted tracked files, stable across runs.
      
          The drift signal resolves ``src/**`` against the git ls-files set; two runs
          must report the same coverage counts, pinning the walk's reproducibility.
          """
          repo = _init_repo(tmp_path)
          for name in ("d.py", "a.py", "c.py", "b.py"):
              _write(repo, f"src/{name}")
          _write(repo, "CODEOWNERS", "src/** @team\n")
          _commit_all(repo)
      
          first = detect_path_existence_drift(repo)
          second = detect_path_existence_drift(repo)
          assert first["matched_patterns"] == second["matched_patterns"] == 1
          assert first["empty_ownership_patterns"] == []
          assert first == second
      
      
      # =====================================================================
      # 14. Label-permutation invariance - the reorder + relabel variant
      # =====================================================================
      #
      # THE critical test. Task 10 added relation-relabel invariance (test 9) over
      # pre-built relations and the static-relation order invariance (test 10). This
      # pins the contract end-to-end at the level the requirement states it: two
      # *static communities* expressing the SAME partition - once swapped X<->Y and
      # once with the community LIST reversed - must yield byte-identical disagreement
      # against a fixed human grouping. The metric keys on the equivalence relation,
      # not the partition labels.
      
      def test_disagreement_invariant_to_community_swap_and_reorder() -> None:
          """THE critical test - metric keys on the equivalence relation, not labels.
      
          Human groups A={f1,f2}, B={f3,f4}. Static communities X={f1,f2}, Y={f3,f4}.
          The disagreement against the human grouping is computed three ways that all
          describe the SAME partition: (a) the baseline, (b) the two communities
          relabelled and swapped (X<->Y), and (c) the community list reversed. All
          three must serialise byte-identically - any label or order dependence would
          break here and nowhere else.
          """
          f1, f2, f3, f4 = (_p("f1.py"), _p("f2.py"), _p("f3.py"), _p("f4.py"))
          human = human_grouping_relation({"A": {f1, f2}, "B": {f3, f4}})
      
          # Relation built from communities listed [X, Y].
          static_xy = (structure_drift._pairs_within({f1, f2})
                       | structure_drift._pairs_within({f3, f4}))
          # Same partition, communities swapped/relabelled [Y, X].
          static_yx = (structure_drift._pairs_within({f3, f4})
                       | structure_drift._pairs_within({f1, f2}))
      
          base = compute_grouping_disagreement(human, static_xy, cochange_rel=set())
          swapped = compute_grouping_disagreement(human, static_yx, cochange_rel=set())
      
          base_json = json.dumps(base, sort_keys=True)
          assert base_json == json.dumps(swapped, sort_keys=True)
          # And the disagreement is the expected, label-free content: the two human
          # boundaries are exactly the two static communities, so everything agrees
          # and nothing splits.
          assert base["human_grouped_static_splits"] == []
          assert base["human_split_static_fuses"] == []
          assert base["human_static_agree_count"] == 2
      
      
      # =====================================================================
      # 15. Seam allowlist - the denominator arithmetic, pinned exactly
      # =====================================================================
      
      def test_seam_allowlist_subtracts_from_denominator_not_the_ratio() -> None:
          """Allowlisted pairs leave the denominator BEFORE any ratio is taken.
      
          Pins the requirement's worked example: of 100 declared-grouping pairs, 47
          straddle an allowlisted seam and 10 of the remaining 53 are genuine
          disagreements. The honest drift ratio is 10 / (100 - 47) = 10/53, never
          10/100 - the allowlist shrinks the denominator, it does not divide into the
          raw total. Here the disagreement list is the 10 off-seam pairs plus the 47
          seam pairs; after the allowlist only the 10 survive, so the count is 10 and
          a caller dividing by the surviving universe gets 10/53.
          """
          seam = SEAM_ALLOWLIST[0]  # skills/assess/scripts/lib <-> skills/assess/tests
          lo, hi = seam
          # 47 pairs that straddle the allowlisted seam.
          seam_pairs = [
              {"file_a": f"{lo}/mod{i}.py", "file_b": f"{hi}/test_mod{i}.py"}
              for i in range(47)
          ]
          # 10 genuine off-seam disagreements.
          off_seam = [
              {"file_a": f"src/a{i}.py", "file_b": f"src/b{i}.py"} for i in range(10)
          ]
          disagreement = {
              "human_grouped_never_cochange": seam_pairs + off_seam,
              "human_grouped_never_cochange_count": 57,
          }
          filtered = apply_seam_allowlist(disagreement)
          survivors = filtered["human_grouped_never_cochange"]
      
          assert filtered["human_grouped_never_cochange_count"] == 10
          assert {(r["file_a"], r["file_b"]) for r in survivors} == {
              (r["file_a"], r["file_b"]) for r in off_seam
          }
          # The honest ratio the orchestrator would form: 10 over the post-allowlist
          # denominator (100 - 47 = 53), not over the raw 100.
          total_pairs, allowlisted = 100, 47
          denominator = total_pairs - allowlisted
          assert denominator == 53
          assert filtered["human_grouped_never_cochange_count"] / denominator == 10 / 53
      
      
      def test_seam_allowlist_filters_agreement_lists_too(tmp_path: Path) -> None:
          """A seam pair is not double-counted as agreement after suppression.
      
          ``apply_seam_allowlist`` filters every list, the ``*_agree`` sets included,
          so a pair removed from a disagreement list cannot reappear as agreement.
          """
          seam = SEAM_ALLOWLIST[0]
          lo, hi = seam
          seam_pair = {"file_a": f"{lo}/x.py", "file_b": f"{hi}/test_x.py"}
          disagreement = {
              "human_static_agree": [seam_pair],
              "human_static_agree_count": 1,
          }
          filtered = apply_seam_allowlist(disagreement)
          assert filtered["human_static_agree"] == []
          assert filtered["human_static_agree_count"] == 0
      
      
      def test_seam_allowlist_respects_both_orderings_of_a_pair() -> None:
          """A seam matches whichever side each tree lands on in the canonical pair.
      
          The ``scripts`` <-> ``skills`` seam must absorb a pair regardless of which
          of the two trees sorts first into ``file_a``.
          """
          # scripts sorts before skills, so the canonical pair has scripts as file_a;
          # assert the allowlist still matches when the lib seam's order is reversed.
          pair = {"file_a": "scripts/build.py", "file_b": "skills/assess/SKILL.md"}
          out = apply_seam_allowlist({"human_split_but_cochange": [pair],
                                      "human_split_but_cochange_count": 1})
          assert out["human_split_but_cochange"] == []
      
      
      # =====================================================================
      # 16. Graceful degradation - the remaining honest-degrade branches
      # =====================================================================
      
      def test_empty_codeowners_file_still_degrades(tmp_path: Path) -> None:
          """A CODEOWNERS with only comments/blank lines is no ownership map.
      
          The file exists but declares zero globs and no boundary doc accompanies it,
          so there is nothing to drift against - Tier 0 degrades to available:False.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "a.py")
          _copy_fixture(repo, "codeowners_empty", "CODEOWNERS")
          _commit_all(repo)
      
          result = detect_path_existence_drift(repo)
          assert result["available"] is False
          assert result["reason"] == "no ownership map"
      
      
      def test_unreadable_architecture_doc_is_skipped_without_crashing(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """A doc that fails to read is skipped; the rest of the scan still runs.
      
          The honest-degrade contract: an OSError on one architecture doc warns and
          is dropped, never aborting the assessment. A live CODEOWNERS glob alongside
          it still resolves, so the signal stays available.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "CODEOWNERS", "src/** @team\n")
          _copy_fixture(repo, "ARCHITECTURE_modules.md", "ARCHITECTURE.md")
          _commit_all(repo)
      
          real_read_text = Path.read_text
      
          def boom(self, *args, **kwargs):
              if self.name == "ARCHITECTURE.md":
                  raise OSError("simulated unreadable doc")
              return real_read_text(self, *args, **kwargs)
      
          monkeypatch.setattr(Path, "read_text", boom)
      
          # The CODEOWNERS side still parses, so the block is available; the doc's
          # stale ``src/legacy`` reference never surfaces because the doc was skipped.
          # structure_drift's own OSError guard degrades silently (it never aborts the
          # scan), so no exception escapes - the contract is "skip, don't crash".
          result = detect_path_existence_drift(repo)
          assert result["available"] is True
          assert all("ARCHITECTURE.md" not in e["declared_in"]
                     for e in result["empty_ownership_patterns"])
      
      
      def test_malformed_codeowners_line_partial_parses_with_warning(
          tmp_path: Path, capsys,
      ) -> None:
          """A blank/comment-only line is skipped; valid globs still parse.
      
          CODEOWNERS tolerates noise: comment lines and blank lines are ignored while
          the genuine globs around them resolve. The parse is partial, never aborted -
          a live glob matches, a stale one is still flagged.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.py")
          _write(repo, "CODEOWNERS", "\n".join([
              "# ownership",
              "",
              "src/** @team",
              "   ",
              "gone/** @nobody",
          ]) + "\n")
          _commit_all(repo)
      
          result = detect_path_existence_drift(repo)
          assert result["available"] is True
          assert _patterns(result) == ["gone/**"]
          assert result["matched_patterns"] == 1
      
      
      def test_tier1_runs_without_a_static_graph(tmp_path: Path) -> None:
          """No importable package -> Tier 1 static lens empty, but the tier still runs.
      
          With an ownership map but no Python package, ``_compute_communities`` returns
          no communities (line 687), so the static relation is empty. Tier 1 is still
          available and reports the human grouping's self-disagreement - Tier 0's
          existence test and Tier 1's grouping test both degrade honestly, never crash.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "src/a.txt")  # no .py, so discover_packages finds nothing
          _write(repo, "src/b.txt")
          _write(repo, "CODEOWNERS", "src/** @team\n")
          _commit_all(repo)
      
          # coupling_pairs omitted too, so both non-human lenses are empty.
          result = detect_grouping_disagreement(repo, coupling_pairs=[])
          assert result["available"] is True
          assert result["tier_1_available"] is True
          # The two .txt files are co-owned but no static community backs them.
          pair = {"file_a": "src/a.txt", "file_b": "src/b.txt"}
          assert pair in result["human_grouped_static_splits"]
          assert result["human_static_agree"] == []
      
      
      def test_compute_communities_degrades_when_networkx_missing(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """No networkx -> the static lens is empty, Tier 1 still available.
      
          Patching ``structure_graph._NETWORKX_AVAILABLE`` False makes
          ``_compute_communities`` short-circuit to ``[]`` (line 683); the standalone
          Tier 1 call then sees an empty static relation and still returns available.
          """
          monkeypatch.setattr(sg, "_NETWORKX_AVAILABLE", False)
          repo = _init_repo(tmp_path)
          _write(repo, "pkg/__init__.py")
          _write(repo, "pkg/a.py", "x = 1\n")
          _write(repo, "pkg/b.py", "y = 2\n")
          _write(repo, "CODEOWNERS", "pkg/** @team\n")
          _commit_all(repo)
      
          # communities omitted -> computed internally, but networkx is "missing".
          result = detect_grouping_disagreement(repo, coupling_pairs=[])
          assert result["available"] is True
          assert result["tier_1_available"] is True
          # No static community formed, so the human pair is a split, not agreement.
          assert result["human_static_agree"] == []
      
      
      def test_build_module_path_map_resolves_real_package_modules() -> None:
          """The module->path map resolves this repo's own package to tracked files.
      
          Exercises the happy path of ``_build_module_path_map`` against a real grimp
          graph: every mapped module's value is a repo-relative ``.py`` source file
          under the package, the exact paths the human and co-change relations speak.
          """
          repo_root = Path(__file__).resolve().parents[3]
          mapping = structure_drift._build_module_path_map(repo_root)
          assert mapping  # the assess package resolves
          for module, rel in mapping.items():
              assert not rel.is_absolute()
              assert rel.suffix in (".py", "")  # a module file or package __init__
              assert (repo_root / rel).exists()
      
      
      def test_build_module_path_map_drops_unresolvable_and_out_of_tree(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """Modules with no source file or one outside the repo root are dropped.
      
          Stubs grimp's graph with three modules: one that resolves to a tracked file
          (kept), one ``_module_file`` cannot resolve (``src is None`` -> skipped), and
          one whose source resolves outside ``repo_root`` (``relative_to`` ValueError
          -> skipped). The map keeps only the in-tree resolvable module - the two
          defensive guards drop the rest rather than crashing.
          """
          repo = _init_repo(tmp_path)
          _write(repo, "pkg/__init__.py")
          _write(repo, "pkg/a.py", "x = 1\n")
          _commit_all(repo)
          repo = repo.resolve()
      
          inside = repo / "pkg" / "a.py"
          outside = (tmp_path.parent / "elsewhere" / "z.py")
      
          class _StubGraph:
              modules = ["pkg.a", "pkg.unresolved", "pkg.external"]
      
          def fake_discover(_root):
              return [repo / "pkg"]
      
          def fake_build(_dirs):
              return _StubGraph(), {}, {}
      
          def fake_module_file(module, _roots):
              if module == "pkg.a":
                  return inside
              if module == "pkg.external":
                  return outside  # resolves, but outside repo_root
              return None  # pkg.unresolved -> src is None
      
          monkeypatch.setattr(sg, "discover_packages", fake_discover)
          monkeypatch.setattr(sg, "_build_grimp_graph", fake_build)
          monkeypatch.setattr(sg, "_module_file", fake_module_file)
      
          mapping = structure_drift._build_module_path_map(repo)
          assert mapping == {"pkg.a": Path("pkg/a.py")}
      
      
      def test_static_relation_empty_when_module_map_unavailable(
          tmp_path: Path, monkeypatch,
      ) -> None:
          """No resolvable module map -> the static relation is empty, not an error."""
          monkeypatch.setattr(structure_drift, "_build_module_path_map",
                              lambda _root: {})
          rel = static_grouping_relation(tmp_path, [{"lib.x", "lib.y"}])
          assert rel == set()
      
    • test_structure_graph.py 10.1 KB
      """Tests for the static dependency-structure analysis (Signals A1-A4).
      
      The deterministic core's contract: every signal is reproducible and verifiable
      from a fixed source tree. These tests pin each signal's arithmetic (A1
      footprint additivity + direct-only-ness), graph semantics (A2 SCCs and Q range,
      A3 front-door vs burrow, A4 cut-lines), the config read, and graceful
      degradation when grimp / networkx are absent -- plus an integration run against
      this package itself.
      """
      from __future__ import annotations
      
      import networkx as nx
      
      from lib import structure_graph as sg
      from lib.assess_config import DEFAULT_KEYHOLE_BUDGET
      
      
      # --------------------------------------------------------------------------
      # Graceful degradation
      # --------------------------------------------------------------------------
      
      def test_degrades_when_grimp_missing(monkeypatch, tmp_path):
          monkeypatch.setattr(sg, "_GRIMP_AVAILABLE", False)
          result = sg.analyze_structure(tmp_path)
          assert result.available is False
          assert "grimp" in result.reason
          # Still JSON-serialisable and carries the budget.
          assert result.as_dict()["available"] is False
      
      
      def test_degrades_when_networkx_missing(monkeypatch, tmp_path):
          monkeypatch.setattr(sg, "_NETWORKX_AVAILABLE", False)
          result = sg.analyze_structure(tmp_path)
          assert result.available is False
          assert "networkx" in result.reason
      
      
      def test_no_packages_is_available_but_empty(tmp_path):
          (tmp_path / "loose.py").write_text("x = 1\n")  # no __init__.py anywhere
          result = sg.analyze_structure(tmp_path)
          assert result.available is True
          assert result.footprints == []
          assert "no importable" in result.reason
      
      
      # --------------------------------------------------------------------------
      # A1 -- comprehension footprint
      # --------------------------------------------------------------------------
      
      def test_footprint_components_sum_to_total():
          surfaces = {"A": 3, "B": 2}
          sizes = {"A": 100, "B": 40}
          fp = sg.compute_footprint("A", ["B"], surfaces, sizes, keyhole_budget=2000)
          assert fp["size"] == 100
          assert fp["dep_surface"] == 2          # public_surface(B)
          assert fp["exposed_surface"] == 3      # public_surface(A)
          assert fp["total"] == fp["size"] + fp["dep_surface"] + fp["exposed_surface"]
          assert fp["total"] == 105
      
      
      def test_footprint_over_budget_flag():
          fp = sg.compute_footprint(
              "A", [], {"A": 0}, {"A": 5000}, keyhole_budget=2000,
          )
          assert fp["over_budget"] is True
          fp2 = sg.compute_footprint(
              "A", [], {"A": 0}, {"A": 10}, keyhole_budget=2000,
          )
          assert fp2["over_budget"] is False
      
      
      def test_footprint_uses_direct_deps_only():
          # A -> B -> C. A's direct dep is only B. C's surface must NOT leak in.
          surfaces = {"A": 1, "B": 2, "C": 999}
          sizes = {"A": 10, "B": 10, "C": 10}
          fp = sg.compute_footprint("A", ["B"], surfaces, sizes, keyhole_budget=2000)
          assert fp["dep_surface"] == 2  # B only, not B + C
          # Changing C's surface leaves A's footprint untouched (transitive isolation).
          surfaces["C"] = 1
          fp_again = sg.compute_footprint("A", ["B"], surfaces, sizes, keyhole_budget=2000)
          assert fp_again["total"] == fp["total"]
      
      
      # --------------------------------------------------------------------------
      # A2 -- blob vs modular
      # --------------------------------------------------------------------------
      
      def test_cycle_is_one_scc():
          g = nx.DiGraph()
          g.add_edges_from([("A", "B"), ("B", "C"), ("C", "A")])
          sccs, q = sg.compute_modularity(g)
          assert len(sccs) == 1
          assert sccs[0] == ["A", "B", "C"]
          assert -0.5 <= q <= 1.0
      
      
      def test_acyclic_has_no_multi_member_scc():
          g = nx.DiGraph()
          g.add_edges_from([("A", "B"), ("B", "C")])
          sccs, q = sg.compute_modularity(g)
          assert sccs == []
          assert -0.5 <= q <= 1.0
      
      
      def test_separated_clusters_score_higher_than_dense_crosstalk():
          # Two tight triangles joined by a single bridge edge: clear modularity.
          separated = nx.DiGraph()
          separated.add_edges_from([
              ("a1", "a2"), ("a2", "a3"), ("a3", "a1"),
              ("b1", "b2"), ("b2", "b3"), ("b3", "b1"),
              ("a1", "b1"),
          ])
          _, q_sep = sg.compute_modularity(separated)
      
          # Everything wired to everything: near-zero / negative modularity.
          dense = nx.DiGraph()
          nodes = ["n1", "n2", "n3", "n4"]
          for u in nodes:
              for v in nodes:
                  if u != v:
                      dense.add_edge(u, v)
          _, q_dense = sg.compute_modularity(dense)
      
          assert q_sep > q_dense
          assert -0.5 <= q_sep <= 1.0
          assert -0.5 <= q_dense <= 1.0
      
      
      # --------------------------------------------------------------------------
      # A3 -- contracts (front door vs burrow)
      # --------------------------------------------------------------------------
      
      def test_all_imports_via_front_door_ratio_is_one():
          g = nx.DiGraph()
          # app imports the pkg package itself (its __init__ / public API).
          g.add_edge("app", "pkg")
          ratio, burrow = sg.compute_front_door_ratio(g, packages={"app", "pkg"})
          assert ratio == 1.0
          assert burrow == []
      
      
      def test_burrowing_lowers_ratio_and_records_edges():
          g = nx.DiGraph()
          g.add_edge("app", "pkg")            # front door
          g.add_edge("app", "pkg.internal")   # burrow into internals
          ratio, burrow = sg.compute_front_door_ratio(g, packages={"app", "pkg"})
          assert ratio == 0.5  # 1 front / 2 total -- exactly representable
          assert burrow == [{"importer": "app", "imported": "pkg.internal"}]
      
      
      def test_intra_package_edges_are_not_contract_edges():
          g = nx.DiGraph()
          g.add_edge("pkg.a", "pkg.b")  # same package -> internal cohesion, ignored
          ratio, burrow = sg.compute_front_door_ratio(g, packages={"pkg"})
          assert ratio == 1.0
          assert burrow == []
      
      
      # --------------------------------------------------------------------------
      # A4 -- breakup candidates
      # --------------------------------------------------------------------------
      
      def test_cohesive_package_is_not_a_breakup_candidate():
          # One dense cluster of 4 modules -> cohesive, no proposed split.
          g = nx.DiGraph()
          mods = ["p.a", "p.b", "p.c", "p.d"]
          for u in mods:
              for v in mods:
                  if u != v:
                      g.add_edge(u, v)
          assert sg.find_breakup_candidates("p", g) is None
      
      
      def test_two_clusters_yield_a_candidate_with_two_cuts():
          g = nx.DiGraph()
          # Two tight triangles, one thin bridge -> two natural cut-lines.
          g.add_edges_from([
              ("p.a1", "p.a2"), ("p.a2", "p.a3"), ("p.a3", "p.a1"),
              ("p.b1", "p.b2"), ("p.b2", "p.b3"), ("p.b3", "p.b1"),
              ("p.a1", "p.b1"),
          ])
          candidate = sg.find_breakup_candidates("p", g)
          assert candidate is not None
          assert candidate["package"] == "p"
          assert candidate["num_clusters"] == 2
          assert len(candidate["clusters"]) == 2
          # Every module is assigned to exactly one cut-line.
          flat = [m for cluster in candidate["clusters"] for m in cluster]
          assert sorted(flat) == sorted(g.nodes())
      
      
      def test_too_small_package_returns_none():
          g = nx.DiGraph()
          g.add_edges_from([("p.a", "p.b"), ("p.c", "p.a")])  # 3 nodes < threshold
          assert sg.find_breakup_candidates("p", g) is None
      
      
      # --------------------------------------------------------------------------
      # Config
      # --------------------------------------------------------------------------
      
      def test_keyhole_budget_default_when_no_config(tmp_path):
          from lib.assess_config import load_structure_config
          cfg = load_structure_config(tmp_path)
          assert cfg["keyhole_budget"] == DEFAULT_KEYHOLE_BUDGET
      
      
      def test_keyhole_budget_read_from_config(tmp_path):
          from lib.assess_config import load_structure_config
          assess = tmp_path / ".assess"
          assess.mkdir()
          (assess / "config.toml").write_text(
              "[structure]\nkeyhole_budget = 1500\n"
          )
          cfg = load_structure_config(tmp_path)
          assert cfg["keyhole_budget"] == 1500
      
      
      def test_keyhole_budget_rejects_malformed_value(tmp_path):
          from lib.assess_config import load_structure_config
          assess = tmp_path / ".assess"
          assess.mkdir()
          # A non-positive / wrong-typed value falls back to the default.
          (assess / "config.toml").write_text(
              '[structure]\nkeyhole_budget = "lots"\n'
          )
          cfg = load_structure_config(tmp_path)
          assert cfg["keyhole_budget"] == DEFAULT_KEYHOLE_BUDGET
      
      
      # --------------------------------------------------------------------------
      # Package discovery
      # --------------------------------------------------------------------------
      
      def test_discover_packages_finds_top_level_only(tmp_path):
          pkg = tmp_path / "pkg"
          sub = pkg / "sub"
          pkg.mkdir()
          sub.mkdir()
          (pkg / "__init__.py").write_text("")
          (sub / "__init__.py").write_text("")
          found = sg.discover_packages(tmp_path)
          assert found == [pkg.resolve()]  # sub is a subpackage, not top-level
      
      
      def test_discover_packages_skips_excluded_dirs(tmp_path):
          real = tmp_path / "real"
          real.mkdir()
          (real / "__init__.py").write_text("")
          venv_pkg = tmp_path / ".venv" / "junk"
          venv_pkg.mkdir(parents=True)
          (venv_pkg / "__init__.py").write_text("")
          found = sg.discover_packages(tmp_path)
          assert found == [real.resolve()]
      
      
      # --------------------------------------------------------------------------
      # Integration -- analyse this package itself
      # --------------------------------------------------------------------------
      
      def test_analyze_structure_on_lib_itself():
          from pathlib import Path
          lib_dir = Path(__file__).resolve().parent.parent / "scripts" / "lib"
          result = sg.analyze_structure(lib_dir)
          assert result.available is True
          assert result.reason == ""
          assert result.module_count > 0
      
          modules = {fp["module"] for fp in result.footprints}
          assert "lib.structure_graph" in modules
          assert "lib.assess_config" in modules
      
          # Q is computed and in range; result round-trips to JSON-friendly dict.
          assert -0.5 <= result.modularity_q <= 1.0
          d = result.as_dict()
          assert d["module_count"] == result.module_count
          assert isinstance(d["footprints"], list)
          # Every footprint exposes the expected arithmetic shape.
          sample = result.footprints[0]
          assert sample["total"] == (
              sample["size"] + sample["dep_surface"] + sample["exposed_surface"]
          )
      
    • test_test_focus.py 21.4 KB
      """Contract tests for ``lib/test_focus.compute_test_focus``.
      
      Cover each signal classification, the risk-band assignment by hotspot position,
      the ranking order, the ``covered_clean`` filter, the honest no-coverage degrade
      (``coverage_data=None`` without ``repo_root`` -> every entry
      ``unknown_no_coverage`` and ``coverage_present: False``), the test-file fallback
      under ``repo_root`` (``sibling_test_only`` / ``unsupported``), and the
      ``mutation_scope`` filter.
      """
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      # scripts/ on the path so ``lib`` imports resolve the same way the orchestrator does.
      sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
      
      from lib.test_focus import compute_test_focus, mutation_scope  # noqa: E402
      
      
      def _hot(*paths: str) -> list[dict]:
          """Build a top_hotspots-shaped list (ranked, each entry a dict with a path)."""
          return [{"path": p} for p in paths]
      
      
      def _coverage(per_file: dict[str, float], overall: float = 0.5) -> dict:
          return {"_overall": overall, "per_file": per_file}
      
      
      def _empty_heuristics() -> dict:
          return {
              "assertion_on_internal": [],
              "untested_boundaries": [],
              "duplicate_truth": [],
          }
      
      
      def _entry(block: dict, path: str) -> dict:
          return next(e for e in block["entries"] if e["path"] == path)
      
      
      # ── signal classification ─────────────────────────────────────────────────────
      
      def test_no_covering_test_when_file_absent_from_report() -> None:
          block = compute_test_focus(_hot("a.py"), _coverage({"other.py": 0.8}),
                                     _empty_heuristics())
          entry = _entry(block, "a.py")
          assert entry["test_signal"] == "no_covering_test"
          assert entry["suggested_action"] == "add_tests"
          assert entry["hollow_heuristic_kinds"] == []
      
      
      def test_no_covering_test_when_line_rate_zero() -> None:
          block = compute_test_focus(_hot("a.py"), _coverage({"a.py": 0.0}),
                                     _empty_heuristics())
          assert _entry(block, "a.py")["test_signal"] == "no_covering_test"
      
      
      def test_covered_but_hollow_when_in_a_heuristic_bucket() -> None:
          heuristics = {
              "assertion_on_internal": [],
              "untested_boundaries": [{"file": "a.py", "line": 3, "operator": "<="}],
              "duplicate_truth": [],
          }
          block = compute_test_focus(_hot("a.py"), _coverage({"a.py": 0.9}), heuristics)
          entry = _entry(block, "a.py")
          assert entry["test_signal"] == "covered_but_hollow"
          assert entry["suggested_action"] == "strengthen_assertions"
          assert entry["hollow_heuristic_kinds"] == ["untested_boundaries"]
      
      
      def test_covered_but_hollow_matches_test_file_key() -> None:
          """assertion_on_internal entries name the file under ``test_file``; a hot file
          matching that key still counts as hollow."""
          heuristics = {
              "assertion_on_internal": [
                  {"test_file": "a.py", "subject_function": "test_x:obj",
                   "internal_field": "_y", "confidence": "medium"}
              ],
              "untested_boundaries": [],
              "duplicate_truth": [],
          }
          block = compute_test_focus(_hot("a.py"), _coverage({"a.py": 0.9}), heuristics)
          assert _entry(block, "a.py")["hollow_heuristic_kinds"] == ["assertion_on_internal"]
      
      
      def test_multiple_hollow_kinds_collected_in_report_order() -> None:
          heuristics = {
              "assertion_on_internal": [{"test_file": "a.py", "internal_field": "_y"}],
              "untested_boundaries": [{"file": "a.py", "line": 1, "operator": "<"}],
              "duplicate_truth": [{"file": "a.py", "field_name": "x", "derives_from": "y"}],
          }
          block = compute_test_focus(_hot("a.py"), _coverage({"a.py": 0.9}), heuristics)
          assert _entry(block, "a.py")["hollow_heuristic_kinds"] == [
              "assertion_on_internal", "untested_boundaries", "duplicate_truth",
          ]
      
      
      def test_covered_clean_is_filtered_out() -> None:
          block = compute_test_focus(_hot("a.py"), _coverage({"a.py": 0.95}),
                                     _empty_heuristics())
          assert block["entries"] == []
          assert block["total_focus_targets"] == 0
          assert block["coverage_present"] is True
      
      
      # ── risk bands ────────────────────────────────────────────────────────────────
      
      def test_risk_band_by_hotspot_position() -> None:
          paths = [f"f{i}.py" for i in range(10)]
          # No coverage report -> every file is a focus target, so all 10 appear.
          block = compute_test_focus(_hot(*paths), None, _empty_heuristics())
          band = {e["path"]: e["risk_band"] for e in block["entries"]}
          assert [band[f"f{i}.py"] for i in range(3)] == ["high", "high", "high"]
          assert [band[f"f{i}.py"] for i in range(3, 7)] == ["medium"] * 4
          assert [band[f"f{i}.py"] for i in range(7, 10)] == ["low"] * 3
      
      
      def test_files_beyond_top_ten_are_excluded() -> None:
          paths = [f"f{i}.py" for i in range(13)]
          block = compute_test_focus(_hot(*paths), None, _empty_heuristics())
          assert block["total_focus_targets"] == 10
          assert all(int(e["path"][1:-3]) < 10 for e in block["entries"])
      
      
      # ── ranking ───────────────────────────────────────────────────────────────────
      
      def test_ranking_risk_band_dominates_then_signal_severity() -> None:
          # low-risk file with the most severe signal vs high-risk with a milder one:
          # the high-risk file must still rank first (band dominates).
          hot = _hot(*[f"f{i}.py" for i in range(8)])  # f0-f2 high, f3-f6 medium, f7 low
          coverage = _coverage({
              "f0.py": 0.95,  # high, covered_clean -> filtered
              "f7.py": 0.0,   # low, no_covering_test (severe)
          })
          # f0 filtered; f1,f2 high no_covering_test; f3-f6 medium; f7 low severe.
          block = compute_test_focus(hot, coverage, _empty_heuristics())
          ranked = [e["path"] for e in block["entries"]]
          # First entries are the high-band files, low-band f7 is last despite severity.
          assert ranked[0] in {"f1.py", "f2.py"}
          assert ranked[-1] == "f7.py"
          assert block["entries"][0]["risk_band"] == "high"
      
      
      def test_signal_severity_orders_within_a_band() -> None:
          # Two high-risk files: one with no test (severe), one covered-but-hollow.
          hot = _hot("a.py", "b.py")
          coverage = _coverage({"b.py": 0.9})  # a.py absent -> no_covering_test
          heuristics = {
              "assertion_on_internal": [],
              "untested_boundaries": [{"file": "b.py", "line": 1, "operator": "<"}],
              "duplicate_truth": [],
          }
          block = compute_test_focus(hot, coverage, heuristics)
          ranked = [e["path"] for e in block["entries"]]
          assert ranked == ["a.py", "b.py"]  # no_covering_test outranks covered_but_hollow
      
      
      # ── honest degrade ────────────────────────────────────────────────────────────
      
      def test_no_coverage_degrades_to_unknown_not_clean() -> None:
          paths = [f"f{i}.py" for i in range(3)]
          block = compute_test_focus(_hot(*paths), None, _empty_heuristics())
          assert block["coverage_present"] is False
          assert block["available"] is True
          assert block["total_focus_targets"] == 3
          for entry in block["entries"]:
              assert entry["test_signal"] == "unknown_no_coverage"
              assert entry["suggested_action"] == "add_tests"
              assert entry["hollow_heuristic_kinds"] == []
      
      
      def test_empty_inputs_produce_empty_block() -> None:
          block = compute_test_focus([], None, None)
          assert block == {
              "available": True,
              "coverage_present": False,
              "entries": [],
              "total_focus_targets": 0,
          }
      
      
      def test_bare_string_hotspot_entries_supported() -> None:
          block = compute_test_focus(["a.py", "b.py"], None, _empty_heuristics())
          assert {e["path"] for e in block["entries"]} == {"a.py", "b.py"}
      
      
      def test_malformed_hotspot_entries_are_skipped() -> None:
          block = compute_test_focus(
              [{"path": "a.py"}, {"no_path": 1}, None, 42],
              _coverage({"a.py": 0.0}),
              _empty_heuristics(),
          )
          assert [e["path"] for e in block["entries"]] == ["a.py"]
      
      
      # ── sibling-test fallback and the unsupported signal (#317) ───────────────────
      
      
      def _touch(root: Path, rel: str) -> None:
          p = root / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text("x\n", encoding="utf-8")
      
      
      def test_sibling_test_fallback_credits_file_with_sibling_test(tmp_path: Path) -> None:
          """No coverage report, but a sibling test file exists: the file is credited
          as sibling_test_only / measure_coverage (never a covered bucket, never
          unknown/no_covering_test/unsupported, never add_tests). Covers each naming
          convention and the sibling __tests__/ directory."""
          for rel in ("src/a.ts", "src/a.test.ts",
                      "src/b.tsx", "src/b.spec.tsx",
                      "pkg/c.go", "pkg/c_test.go",
                      "lib/d.py", "lib/test_d.py",
                      "web/e.js", "web/__tests__/e.test.js",
                      "web/f.js", "web/__tests__/f.js"):
              _touch(tmp_path, rel)
          hot = ["src/a.ts", "src/b.tsx", "pkg/c.go", "lib/d.py", "web/e.js", "web/f.js"]
          block = compute_test_focus(hot, None, None, repo_root=tmp_path)
          assert block["coverage_present"] is False
          by_path = {e["path"]: e for e in block["entries"]}
          assert set(by_path) == set(hot)
          for path in hot:
              assert by_path[path]["test_signal"] == "sibling_test_only"
              assert by_path[path]["suggested_action"] == "measure_coverage"
              assert by_path[path]["hollow_heuristic_kinds"] == []
      
      
      def test_sibling_test_fallback_keeps_hollow_heuristics(tmp_path: Path) -> None:
          """A sibling-tested file that trips a hollow heuristic keeps the
          sibling_test_only signal (no coverage was measured) but carries the hollow
          kinds so that evidence is not lost."""
          _touch(tmp_path, "src/a.py")
          _touch(tmp_path, "src/test_a.py")
          heur = _empty_heuristics()
          heur["untested_boundaries"] = [{"file": "src/a.py"}]
          block = compute_test_focus(["src/a.py"], None, heur, repo_root=tmp_path)
          entry = _entry(block, "src/a.py")
          assert entry["test_signal"] == "sibling_test_only"
          assert entry["suggested_action"] == "measure_coverage"
          assert entry["hollow_heuristic_kinds"] == ["untested_boundaries"]
      
      
      def test_unsupported_test_signal_when_no_coverage_and_no_sibling(tmp_path: Path) -> None:
          """No coverage report and no sibling test: the honest signal is unsupported,
          with its own action and a severity below a known no_covering_test."""
          _touch(tmp_path, "src/b.ts")
          block = compute_test_focus(["src/b.ts"], None, None, repo_root=tmp_path)
          entry = _entry(block, "src/b.ts")
          assert entry["test_signal"] == "unsupported"
          assert entry["suggested_action"] == "measure_coverage"
      
      
      def test_unsupported_test_signal_ranks_within_band(tmp_path: Path) -> None:
          """unsupported has its own severity rank: less tested ranks higher, so a file
          with no test found anywhere outranks a sibling-tested one in the same band,
          whatever the hotspot order."""
          _touch(tmp_path, "a.py")
          _touch(tmp_path, "test_a.py")
          _touch(tmp_path, "b.py")
          heur = _empty_heuristics()
          heur["duplicate_truth"] = [{"file": "a.py"}]
          block = compute_test_focus(["a.py", "b.py"], None, heur, repo_root=tmp_path)
          assert [(e["path"], e["test_signal"]) for e in block["entries"]] == [
              ("b.py", "unsupported"), ("a.py", "sibling_test_only"),
          ]
      
      
      def test_sibling_test_fallback_skips_deleted_source(tmp_path: Path) -> None:
          """A stale hotspot entry for a deleted source is never credited by a test
          file that outlived it."""
          _touch(tmp_path, "src/a.test.ts")
          block = compute_test_focus(["src/a.ts"], None, None, repo_root=tmp_path)
          assert _entry(block, "src/a.ts")["test_signal"] == "unsupported"
      
      
      def test_sibling_test_fallback_parallel_tests_tree(tmp_path: Path) -> None:
          """The parallel tests/ layout credits: this repo's own convention
          (skills/assess/tests/test_<stem>.py for skills/assess/scripts/lib/<stem>.py),
          a root tests/ tree mirroring the source path, a mirror that drops a src/
          root, an adjacent test/ directory, and a Go-style root test/ tree."""
          for rel in ("skills/assess/scripts/lib/doc_graph.py",
                      "skills/assess/tests/test_doc_graph.py",
                      "src/pkg/mod.py", "tests/src/pkg/test_mod.py",
                      "src/app/view.ts", "tests/app/view.spec.ts",
                      "lib/util.js", "lib/test/util.test.js",
                      "cmd/run.go", "test/run_test.go"):
              _touch(tmp_path, rel)
          hot = ["skills/assess/scripts/lib/doc_graph.py", "src/pkg/mod.py",
                 "src/app/view.ts", "lib/util.js", "cmd/run.go"]
          block = compute_test_focus(hot, None, None, repo_root=tmp_path)
          by_path = {e["path"]: e["test_signal"] for e in block["entries"]}
          assert by_path == {path: "sibling_test_only" for path in hot}
      
      
      def test_sibling_test_fallback_parallel_tree_needs_matching_name(tmp_path: Path) -> None:
          """A tests/ tree with only unrelated test files does not credit a source."""
          _touch(tmp_path, "skills/assess/scripts/lib/doc_graph.py")
          _touch(tmp_path, "skills/assess/tests/test_other.py")
          _touch(tmp_path, "tests/doc_graph.py")
          block = compute_test_focus(
              ["skills/assess/scripts/lib/doc_graph.py"], None, None, repo_root=tmp_path,
          )
          assert block["entries"][0]["test_signal"] == "unsupported"
      
      
      def test_no_repo_root_keeps_unknown_no_coverage() -> None:
          """Backward compatible: without repo_root the no-report degrade is unchanged."""
          block = compute_test_focus(["a.py"], None, None)
          assert block["entries"][0]["test_signal"] == "unknown_no_coverage"
      
      
      def test_sibling_test_fallback_hyphenated_stem_and_test_files(tmp_path: Path) -> None:
          """A hyphenated script matches its underscore test name (this repo's
          complexity-treemap.py -> tests/test_complexity_treemap.py), and a hot file
          that is itself a test is test evidence, never 'no test found'."""
          for rel in ("skills/assess/scripts/complexity-treemap.py",
                      "skills/assess/tests/test_complexity_treemap.py",
                      "src/a.test.ts", "web/__tests__/b.js"):
              _touch(tmp_path, rel)
          hot = ["skills/assess/scripts/complexity-treemap.py",
                 "skills/assess/tests/test_complexity_treemap.py",
                 "src/a.test.ts", "web/__tests__/b.js"]
          block = compute_test_focus(hot, None, None, repo_root=tmp_path)
          by_path = {e["path"]: e["test_signal"] for e in block["entries"]}
          assert by_path == {path: "sibling_test_only" for path in hot}
      
      
      def test_flat_tests_tree_does_not_credit_every_same_named_file(tmp_path: Path) -> None:
          """A single root tests/test_mod.py carries no path relationship to either
          src/a/mod.py or src/b/mod.py, so a bare-name match cannot credit both; this
          repo's own flat skills/assess/tests layout is still credited alongside."""
          for rel in ("src/a/mod.py", "src/b/mod.py", "tests/test_mod.py",
                      "skills/assess/scripts/lib/doc_graph.py",
                      "skills/assess/tests/test_doc_graph.py"):
              _touch(tmp_path, rel)
          hot = ["src/a/mod.py", "src/b/mod.py", "skills/assess/scripts/lib/doc_graph.py"]
          block = compute_test_focus(hot, None, None, repo_root=tmp_path)
          by_path = {e["path"]: e["test_signal"] for e in block["entries"]}
          credited = [p for p in ("src/a/mod.py", "src/b/mod.py")
                      if by_path[p] == "sibling_test_only"]
          assert len(credited) <= 1
          assert by_path["skills/assess/scripts/lib/doc_graph.py"] == "sibling_test_only"
      
      
      def test_flat_tests_tree_bounded_to_package_depth(tmp_path: Path) -> None:
          """A flat root tests/ tree does not reach a source nested deeper than its
          top-level package directory, while a mirrored path at the same root and a
          same-named file with a direct sibling test keep their credit."""
          for rel in ("pkg/sub/deep/mod.py", "tests/test_mod.py",
                      "pkg/sub/deep/view.py", "tests/sub/deep/test_view.py",
                      "src/x/util.py", "src/x/test_util.py",
                      "src/y/util.py"):
              _touch(tmp_path, rel)
          hot = ["pkg/sub/deep/mod.py", "pkg/sub/deep/view.py",
                 "src/x/util.py", "src/y/util.py"]
          block = compute_test_focus(hot, None, None, repo_root=tmp_path)
          by_path = {e["path"]: e["test_signal"] for e in block["entries"]}
          assert by_path == {
              "pkg/sub/deep/mod.py": "unsupported",
              "pkg/sub/deep/view.py": "sibling_test_only",
              "src/x/util.py": "sibling_test_only",
              "src/y/util.py": "unsupported",
          }
      
      
      def test_sibling_test_fallback_jvm_ruby_and_dotnet_spellings(tmp_path: Path) -> None:
          """The focus signal reads the shared convention list, so JUnit / XCTest /
          RSpec spellings credit a file, a Ruby spec/ mirror tree credits it, and a hot
          file that is itself a ``_spec`` / ``Test`` file is test evidence."""
          for rel in ("src/Foo.java", "src/FooTest.java",
                      "src/Bar.cs", "src/BarTests.cs",
                      "lib/baz.rb", "lib/baz_spec.rb",
                      "app/models/qux.rb", "spec/app/models/qux_spec.rb",
                      "lib/quux_spec.rb", "src/CorgeTest.kt"):
              _touch(tmp_path, rel)
          hot = ["src/Foo.java", "src/Bar.cs", "lib/baz.rb", "app/models/qux.rb",
                 "lib/quux_spec.rb", "src/CorgeTest.kt"]
          block = compute_test_focus(hot, None, None, repo_root=tmp_path)
          by_path = {e["path"]: e["test_signal"] for e in block["entries"]}
          assert by_path == {path: "sibling_test_only" for path in hot}
      
      
      def test_sibling_test_fallback_with_partial_coverage_report(tmp_path: Path) -> None:
          """A present report that omits a file is not evidence of no test: with a
          sibling test on disk the file reads sibling_test_only / measure_coverage.
          Without one it stays no_covering_test, and a file the report records at a
          0 rate stays no_covering_test even with a sibling test (the report measured
          it)."""
          for rel in ("src/a.ts", "src/a.test.ts", "src/b.ts",
                      "src/c.ts", "src/c.test.ts"):
              _touch(tmp_path, rel)
          cov = _coverage({"src/c.ts": 0.0, "src/other.ts": 0.8})
          heur = _empty_heuristics()
          heur["untested_boundaries"] = [{"file": "src/a.ts"}]
          block = compute_test_focus(["src/a.ts", "src/b.ts", "src/c.ts"], cov, heur,
                                     repo_root=tmp_path)
          assert block["coverage_present"] is True
          got = {e["path"]: (e["test_signal"], e["suggested_action"],
                             e["hollow_heuristic_kinds"]) for e in block["entries"]}
          assert got == {
              "src/a.ts": ("sibling_test_only", "measure_coverage", ["untested_boundaries"]),
              "src/b.ts": ("no_covering_test", "add_tests", []),
              "src/c.ts": ("no_covering_test", "add_tests", []),
          }
      
      
      def test_mutation_scope_keeps_only_entries_with_test_evidence(tmp_path: Path) -> None:
          """The mutation pass skips files with no test: an unsupported head entry
          (which outranks sibling_test_only in the table) never enters the scope, and
          the scope keeps the table's ranked order among the rest."""
          for rel in ("a.py", "b.py", "test_b.py", "c.py", "d.py", "test_d.py"):
              _touch(tmp_path, rel)
          block = compute_test_focus(["a.py", "b.py", "c.py", "d.py"], None, None,
                                     repo_root=tmp_path)
          assert [e["test_signal"] for e in block["entries"]] == [
              "unsupported", "unsupported", "sibling_test_only", "sibling_test_only"]
          assert mutation_scope(block) == ["b.py", "d.py"]
          assert mutation_scope(block["entries"]) == ["b.py", "d.py"]
          hollow = {"entries": [
              {"path": "x.py", "test_signal": "no_covering_test"},
              {"path": "y.py", "test_signal": "covered_but_hollow"},
              {"path": "z.py", "test_signal": "unknown_no_coverage"},
          ]}
          assert mutation_scope(hollow) == ["y.py"]
          assert mutation_scope(None) == []
          assert mutation_scope({"entries": "bad"}) == []
      
      
      def test_mutation_scope_excludes_hot_file_that_is_itself_a_test(tmp_path: Path) -> None:
          """A hot test file counts as its own test (sibling_test_only, never
          unsupported) and keeps its table row, but nothing tests it, so mutating it
          measures nothing: it never enters the mutation scope."""
          for rel in ("src/a.py", "src/test_a.py", "web/b.ts", "web/b.test.ts",
                      "web/__tests__/c.ts"):
              _touch(tmp_path, rel)
          hot = ["src/test_a.py", "web/b.test.ts", "web/__tests__/c.ts", "src/a.py", "web/b.ts"]
          block = compute_test_focus(hot, None, None, repo_root=tmp_path)
          by_path = {e["path"]: e["test_signal"] for e in block["entries"]}
          assert by_path == {path: "sibling_test_only" for path in hot}
          assert mutation_scope(block) == ["src/a.py", "web/b.ts"]
          hollow = {"entries": [
              {"path": "tests/test_x.py", "test_signal": "covered_but_hollow"},
              {"path": "x.py", "test_signal": "covered_but_hollow"},
          ]}
          assert mutation_scope(hollow) == ["x.py"]
      
      
      def test_skill_md_mutation_scope_jq_mirrors_test_path_rule() -> None:
          """SKILL.md Step 2d re-derives the mutation scope in jq; its test-file regex
          must stay the same pattern as ``sibling_tests.IS_TEST_RE`` so the offer and
          the core never disagree about which hot files are tests."""
          from lib.sibling_tests import IS_TEST_RE
      
          skill = (Path(__file__).resolve().parents[1] / "SKILL.md").read_text()
          focus_line = next(line for line in skill.splitlines() if line.startswith("FOCUS_FILES="))
          assert IS_TEST_RE.pattern.replace("\\", "\\\\") in focus_line
          assert '"__tests__"' in focus_line
      
    • test_test_pressure.py 33 KB
      """Tests for Layer 1 write-side truth pressure: mutation tier + cheap heuristics."""
      from __future__ import annotations
      
      import subprocess
      from pathlib import Path
      
      import lib.test_pressure as tp
      from lib.test_pressure import (
          compute_cheap_heuristics,
          compute_gap_signal,
          compute_survivor_density,
          detect_assertion_on_internal,
          detect_duplicate_truth,
          detect_mutation_config,
          detect_untested_boundaries,
          identify_survivor_clusters,
          run_bounded_mutation,
          scan_test_pressure,
          _parse_cargo_mutants,
          _parse_gremlins,
          _parse_mutmut,
          _parse_mutmut_junitxml,
          _parse_stryker_json,
      )
      
      
      def _write(root: Path, rel: str, text: str) -> None:
          p = root / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # TASK 2 - mutation config detection
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_mutation_config_none(tmp_path: Path) -> None:
          _write(tmp_path, "app.py", "x = 1")
          r = detect_mutation_config(tmp_path)
          assert r["present"] is False
          assert r["tools"] == []
          assert r["ci_integrated"] is False
      
      
      def test_mutation_config_stryker_file(tmp_path: Path) -> None:
          _write(tmp_path, "stryker.conf.json", "{}")
          r = detect_mutation_config(tmp_path)
          assert r["present"] is True
          assert "stryker" in r["tools"]
          assert r["ci_integrated"] is False
      
      
      def test_mutation_config_mutmut_dotfile_and_pyproject(tmp_path: Path) -> None:
          _write(tmp_path, ".mutmut.toml", "")
          r = detect_mutation_config(tmp_path)
          assert "mutmut" in r["tools"]
      
          other = tmp_path / "proj2"
          _write(other, "pyproject.toml", "[tool.mutmut]\npaths_to_mutate = 'src/'")
          r2 = detect_mutation_config(other)
          assert "mutmut" in r2["tools"]
      
      
      def test_mutation_config_cosmic_ray(tmp_path: Path) -> None:
          _write(tmp_path, "cosmic-ray.toml", "[cosmic-ray]")
          assert "cosmic-ray" in detect_mutation_config(tmp_path)["tools"]
      
      
      def test_mutation_config_cargo_mutants(tmp_path: Path) -> None:
          _write(tmp_path, "Cargo.toml",
                 "[dev-dependencies]\n# uses cargo-mutants in CI\n")
          assert "cargo-mutants" in detect_mutation_config(tmp_path)["tools"]
      
      
      def test_mutation_config_ci_integration(tmp_path: Path) -> None:
          """A CI file that invokes a mutation tool sets ci_integrated even with no
          config file present."""
          _write(tmp_path, ".github/workflows/mutation.yml",
                 "jobs:\n  mut:\n    steps:\n      - run: npx stryker run\n")
          r = detect_mutation_config(tmp_path)
          assert r["present"] is True
          assert r["ci_integrated"] is True
          assert "stryker" in r["tools"]
      
      
      def test_mutation_config_gremlins_in_gitlab_ci(tmp_path: Path) -> None:
          _write(tmp_path, ".gitlab-ci.yml", "mutation:\n  script:\n    - gremlins unleash\n")
          r = detect_mutation_config(tmp_path)
          assert r["ci_integrated"] is True
          assert "gremlins" in r["tools"]
      
      
      def test_mutation_config_jenkinsfile(tmp_path: Path) -> None:
          _write(tmp_path, "Jenkinsfile", "sh 'cargo mutants'")
          r = detect_mutation_config(tmp_path)
          assert "cargo-mutants" in r["tools"]
          assert r["ci_integrated"] is True
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # TASK 2 - mutation output parsers
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_parse_stryker_json() -> None:
          out = (
              '{"files": {"src/a.ts": {"mutants": ['
              '{"status": "Killed"}, {"status": "Survived"}, {"status": "Survived"}]}}}'
          )
          parsed = _parse_stryker_json(out)
          assert len(parsed) == 1
          assert parsed[0]["file"] == "src/a.ts"
          assert parsed[0]["killed"] == 1
          assert parsed[0]["survived"] == 2
          assert parsed[0]["total"] == 3
      
      
      def test_parse_stryker_json_garbage_degrades() -> None:
          assert _parse_stryker_json("not json") == []
      
      
      def test_parse_mutmut_survivors_only() -> None:
          out = "src/foo.py:12\nsrc/foo.py:15\nsrc/bar.py:7\n"
          parsed = {p["file"]: p for p in _parse_mutmut(out)}
          assert parsed["src/foo.py"]["survived"] == 2
          assert parsed["src/foo.py"]["total"] is None  # mutmut only lists survivors
          assert parsed["src/bar.py"]["survived"] == 1
      
      
      def test_parse_gremlins() -> None:
          out = (
              "KILLED   pkg/x.go:10:2\n"
              "LIVED    pkg/x.go:12:4\n"
              "NOT COVERED pkg/y.go:3:1\n"
          )
          by_file = {p["file"]: p for p in _parse_gremlins(out)}
          assert by_file["pkg/x.go"]["killed"] == 1
          assert by_file["pkg/x.go"]["survived"] == 1
          assert by_file["pkg/y.go"]["survived"] == 1
      
      
      def test_parse_cargo_mutants() -> None:
          out = (
              "MISSED   src/lib.rs:10:5: replace foo -> bar\n"
              "CAUGHT   src/lib.rs:12:1: replace baz\n"
              "UNVIABLE src/lib.rs:14:1: nope\n"
          )
          by_file = {p["file"]: p for p in _parse_cargo_mutants(out)}
          assert by_file["src/lib.rs"]["survived"] == 1  # MISSED
          assert by_file["src/lib.rs"]["killed"] == 1    # CAUGHT, UNVIABLE ignored
          assert by_file["src/lib.rs"]["total"] == 2
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # TASK 2 - bounded mutation run
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_run_bounded_mutation_requires_opt_in(tmp_path: Path) -> None:
          _write(tmp_path, "app.py", "x = 1")
          r = run_bounded_mutation(tmp_path, hot_files=["app.py"], opt_in=False)
          assert r["mutation_run"] is False
          assert r["available"] is False
          assert "opt-in" in r["reason"]
      
      
      def test_run_bounded_mutation_tool_absent(tmp_path: Path, monkeypatch) -> None:
          _write(tmp_path, "app.py", "x = 1")
          monkeypatch.setattr(tp.shutil, "which", lambda _t: None)
          r = run_bounded_mutation(tmp_path, hot_files=["app.py"], opt_in=True)
          assert r["mutation_run"] is False
          assert r["available"] is False
      
      
      def test_run_bounded_mutation_runs_and_parses(tmp_path: Path, monkeypatch) -> None:
          _write(tmp_path, "app.py", "def f(): pass")
          monkeypatch.setattr(tp.shutil, "which", lambda t: "/usr/bin/" + t)
      
          def fake_run(cmd, **kwargs):
              return subprocess.CompletedProcess(
                  cmd, 0, stdout="app.py:3\napp.py:9\n", stderr="")
      
          monkeypatch.setattr(tp.subprocess, "run", fake_run)
          r = run_bounded_mutation(tmp_path, hot_files=["app.py"], opt_in=True)
          assert r["mutation_run"] is True
          assert r["tool"] == "mutmut"
          assert r["per_file"][0]["survived"] == 2
      
      
      def test_run_bounded_mutation_caps_scope(tmp_path: Path, monkeypatch) -> None:
          _write(tmp_path, "app.py", "x = 1")
          monkeypatch.setattr(tp.shutil, "which", lambda t: "/usr/bin/" + t)
          monkeypatch.setattr(
              tp.subprocess, "run",
              lambda cmd, **k: subprocess.CompletedProcess(cmd, 0, stdout="", stderr=""))
          many = [f"f{i}.py" for i in range(20)]
          r = run_bounded_mutation(tmp_path, hot_files=many, opt_in=True)
          assert len(r["scope"]) == tp.MAX_FILES_TO_MUTATE
      
      
      def test_run_bounded_mutation_timeout_degrades(tmp_path: Path, monkeypatch) -> None:
          _write(tmp_path, "app.py", "x = 1")
          monkeypatch.setattr(tp.shutil, "which", lambda t: "/usr/bin/" + t)
      
          def fake_run(cmd, **kwargs):
              raise subprocess.TimeoutExpired(cmd, tp.MUTATION_TIMEOUT)
      
          monkeypatch.setattr(tp.subprocess, "run", fake_run)
          r = run_bounded_mutation(tmp_path, hot_files=["app.py"], opt_in=True)
          assert r["mutation_run"] is False
          assert "timeout" in r["reason"].lower()
          assert r["per_file"] == []
      
      
      def test_run_bounded_mutation_prefers_detected_tool(tmp_path: Path, monkeypatch) -> None:
          """When a stryker config is present and both go+ts source exist, the
          config-detected tool wins over a merely-language-present one."""
          _write(tmp_path, "src/a.ts", "export const x = 1;")
          _write(tmp_path, "main.go", "package main")
          _write(tmp_path, "stryker.conf.json", "{}")
          monkeypatch.setattr(tp.shutil, "which", lambda t: "/usr/bin/" + t)
          captured = {}
      
          def fake_run(cmd, **kwargs):
              captured["cmd"] = cmd
              return subprocess.CompletedProcess(cmd, 0, stdout="{}", stderr="")
      
          monkeypatch.setattr(tp.subprocess, "run", fake_run)
          r = run_bounded_mutation(tmp_path, hot_files=["src/a.ts"], opt_in=True)
          assert r["tool"] == "stryker"
          assert captured["cmd"][0] == "stryker"
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # TASK 2 - aggregation
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_compute_survivor_density_with_totals() -> None:
          per_file = [
              {"file": "a.ts", "killed": 8, "survived": 2, "total": 10},
              {"file": "b.ts", "killed": 5, "survived": 5, "total": 10},
          ]
          d = compute_survivor_density(per_file)
          assert d["total_survived"] == 7
          assert d["total_mutants"] == 20
          assert abs(d["overall"] - 0.35) < 1e-9
          assert d["by_file"]["b.ts"] == 5
      
      
      def test_compute_survivor_density_no_totals_is_none() -> None:
          per_file = [{"file": "x.py", "killed": None, "survived": 3, "total": None}]
          d = compute_survivor_density(per_file)
          assert d["overall"] is None
          assert d["total_mutants"] is None
          assert d["total_survived"] == 3
      
      
      def test_identify_survivor_clusters() -> None:
          per_file = [
              {"file": "hot.py", "survived": 7},
              {"file": "warm.py", "survived": 3},
              {"file": "cool.py", "survived": 1},
          ]
          clusters = identify_survivor_clusters(per_file)
          assert [c["file"] for c in clusters] == ["hot.py", "warm.py"]  # cool below threshold
          assert clusters[0]["survived"] == 7  # sorted descending
      
      
      def test_compute_gap_signal_variants() -> None:
          assert compute_gap_signal(0.9, 0.3) == "high coverage + low mutation score"
          assert compute_gap_signal(0.9, 0.8) == "no gap"
          assert compute_gap_signal(None, 0.3) == "not assessed"
          assert compute_gap_signal(0.9, None) == "not assessed"
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # TASK 2 - mutmut junitxml parser (real totals, not survivor-only)
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_parse_mutmut_junitxml_per_file_totals(fixtures_dir: Path) -> None:
          """A real mutmut junitxml fixture yields per-file killed/survived/total.
          A <testcase> with a <failure> child is a survivor; otherwise it was killed."""
          parsed = {p["file"]: p for p in
                    _parse_mutmut_junitxml(fixtures_dir / "mutmut-junitxml.xml")}
          assert parsed["src/calc.py"]["killed"] == 1
          assert parsed["src/calc.py"]["survived"] == 2
          assert parsed["src/calc.py"]["total"] == 3
          assert parsed["src/util.py"]["killed"] == 2
          assert parsed["src/util.py"]["survived"] == 0
          assert parsed["src/util.py"]["total"] == 2
      
      
      def test_parse_mutmut_junitxml_classname_fallback(tmp_path: Path) -> None:
          """Some versions encode the path in classname (mutmut.<dotted>) instead of
          the file attribute - derive <path>.py from it when file= is absent."""
          xml = (
              '<?xml version="1.0" ?>\n'
              '<testsuites><testsuite name="mutmut">'
              '<testcase classname="mutmut.pkg.mod" name="m1"></testcase>'
              '<testcase classname="mutmut.pkg.mod" name="m2">'
              '<failure message="bad_survived">x</failure></testcase>'
              '</testsuite></testsuites>'
          )
          p = tmp_path / "report.xml"
          p.write_text(xml, encoding="utf-8")
          parsed = {row["file"]: row for row in _parse_mutmut_junitxml(p)}
          assert parsed["pkg/mod.py"] == {
              "file": "pkg/mod.py", "killed": 1, "survived": 1, "total": 2}
      
      
      def test_parse_mutmut_junitxml_missing_file_degrades(tmp_path: Path) -> None:
          assert _parse_mutmut_junitxml(tmp_path / "nope.xml") == []
      
      
      def test_parse_mutmut_junitxml_malformed_degrades(tmp_path: Path) -> None:
          p = tmp_path / "bad.xml"
          p.write_text("not xml at all <<<", encoding="utf-8")
          assert _parse_mutmut_junitxml(p) == []
      
      
      def test_junitxml_to_density_overall_is_non_null(fixtures_dir: Path) -> None:
          """The whole point of the fix: junitxml carries totals, so survivor density
          has a real overall value (unlike the survivor-only stdout parse)."""
          per_file = _parse_mutmut_junitxml(fixtures_dir / "mutmut-junitxml.xml")
          d = compute_survivor_density(per_file)
          assert d["overall"] is not None
          assert d["total_mutants"] == 5
          assert d["total_survived"] == 2
          assert abs(d["overall"] - 0.4) < 1e-9
      
      
      def test_run_bounded_mutation_uses_junitxml(tmp_path: Path, monkeypatch,
                                                  fixtures_dir: Path) -> None:
          """The two-step mutmut path: `mutmut run` then `mutmut junitxml`. The junitxml
          parse wins, so per_file carries real totals."""
          _write(tmp_path, "app.py", "def f(): pass")
          monkeypatch.setattr(tp.shutil, "which", lambda t: "/usr/bin/" + t)
          xml = (fixtures_dir / "mutmut-junitxml.xml").read_text(encoding="utf-8")
      
          def fake_run(cmd, **kwargs):
              if cmd[:2] == ["mutmut", "junitxml"]:
                  return subprocess.CompletedProcess(cmd, 0, stdout=xml, stderr="")
              return subprocess.CompletedProcess(cmd, 0, stdout="app.py:3\n", stderr="")
      
          monkeypatch.setattr(tp.subprocess, "run", fake_run)
          r = run_bounded_mutation(tmp_path, hot_files=["app.py"], opt_in=True)
          assert r["mutation_run"] is True
          assert r["tool"] == "mutmut"
          by_file = {p["file"]: p for p in r["per_file"]}
          assert by_file["src/calc.py"]["total"] == 3
          assert compute_survivor_density(r["per_file"])["overall"] is not None
      
      
      def test_run_bounded_mutation_junitxml_empty_falls_back_to_stdout(
              tmp_path: Path, monkeypatch) -> None:
          """When `mutmut junitxml` is absent/empty (e.g. mutmut 3.x dropped it), the
          run degrades to the survivor-only stdout parse - exactly as before the fix."""
          _write(tmp_path, "app.py", "def f(): pass")
          monkeypatch.setattr(tp.shutil, "which", lambda t: "/usr/bin/" + t)
      
          def fake_run(cmd, **kwargs):
              if cmd[:2] == ["mutmut", "junitxml"]:
                  return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="no cmd")
              return subprocess.CompletedProcess(cmd, 0, stdout="app.py:3\napp.py:9\n",
                                                 stderr="")
      
          monkeypatch.setattr(tp.subprocess, "run", fake_run)
          r = run_bounded_mutation(tmp_path, hot_files=["app.py"], opt_in=True)
          assert r["mutation_run"] is True
          assert r["per_file"][0]["survived"] == 2
          assert r["per_file"][0]["total"] is None  # stdout parse: no totals
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # TASK 3 - assertion on internal
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_assertion_on_internal_flags_private_only(tmp_path: Path) -> None:
          """The hollow fingerprint: asserts on a private field, no public assertion."""
          _write(tmp_path, "test_guard.py",
                 "def test_resume():\n"
                 "    g = Guard()\n"
                 "    assert g._resume_count == 1\n")
          findings = detect_assertion_on_internal(tmp_path)
          assert len(findings) == 1
          assert findings[0]["internal_field"] == "_resume_count"
          assert findings[0]["confidence"] == "medium"
      
      
      def test_assertion_on_internal_honest_test_not_flagged(tmp_path: Path) -> None:
          """A test that also asserts on a public attribute is honest - not flagged."""
          _write(tmp_path, "test_guard.py",
                 "def test_resume():\n"
                 "    g = Guard()\n"
                 "    assert g._resume_count == 1\n"
                 "    assert g.status == 'ok'\n")
          assert detect_assertion_on_internal(tmp_path) == []
      
      
      def test_assertion_on_internal_unittest_style(tmp_path: Path) -> None:
          _write(tmp_path, "test_svc_test.py",
                 "class T:\n"
                 "    def test_it(self):\n"
                 "        self.assertEqual(svc._cache, {})\n")
          findings = detect_assertion_on_internal(tmp_path)
          assert findings and findings[0]["internal_field"] == "_cache"
      
      
      def test_assertion_on_internal_dunder_not_flagged(tmp_path: Path) -> None:
          """Dunders (__len__ etc.) are protocol, not private internals."""
          _write(tmp_path, "test_x.py",
                 "def test_len():\n    assert obj.__len__() == 3\n")
          assert detect_assertion_on_internal(tmp_path) == []
      
      
      def test_assertion_on_internal_ts_private(tmp_path: Path) -> None:
          _write(tmp_path, "guard.test.ts",
                 "it('guards', () => { expect(g._resumeCount).toBe(1); });\n")
          findings = detect_assertion_on_internal(tmp_path)
          assert findings and findings[0]["confidence"] == "low"
      
      
      def test_assertion_on_internal_skips_non_test_files(tmp_path: Path) -> None:
          """A production file with `_field` access is not a test - not scanned."""
          _write(tmp_path, "guard.py", "assert g._x == 1\n")
          assert detect_assertion_on_internal(tmp_path) == []
      
      
      def test_assertion_on_internal_degrades_on_syntax_error(tmp_path: Path) -> None:
          _write(tmp_path, "test_broken.py", "def test_(:\n  assert x._y\n")
          assert detect_assertion_on_internal(tmp_path) == []  # no crash
      
      
      # ── #81: testing a private helper *as the subject under test* is legitimate ────
      
      def test_assertion_on_internal_private_helper_called_not_flagged(tmp_path: Path) -> None:
          """A direct unit test of a module-private helper function calls the helper as
          the subject under test - it is not the meridian "assert on internal state"
          anti-pattern. The private name is in call position (`mod._helper(...)`), not
          a field read, so it must NOT be flagged. Reproduces the 8 false positives
          from the v1.14.0 self-check (e.g. `_is_build_artifact`, `_has_sibling_test`)."""
          _write(tmp_path, "test_treemap.py",
                 "import treemap\n"
                 "def test_is_build_artifact():\n"
                 "    assert treemap._is_build_artifact(Path('x')) is True\n"
                 "def test_has_sibling_test():\n"
                 "    assert treemap._has_sibling_test(repo, 'go/foo.go') is True\n")
          assert detect_assertion_on_internal(tmp_path) == []
      
      
      def test_assertion_on_internal_private_field_read_still_flagged(tmp_path: Path) -> None:
          """The #81 refinement must not weaken the true positive: a private field
          *read as a value* (not called) with no public assertion is still hollow."""
          _write(tmp_path, "test_guard.py",
                 "def test_cursor():\n"
                 "    p = Processor()\n"
                 "    p.process(['a', 'b'])\n"
                 "    assert p._last_processed_line == 2\n")
          findings = detect_assertion_on_internal(tmp_path)
          assert len(findings) == 1
          assert findings[0]["internal_field"] == "_last_processed_line"
      
      
      def test_assertion_on_internal_public_method_call_keeps_test_honest(tmp_path: Path) -> None:
          """A test that calls a public method (observable behaviour) alongside a
          private-field read is honest - the public call must still count as a public
          assertion so the test is not flagged."""
          _write(tmp_path, "test_guard.py",
                 "def test_mix():\n"
                 "    g = Guard()\n"
                 "    assert g._resume_count == 1\n"
                 "    assert g.public_status() == 'ok'\n")
          assert detect_assertion_on_internal(tmp_path) == []
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # TASK 3 - untested boundaries
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_untested_boundaries_requires_coverage(tmp_path: Path) -> None:
          _write(tmp_path, "app.py", "def f(n):\n    return n <= 10\n")
          assert detect_untested_boundaries(tmp_path, coverage_data=None) == []
      
      
      def test_untested_boundaries_python_compare(tmp_path: Path) -> None:
          _write(tmp_path, "app.py", "def f(n):\n    if n <= 10:\n        return n + 1\n")
          cov = {"app.py": [2, 3]}  # both lines covered
          findings = detect_untested_boundaries(tmp_path, coverage_data=cov)
          ops = {f["operator"] for f in findings}
          assert "<=" in ops
          assert "+1" in ops
          assert all(f["boundary_tested"] is False for f in findings)  # candidate only
      
      
      def test_untested_boundaries_only_covered_lines(tmp_path: Path) -> None:
          _write(tmp_path, "app.py",
                 "def f(n):\n    if n < 5:\n        return 0\n    if n > 9:\n        return 1\n")
          cov = {"app.py": [2]}  # only the first comparison line covered
          findings = detect_untested_boundaries(tmp_path, coverage_data=cov)
          lines = {f["line"] for f in findings}
          assert lines == {2}  # line 4 (n > 9) not covered, excluded
      
      
      def test_untested_boundaries_coverage_dict_form(tmp_path: Path) -> None:
          """Coverage as {file: {line: hits}} with a zero-hit line excluded."""
          _write(tmp_path, "app.go", "func f(n int) bool {\n    return n >= 3\n}\n")
          cov = {"app.go": {2: 5, 1: 0}}
          findings = detect_untested_boundaries(tmp_path, coverage_data=cov)
          assert findings and findings[0]["operator"] == ">="
      
      
      def test_untested_boundaries_skips_test_files(tmp_path: Path) -> None:
          _write(tmp_path, "app_test.go", "func TestF(t *testing.T) {\n    if n <= 1 {}\n}\n")
          cov = {"app_test.go": [2]}
          assert detect_untested_boundaries(tmp_path, coverage_data=cov) == []
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # TASK 3 - duplicate truth
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_duplicate_truth_python_direct_copy(tmp_path: Path) -> None:
          _write(tmp_path, "model.py",
                 "class M:\n"
                 "    def __init__(self, balance):\n"
                 "        self.balance = balance\n"
                 "        self.shadow = self.balance\n")
          findings = detect_duplicate_truth(tmp_path)
          dup = [f for f in findings if f["field_name"] == "shadow"]
          assert dup and dup[0]["derives_from"] == "balance"
      
      
      def test_duplicate_truth_python_offset(tmp_path: Path) -> None:
          _write(tmp_path, "model.py",
                 "class M:\n"
                 "    def shift(self):\n"
                 "        self.next_index = self.index + 1\n")
          findings = detect_duplicate_truth(tmp_path)
          assert any(f["field_name"] == "next_index"
                     and f["derives_from"] == "index" for f in findings)
      
      
      def test_duplicate_truth_independent_computation_not_flagged(tmp_path: Path) -> None:
          """A field also assigned from an independent computation is honest."""
          _write(tmp_path, "model.py",
                 "class M:\n"
                 "    def calc(self, items):\n"
                 "        self.total = self.base\n"
                 "        self.total = sum(items)\n")  # second assignment is independent
          findings = detect_duplicate_truth(tmp_path)
          assert all(f["field_name"] != "total" for f in findings)
      
      
      def test_duplicate_truth_ts(tmp_path: Path) -> None:
          _write(tmp_path, "model.ts",
                 "class M {\n  update() {\n    this.mirror = this.source;\n  }\n}\n")
          findings = detect_duplicate_truth(tmp_path)
          assert any(f["field_name"] == "mirror"
                     and f["derives_from"] == "source" for f in findings)
      
      
      def test_duplicate_truth_degrades_on_syntax_error(tmp_path: Path) -> None:
          _write(tmp_path, "broken.py", "class M(:\n  x =\n")
          assert detect_duplicate_truth(tmp_path) == []  # no crash
      
      
      # ── #82: function-local aliasing is a transient binding, not a duplicate field ─
      
      def test_duplicate_truth_ignores_function_local_aliasing(tmp_path: Path) -> None:
          """Ordinary local-variable aliasing inside a function body is a transient
          binding, not a duplicate source-of-truth *field*. Reproduces the 14 false
          positives from the v1.14.0 self-check (`raw = result.stdout`,
          `current = node.parent`, SVG coordinate locals `gx = mid`)."""
          _write(tmp_path, "app.py",
                 "def run(result, node, mid):\n"
                 "    raw = result.stdout\n"
                 "    current = node.parent\n"
                 "    gx = mid\n"
                 "    return raw, current, gx\n")
          assert detect_duplicate_truth(tmp_path) == []
      
      
      def test_duplicate_truth_instance_attribute_inside_method_still_flagged(tmp_path: Path) -> None:
          """A `self.x = self.y` copy inside a method is an instance *field*, not a
          local, and must still be flagged even though it is lexically inside a
          function."""
          _write(tmp_path, "model.py",
                 "class M:\n"
                 "    def sync(self):\n"
                 "        self.mirror = self.source\n")
          findings = detect_duplicate_truth(tmp_path)
          assert any(f["field_name"] == "mirror" and f["derives_from"] == "source"
                     for f in findings)
      
      
      def test_duplicate_truth_module_level_field_still_flagged(tmp_path: Path) -> None:
          """A module-level name that only ever copies another name is a duplicate
          source of truth and stays in scope."""
          _write(tmp_path, "config.py",
                 "PRIMARY = compute_primary()\n"
                 "ALIAS = PRIMARY\n")
          findings = detect_duplicate_truth(tmp_path)
          assert any(f["field_name"] == "ALIAS" and f["derives_from"] == "PRIMARY"
                     for f in findings)
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # Aggregation + public entry point
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_compute_cheap_heuristics_shape(tmp_path: Path) -> None:
          _write(tmp_path, "test_x.py", "def test_a():\n    assert o._p == 1\n")
          r = compute_cheap_heuristics(tmp_path)
          assert set(r) == {
              "assertion_on_internal", "untested_boundaries",
              "duplicate_truth", "confidence_note",
          }
          assert r["confidence_note"] == tp.CHEAP_HEURISTIC_NOTE
          assert r["assertion_on_internal"]  # the hollow test was flagged
      
      
      def test_scan_test_pressure_full_block(tmp_path: Path) -> None:
          _write(tmp_path, "stryker.conf.json", "{}")
          _write(tmp_path, "src/a.ts", "export const x = 1;")
          _write(tmp_path, "guard.test.ts",
                 "it('x', () => { expect(g._secret).toBe(1); });\n")
          block = scan_test_pressure(tmp_path, hot_files=None, opt_in=False)
          expected_keys = {
              "mutation_config_present", "mutation_tools_detected", "ci_integrated",
              "mutation_run", "mutation_scope", "per_file", "survivor_density",
              "survivor_clusters", "gap_signal", "cheap_heuristics",
          }
          assert expected_keys <= set(block)
          assert block["mutation_config_present"] is True
          assert "stryker" in block["mutation_tools_detected"]
          assert block["mutation_run"] is False         # opt_in defaulted off
          assert block["gap_signal"] == "not assessed"  # no coverage / no run
          assert block["cheap_heuristics"]["assertion_on_internal"]  # ts hollow test
      
      
      def test_scan_test_pressure_never_raises_on_empty(tmp_path: Path) -> None:
          block = scan_test_pressure(tmp_path)
          assert block["mutation_config_present"] is False
          assert block["per_file"] == []
          assert block["survivor_clusters"] == []
      
      
      # ════════════════════════════════════════════════════════════════════════════
      # Thesis integration test - hollow vs honest fixture repos
      #
      # The two fixture repos under tests/fixtures/ carry an IDENTICAL source file
      # (src/processor.py) and differ ONLY in what their test asserts on: the hollow
      # repo pins the private `_last_processed_line` cursor; the honest repo pins the
      # public `process` return value. This is the load-bearing claim of the whole
      # module - a suite can have full line coverage yet pin the implementation, and
      # the assertion-on-internal heuristic is what tells those two apart.
      # ════════════════════════════════════════════════════════════════════════════
      
      def test_thesis_hollow_repo_flags_internal_assertion(fixtures_dir: Path) -> None:
          """The hollow fixture asserts only on a private field -> flagged."""
          findings = detect_assertion_on_internal(fixtures_dir / "hollow_test_repo")
          assert len(findings) == 1
          assert findings[0]["internal_field"] == "_last_processed_line"
          assert findings[0]["test_file"] == "tests/test_processor.py"
          assert findings[0]["confidence"] == "medium"
      
      
      def test_thesis_honest_repo_yields_no_findings(fixtures_dir: Path) -> None:
          """The honest fixture asserts on the public return value -> not flagged.
      
          Same source, same coverage as the hollow repo. The only variable is what the
          test pins, so a finding here would mean the heuristic flags honest tests."""
          assert detect_assertion_on_internal(fixtures_dir / "honest_test_repo") == []
      
      
      def test_thesis_full_scan_separates_hollow_from_honest(fixtures_dir: Path) -> None:
          """End-to-end via the public scan_test_pressure entry point: the hollow repo
          surfaces an assertion_on_internal candidate in its test_pressure block; the
          honest repo's identical-source block carries none. This is the signal the
          LLM consumes from run-context.json."""
          hollow = scan_test_pressure(fixtures_dir / "hollow_test_repo")
          honest = scan_test_pressure(fixtures_dir / "honest_test_repo")
          assert hollow["cheap_heuristics"]["assertion_on_internal"]
          assert honest["cheap_heuristics"]["assertion_on_internal"] == []
          # The thesis is about test-pinning, not mutation setup: neither fixture
          # configures mutation testing, so that signal stays identical across both.
          assert hollow["mutation_config_present"] is False
          assert honest["mutation_config_present"] is False
      
      
      def test_mutation_run_requires_parsed_mutants(tmp_path: Path, monkeypatch) -> None:
          """A tool that runs and exits 0 but yields no parsed mutants must not claim a
          run (#317): a stryker spec on a TypeScript repo used to report mutation_run
          True off an empty parse, lifting the Layer 6 cap with no evidence."""
          _write(tmp_path, "src/a.ts", "export const a = 1;")
          monkeypatch.setattr(tp.shutil, "which", lambda t: "/usr/bin/" + t)
          monkeypatch.setattr(
              tp.subprocess, "run",
              lambda cmd, **k: subprocess.CompletedProcess(cmd, 0, stdout="", stderr=""))
          r = run_bounded_mutation(tmp_path, hot_files=["src/a.ts"], opt_in=True)
          assert r["mutation_run"] is False
          assert r["available"] is True
          assert r["tool"] == "stryker"
          assert r["per_file"] == []
          assert r["reason"] == "no mutant records recovered from stryker output (exit code 0)"
      
      
      def test_mutation_run_requires_parsed_mutants_true_with_records(
              tmp_path: Path, monkeypatch) -> None:
          """The honest path still reports a run when the parser yields mutant data."""
          _write(tmp_path, "app.py", "def f(): pass")
          monkeypatch.setattr(tp.shutil, "which", lambda t: "/usr/bin/" + t)
          monkeypatch.setattr(
              tp.subprocess, "run",
              lambda cmd, **k: subprocess.CompletedProcess(
                  cmd, 0, stdout="app.py:3\n", stderr=""))
          r = run_bounded_mutation(tmp_path, hot_files=["app.py"], opt_in=True)
          assert r["mutation_run"] is True
          assert r["per_file"]
      
    • test_understanding_analysis.py 11.1 KB
      """Tests for the understanding analysis (B4) + velocity clock (D2).
      
      Two styles, matching the contract:
        - Pure-logic tests mock ``authorship_by_path`` / ``doc_staleness`` /
          ``complexity_stats`` and assert the orphaned-understanding logic and
          intent-source detection in isolation (repo_root is a non-git temp dir, so
          the velocity clock is exercised through its ``None`` degrade path).
        - Git-integration tests build synthetic histories with controlled commit
          metadata, feed the *real* ``authorship_analysis`` output in, and assert the
          end-to-end classification and the velocity clock against backdated commits.
      
      Expected values are hand-noted so the contract stays auditable.
      """
      from __future__ import annotations
      
      import datetime as _dt
      import os
      import subprocess
      from pathlib import Path
      
      from lib.change_coupling import authorship_analysis
      from lib.understanding_analysis import analyze_understanding
      
      
      # --- git fixture helpers (local, for full control of author/committer/date) --
      
      def _git(repo: Path, *args: str, env: dict | None = None) -> None:
          full_env = {**os.environ, **(env or {})}
          subprocess.run(["git", "-C", str(repo), *args],
                         check=True, capture_output=True, text=True, env=full_env)
      
      
      def _write(repo: Path, rel: str, text: str) -> None:
          p = repo / rel
          p.parent.mkdir(parents=True, exist_ok=True)
          p.write_text(text, encoding="utf-8")
      
      
      def _commit(
          repo: Path,
          files: dict[str, str],
          message: str = "change",
          *,
          author: tuple[str, str] | None = None,
          committer: tuple[str, str] | None = None,
          co_authors: list[str] | None = None,
          days_ago: int | None = None,
      ) -> None:
          """Write ``files`` (rel path -> contents), stage, and commit.
      
          ``author``/``committer`` are (name, email) tuples; ``co_authors`` is a list
          of "Name <email>" strings folded into Co-Authored-By trailers; ``days_ago``
          backdates author + committer time (git rejects relative strings, so we emit
          a strict ISO timestamp) to drive the velocity clock.
          """
          for rel, text in files.items():
              _write(repo, rel, text)
          _git(repo, "add", "-A")
          body = message
          for ca in co_authors or []:
              body += f"\n\nCo-Authored-By: {ca}"
          env: dict[str, str] = {}
          if author:
              env["GIT_AUTHOR_NAME"], env["GIT_AUTHOR_EMAIL"] = author
          if committer:
              env["GIT_COMMITTER_NAME"], env["GIT_COMMITTER_EMAIL"] = committer
          if days_ago is not None:
              when = _dt.datetime.now() - _dt.timedelta(days=days_ago)
              stamp = when.strftime("%Y-%m-%dT%H:%M:%S")
              env["GIT_AUTHOR_DATE"] = stamp
              env["GIT_COMMITTER_DATE"] = stamp
          _git(repo, "commit", "-q", "-m", body, env=env)
      
      
      def _init_repo(tmp_path: Path) -> Path:
          repo = tmp_path / "repo"
          repo.mkdir()
          _git(repo, "init", "-q")
          _git(repo, "config", "user.email", "dev@example.com")
          _git(repo, "config", "user.name", "Dev Human")
          return repo
      
      
      # Complexity stats with one high-CCN file. p95 absent -> MIN_HIGH_CCN (10) floor.
      def _stats_high(path: str, ccn: float = 25.0) -> dict:
          return {"ccn": {"p95": 0.0}, "top_complex": [{"path": path, "ccn": ccn}]}
      
      
      def _no_docs() -> dict:
          return {"available": False, "docs": []}
      
      
      # --- pure-logic: the orphaned-understanding finding --------------------------
      
      def test_orphaned_when_agent_high_complexity_no_doc(tmp_path: Path) -> None:
          """High complexity ∧ no human anchor ∧ no intent source -> orphaned."""
          authorship = {"lib/x.py": {"human_anchor": False, "authorship_class": "agent",
                                     "intent_source": False, "contributors": []}}
          result = analyze_understanding(
              tmp_path, authorship, _no_docs(), _stats_high("lib/x.py"))
      
          assert result["available"] is True
          assert result["orphaned_understanding"] == ["lib/x.py"]
          mod = result["modules"][0]
          assert mod["finding"] == "orphaned_understanding"
          assert mod["authorship_class"] == "agent"
          assert mod["human_anchor"] is False
          assert mod["intent_source"] is False
          assert mod["recommendation"] is not None
          # Non-git repo_root -> the velocity clock degrades to None, never "fresh".
          assert mod["days_since_comprehension_event"] is None
      
      
      def test_human_anchor_suppresses_orphan(tmp_path: Path) -> None:
          authorship = {"lib/x.py": {"human_anchor": True, "authorship_class": "human",
                                     "intent_source": True, "contributors": []}}
          result = analyze_understanding(
              tmp_path, authorship, _no_docs(), _stats_high("lib/x.py"))
      
          assert result["orphaned_understanding"] == []
          assert result["modules"][0]["finding"] is None
          assert result["modules"][0]["recommendation"] is None
      
      
      def test_intent_source_suppresses_orphan(tmp_path: Path) -> None:
          """A co-located doc is an intent source even with no human anchor."""
          authorship = {"lib/x.py": {"human_anchor": False, "authorship_class": "agent",
                                     "intent_source": False, "contributors": []}}
          doc_staleness = {"available": True, "docs": [{"path": "lib/README.md"}]}
          result = analyze_understanding(
              tmp_path, authorship, doc_staleness, _stats_high("lib/x.py"))
      
          assert result["orphaned_understanding"] == []
          mod = result["modules"][0]
          assert mod["intent_source"] is True
          assert mod["finding"] is None
      
      
      def test_low_complexity_not_orphaned(tmp_path: Path) -> None:
          """Below the CCN gate, agent + no doc is still not orphaned."""
          authorship = {"lib/x.py": {"human_anchor": False, "authorship_class": "agent",
                                     "intent_source": False, "contributors": []}}
          # ccn 3 is below the MIN_HIGH_CCN (10) floor.
          result = analyze_understanding(
              tmp_path, authorship, _no_docs(), _stats_high("lib/x.py", ccn=3.0))
      
          assert result["orphaned_understanding"] == []
          assert result["modules"][0]["finding"] is None
      
      
      def test_root_doc_covers_everything(tmp_path: Path) -> None:
          """A repo-root doc is an ancestor of all paths -> intent source everywhere."""
          authorship = {"lib/x.py": {"human_anchor": False, "authorship_class": "agent",
                                     "intent_source": False, "contributors": []}}
          doc_staleness = {"available": True, "docs": [{"path": "README.md"}]}
          result = analyze_understanding(
              tmp_path, authorship, doc_staleness, _stats_high("lib/x.py"))
      
          assert result["modules"][0]["intent_source"] is True
          assert result["orphaned_understanding"] == []
      
      
      def test_unrelated_doc_is_not_intent_source(tmp_path: Path) -> None:
          """A doc in a sibling subtree does not cover the module."""
          authorship = {"lib/x.py": {"human_anchor": False, "authorship_class": "agent",
                                     "intent_source": False, "contributors": []}}
          doc_staleness = {"available": True, "docs": [{"path": "other/README.md"}]}
          result = analyze_understanding(
              tmp_path, authorship, doc_staleness, _stats_high("lib/x.py"))
      
          assert result["modules"][0]["intent_source"] is False
          assert result["orphaned_understanding"] == ["lib/x.py"]
      
      
      def test_unavailable_when_no_authorship(tmp_path: Path) -> None:
          result = analyze_understanding(tmp_path, {}, _no_docs(), {})
          assert result["available"] is False
          assert result["modules"] == []
          assert result["orphaned_understanding"] == []
      
      
      def test_mixed_class_passthrough(tmp_path: Path) -> None:
          authorship = {"lib/x.py": {"human_anchor": True, "authorship_class": "mixed",
                                     "intent_source": True, "contributors": []}}
          result = analyze_understanding(
              tmp_path, authorship, _no_docs(), _stats_high("lib/x.py"))
          assert result["modules"][0]["authorship_class"] == "mixed"
      
      
      # --- git-integration: real authorship + the velocity clock -------------------
      
      def test_agent_only_repo_is_orphaned(tmp_path: Path) -> None:
          """Only agent commits, no doc, high complexity -> orphaned; clock is None."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"svc.py": "v1"}, "feat",
                  author=("dependabot[bot]", "49699333+dependabot[bot]@users.noreply.github.com"),
                  committer=("dependabot[bot]", "49699333+dependabot[bot]@users.noreply.github.com"))
      
          authorship = {"svc.py": authorship_analysis(repo, "svc.py")}
          assert authorship["svc.py"]["authorship_class"] == "agent"
      
          result = analyze_understanding(repo, authorship, _no_docs(), _stats_high("svc.py"))
          assert result["orphaned_understanding"] == ["svc.py"]
          mod = result["modules"][0]
          assert mod["finding"] == "orphaned_understanding"
          # No human-authored commit exists -> clock indeterminate.
          assert mod["days_since_comprehension_event"] is None
      
      
      def test_human_commit_sets_anchor_and_clock(tmp_path: Path) -> None:
          """A backdated human commit -> human_anchor, no orphan, dated velocity clock."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"svc.py": "v1"}, "feat",
                  author=("Alice", "alice@example.com"),
                  committer=("Alice", "alice@example.com"),
                  days_ago=30)
      
          authorship = {"svc.py": authorship_analysis(repo, "svc.py")}
          assert authorship["svc.py"]["human_anchor"] is True
      
          result = analyze_understanding(repo, authorship, _no_docs(), _stats_high("svc.py"))
          assert result["orphaned_understanding"] == []
          mod = result["modules"][0]
          assert mod["finding"] is None
          # Clock reads ~30 days; allow a day of rounding/clock drift.
          assert mod["days_since_comprehension_event"] is not None
          assert 29 <= mod["days_since_comprehension_event"] <= 31
      
      
      def test_mixed_repo_classified_mixed(tmp_path: Path) -> None:
          """Human author + agent co-author -> mixed, and the clock follows the human."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"svc.py": "v1"}, "feat",
                  author=("Bob", "bob@example.com"),
                  committer=("Bob", "bob@example.com"),
                  co_authors=["Claude <noreply@anthropic.com>"],
                  days_ago=10)
      
          authorship = {"svc.py": authorship_analysis(repo, "svc.py")}
          assert authorship["svc.py"]["authorship_class"] == "mixed"
      
          result = analyze_understanding(repo, authorship, _no_docs(), _stats_high("svc.py"))
          mod = result["modules"][0]
          assert mod["authorship_class"] == "mixed"
          assert mod["human_anchor"] is True
          assert mod["finding"] is None
          assert 9 <= mod["days_since_comprehension_event"] <= 11
      
      
      def test_agent_commit_does_not_count_as_comprehension_event(tmp_path: Path) -> None:
          """Newest commit is an agent's; the clock dates the older human commit."""
          repo = _init_repo(tmp_path)
          _commit(repo, {"svc.py": "v1"}, "human work",
                  author=("Carol", "carol@example.com"),
                  committer=("Carol", "carol@example.com"),
                  days_ago=40)
          _commit(repo, {"svc.py": "v2"}, "agent tweak",
                  author=("github-actions[bot]", "github-actions[bot]@users.noreply.github.com"),
                  committer=("github-actions[bot]", "github-actions[bot]@users.noreply.github.com"),
                  days_ago=5)
      
          authorship = {"svc.py": authorship_analysis(repo, "svc.py")}
          result = analyze_understanding(repo, authorship, _no_docs(), _stats_high("svc.py"))
          mod = result["modules"][0]
          # The agent commit (5 days ago) is ignored; the clock reads the human one.
          assert 39 <= mod["days_since_comprehension_event"] <= 41
      
    • test_uninstall.py 3.1 KB
      """Tests for the uninstall path: run-context pointer, doc completeness, offer (Task 14)."""
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      from assess_core import build_run_context
      
      # skills/assess/tests/ -> skills/assess is two parents up.
      ASSESS_DIR = Path(__file__).resolve().parents[1]
      UNINSTALL_DOC = ASSESS_DIR / "references" / "uninstall.md"
      ASSESS_PR_SKILL = ASSESS_DIR.parent / "assess-pr" / "SKILL.md"
      ASSESS_SKILL = ASSESS_DIR / "SKILL.md"
      
      
      # ── run-context pointer ─────────────────────────────────────────────────────
      
      def test_run_context_carries_uninstall_path(tmp_path: Path) -> None:
          repo = tmp_path / "repo"
          repo.mkdir()
          (repo / ".assess").mkdir()
          (repo / ".assess" / "complexity-stats.json").write_text(json.dumps({
              "files_scored": 1, "loc": {}, "ccn": {},
              "top_hotspots": [], "top_complex": [], "top_large": [],
          }))
          ctx = build_run_context(repo_root=repo, run_date="2026-05-22")
          assert ctx["uninstall_instructions_path"] == "references/uninstall.md"
      
      
      def test_uninstall_path_resolves_to_real_doc() -> None:
          # The pointer is relative to the skill dir; it must name a file that ships.
          assert (ASSESS_DIR / "references" / "uninstall.md").is_file()
      
      
      # ── doc completeness / accuracy ─────────────────────────────────────────────
      
      def test_uninstall_doc_covers_every_artifact_class() -> None:
          text = UNINSTALL_DOC.read_text(encoding="utf-8")
          # Each artifact class /assess can leave in a target repo.
          assert "rm -rf" in text and ".assess" in text          # 1. the wiki dir
          assert "badge.json" in text                              # 2. README badge
          assert ".github/workflows/assess-gate.yml" in text       # 3. CI gate
          assert ".no-" in text                                    # 4. decline markers
          assert "assess-archetype" in text                        # 5. archetype marker
          # Findings issues are acknowledged but explicitly NOT auto-closed.
          assert "assess-finding" in text
      
      
      def test_uninstall_doc_lists_instruction_files_for_archetype_marker() -> None:
          text = UNINSTALL_DOC.read_text(encoding="utf-8")
          for name in ("CLAUDE.md", "AGENTS.md", "GEMINI.md",
                       ".cursorrules", ".github/copilot-instructions.md"):
              assert name in text, f"uninstall doc omits instruction file {name}"
      
      
      # ── offer appears at end of run ─────────────────────────────────────────────
      
      def test_uninstall_offered_in_assess_pr() -> None:
          text = ASSESS_PR_SKILL.read_text(encoding="utf-8")
          assert "Step 8" in text and "Uninstall" in text
          assert "uninstall_instructions_path" in text
      
      
      def test_orchestrator_references_uninstall() -> None:
          text = ASSESS_SKILL.read_text(encoding="utf-8")
          assert "uninstall" in text.lower()
          assert "uninstall_instructions_path" in text
      
    • test_vault_queries.py 3.9 KB
      """Unit tests for the vault-native navigation query parser (issue #176).
      
      Covers the three predicate kinds (folder / tag / frontmatter field) across both
      hub forms (`.base` filter expressions and ```dataview``` query blocks), the
      frontmatter parser, and the union selection semantics.
      """
      from __future__ import annotations
      
      from pathlib import Path
      
      from lib.vault_queries import (
          TAGS_KEY,
          parse_base_queries,
          parse_dataview_queries,
          parse_frontmatter,
          select_notes,
      )
      
      
      # ---- query parsing --------------------------------------------------------
      
      def test_base_infolder_predicate_extracts_folder() -> None:
          text = 'filters:\n  and:\n    - file.inFolder("_jira")\n    - file.ext == "md"\n'
          queries = parse_base_queries(text)
          assert len(queries) == 1
          q = queries[0]
          assert q.folders == {"_jira"}
          # `file.ext == "md"` is file metadata, never a frontmatter-field predicate.
          assert q.fields == []
      
      
      def test_base_frontmatter_field_predicate() -> None:
          q = parse_base_queries('filters:\n  and:\n    - status == "open"\n')[0]
          assert ("status", "open") in q.fields
          assert q.folders == set()
      
      
      def test_base_tag_predicate() -> None:
          q = parse_base_queries('filters:\n  - tags.contains("project")\n')[0]
          assert q.tags == {"project"}
      
      
      def test_dataview_from_folder_and_where() -> None:
          block = (
              "```dataview\n"
              'TABLE status FROM "_jira"\n'
              'WHERE status = "open"\n'
              "```\n"
          )
          queries = parse_dataview_queries(block)
          assert len(queries) == 1
          q = queries[0]
          assert q.folders == {"_jira"}
          assert ("status", "open") in q.fields
      
      
      def test_dataview_from_tag() -> None:
          q = parse_dataview_queries("```dataview\nLIST FROM #project\n```")[0]
          assert q.tags == {"project"}
      
      
      def test_dataviewjs_block_is_not_parsed() -> None:
          # dataviewjs is arbitrary JS - not statically resolvable, so no edges.
          block = '```dataviewjs\ndv.pages(\'"_jira"\')\n```'
          assert parse_dataview_queries(block) == []
      
      
      def test_empty_query_is_dropped() -> None:
          assert parse_dataview_queries("```dataview\nLIST\n```") == []
          assert parse_base_queries("views:\n  - type: table\n") == []
      
      
      # ---- frontmatter ----------------------------------------------------------
      
      def test_frontmatter_inline_tags_and_scalar() -> None:
          fm = parse_frontmatter('---\nstatus: open\ntags: [project, urgent]\n---\nbody')
          assert fm["status"] == "open"
          assert fm[TAGS_KEY] == {"project", "urgent"}
      
      
      def test_frontmatter_block_tags() -> None:
          fm = parse_frontmatter("---\ntags:\n  - alpha\n  - beta\n---\n")
          assert fm[TAGS_KEY] == {"alpha", "beta"}
      
      
      def test_no_frontmatter_returns_empty() -> None:
          assert parse_frontmatter("# Just a heading\n") == {}
      
      
      # ---- selection ------------------------------------------------------------
      
      def _doc_rels(*rels: str) -> list[tuple[Path, Path]]:
          return [(Path("/repo") / r, Path(r)) for r in rels]
      
      
      def test_select_by_folder() -> None:
          q = parse_base_queries('- file.inFolder("_jira")')[0]
          docs = _doc_rels("_jira/a.md", "_jira/deep/b.md", "notes/c.md")
          selected = {p.name for p in select_notes(q, docs, lambda _d: {})}
          assert selected == {"a.md", "b.md"}
      
      
      def test_select_union_never_emptied_by_unmatched_field() -> None:
          # folder selects two notes; an unsatisfiable field predicate must NOT wipe
          # them out - selection is a union, so over-linking is the failure direction.
          q = parse_base_queries(
              '- file.inFolder("_jira")\n- nonexistent == "value"'
          )[0]
          docs = _doc_rels("_jira/a.md", "_jira/b.md")
          assert len(select_notes(q, docs, lambda _d: {})) == 2
      
      
      def test_select_by_tag_uses_frontmatter() -> None:
          q = parse_dataview_queries("```dataview\nLIST FROM #project\n```")[0]
          docs = _doc_rels("a.md", "b.md")
          fm = {docs[0][0]: {TAGS_KEY: {"project"}}, docs[1][0]: {TAGS_KEY: {"other"}}}
          selected = {p.name for p in select_notes(q, docs, lambda d: fm.get(d, {}))}
          assert selected == {"a.md"}
      
    • test_wiki_writer.py 22.7 KB
      """Tests for wiki writer module."""
      from __future__ import annotations
      
      from pathlib import Path
      
      
      from lib.wiki_writer import (
          HotspotEntry,
          LogEntry,
          append_log_entry,
          slug_for_path,
          verify_log_chain,
          write_hotspot_page,
          write_index,
      )
      
      
      def _log_entry(**overrides: object) -> LogEntry:
          """A baseline LogEntry; overrides win."""
          base = dict(
              run_date="2026-07-07", files_scored=100, readiness_score=4.5,
              maturity_label="Solid", instructions_grade="B+",
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="Action X", plugin_version="1.55.0",
          )
          base.update(overrides)
          return LogEntry(**base)
      
      
      def test_slug_for_path_basic() -> None:
          assert slug_for_path("src/foo/bar.go").startswith("src-foo-bar-go-")
          assert slug_for_path("services/api/handler.ts").startswith("services-api-handler-ts-")
      
      
      def test_slug_for_path_handles_special_chars() -> None:
          assert slug_for_path("src/foo bar/baz.go").startswith("src-foo-bar-baz-go-")
      
      
      def test_slug_for_path_avoids_collision() -> None:
          """Distinct paths must produce distinct slugs even when they normalize the same."""
          slug_a = slug_for_path("src/foo-bar.py")
          slug_b = slug_for_path("src/foo/bar.py")
          assert slug_a != slug_b
          # Both still start with the readable form
          assert slug_a.startswith("src-foo-bar-py-")
          assert slug_b.startswith("src-foo-bar-py-")
      
      
      def test_write_index_creates_file(tmp_assess_dir: Path) -> None:
          entries = [
              HotspotEntry(
                  path="src/foo.go", first_flagged="2026-01-01", last_seen="2026-05-22",
                  status="active", ccn=30, loc=600,
              ),
          ]
          write_index(tmp_assess_dir, entries, last_updated="2026-05-22")
          index = tmp_assess_dir / "index.md"
          assert index.exists()
          content = index.read_text()
          assert "src/foo.go" in content
          assert "active" in content
      
      
      def test_write_index_overwrites(tmp_assess_dir: Path) -> None:
          (tmp_assess_dir / "index.md").write_text("OLD")
          entries = [HotspotEntry(
              path="src/new.go", first_flagged="2026-05-22", last_seen="2026-05-22",
              status="active", ccn=20, loc=300,
          )]
          write_index(tmp_assess_dir, entries, last_updated="2026-05-22")
          content = (tmp_assess_dir / "index.md").read_text()
          assert "OLD" not in content
          assert "src/new.go" in content
      
      
      def test_write_index_renders_none_metrics_as_dash(tmp_assess_dir: Path) -> None:
          """A graduated file that fell off every top-N list has unknown current
          metrics. The wiki must render those as `-`, never as `0` - zero in this
          column reads as "the file was emptied" and contradicts the report
          (issue #52 Bug 1)."""
          entries = [HotspotEntry(
              path="src/grad.go", first_flagged="2026-01-01", last_seen="2026-05-29",
              status="graduated", ccn=None, loc=None,
          )]
          write_index(tmp_assess_dir, entries, last_updated="2026-05-29")
          content = (tmp_assess_dir / "index.md").read_text()
          # `-` appears in the row's metric cells; `0` must not.
          row = [line for line in content.splitlines() if "src/grad.go" in line][0]
          assert "| - | - |" in row
          # Real zeros remain zeros (rare for tracked source, but the renderer
          # must distinguish them from unknown values).
          entries = [HotspotEntry(
              path="src/empty.go", first_flagged="2026-01-01", last_seen="2026-05-29",
              status="graduated", ccn=0, loc=0,
          )]
          write_index(tmp_assess_dir, entries, last_updated="2026-05-29")
          content = (tmp_assess_dir / "index.md").read_text()
          row = [line for line in content.splitlines() if "src/empty.go" in line][0]
          assert "| 0 | 0 |" in row
      
      
      def test_write_index_legend_defines_every_status_token(tmp_assess_dir: Path) -> None:
          """The legend must define every status a hotspot row can carry: the four
          tokens assess_core's diff/status map emits (graduated, new, regressed,
          persistent) plus the `active` fallback for paths with no diff entry."""
          entries = [HotspotEntry(
              path="src/foo.go", first_flagged="2026-06-07", last_seen="2026-06-07",
              status="new", ccn=30, loc=600,
          )]
          write_index(tmp_assess_dir, entries, last_updated="2026-06-07")
          content = (tmp_assess_dir / "index.md").read_text()
          legend = content.split("## Legend", 1)[1]
          for status in ("active", "new", "graduated", "regressed", "persistent"):
              assert f"- **{status}**" in legend, f"legend missing status {status!r}"
      
      
      def test_append_log_entry_creates_file_if_missing(tmp_assess_dir: Path) -> None:
          entry = LogEntry(
              run_date="2026-05-22", files_scored=100, readiness_score=4.5,
              maturity_label="Solid", instructions_grade="B+",
              graduated_count=1, regressed_count=0, new_count=0, persistent_count=2,
              top_action="Add complexity rules to .golangci.yml",
          )
          append_log_entry(tmp_assess_dir, entry)
          log = tmp_assess_dir / "log.md"
          assert log.exists()
          assert "2026-05-22" in log.read_text()
      
      
      def test_append_log_entry_appends(tmp_assess_dir: Path) -> None:
          (tmp_assess_dir / "log.md").write_text("# Assess Log\n\n## 2026-05-01\n\nOld entry.\n\n---\n")
          entry = LogEntry(
              run_date="2026-05-22", files_scored=100, readiness_score=4.5,
              maturity_label="Solid", instructions_grade="B+",
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="Action X",
          )
          append_log_entry(tmp_assess_dir, entry)
          content = (tmp_assess_dir / "log.md").read_text()
          assert "2026-05-01" in content  # old entry preserved
          assert "2026-05-22" in content  # new entry appended
          assert content.index("2026-05-01") < content.index("2026-05-22")
      
      
      def test_log_heading_includes_plugin_version(tmp_assess_dir: Path) -> None:
          """The log heading always carries the plugin version when known. This
          makes the log a version history at a glance and naturally disambiguates
          same-day runs across versions (issue #52 Bug 2)."""
          entry = LogEntry(
              run_date="2026-05-29", files_scored=100, readiness_score=4.5,
              maturity_label="Solid", instructions_grade="B+",
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="X", plugin_version="1.13.0",
          )
          append_log_entry(tmp_assess_dir, entry)
          content = (tmp_assess_dir / "log.md").read_text()
          assert "## 2026-05-29 (v1.13.0)" in content
      
      
      def test_log_heading_disambiguates_same_date_same_version(tmp_assess_dir: Path) -> None:
          """Two runs on the same date AT THE SAME version must produce distinct
          headings - otherwise GitHub anchors collide and markdownlint MD024
          fires. Second run appends a HH:MM timestamp inside the parentheses."""
          base = dict(
              run_date="2026-05-29", files_scored=100, readiness_score=4.5,
              maturity_label="Solid", instructions_grade="B+",
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="X", plugin_version="1.13.0",
          )
          append_log_entry(tmp_assess_dir, LogEntry(**base))
          append_log_entry(tmp_assess_dir, LogEntry(**base))
      
          content = (tmp_assess_dir / "log.md").read_text()
          headings = [line for line in content.splitlines() if line.startswith("## ")]
          assert len(headings) == 2
          # First heading has no time; second has a HH:MM stamp inside the parens.
          assert headings[0] == "## 2026-05-29 (v1.13.0)"
          assert headings[1].startswith("## 2026-05-29 (v1.13.0 ")
          assert headings[1].endswith(")")
          # No two identical headings (the bug condition).
          assert headings[0] != headings[1]
      
      
      def test_log_heading_same_date_different_versions_each_unique(tmp_assess_dir: Path) -> None:
          """Same-day runs at different plugin versions distinguish themselves
          via the version in the heading - no time stamp needed."""
          base = dict(
              run_date="2026-05-29", files_scored=100, readiness_score=4.5,
              maturity_label="Solid", instructions_grade="B+",
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="X",
          )
          append_log_entry(tmp_assess_dir, LogEntry(**base, plugin_version="1.12.0"))
          append_log_entry(tmp_assess_dir, LogEntry(**base, plugin_version="1.13.0"))
      
          content = (tmp_assess_dir / "log.md").read_text()
          assert "## 2026-05-29 (v1.12.0)" in content
          assert "## 2026-05-29 (v1.13.0)" in content
      
      
      def test_log_heading_omits_version_when_none(tmp_assess_dir: Path) -> None:
          """A caller that doesn't pass plugin_version (older code path) still
          works - the heading falls back to the bare date format."""
          entry = LogEntry(
              run_date="2026-05-29", files_scored=100, readiness_score=4.5,
              maturity_label="Solid", instructions_grade="B+",
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="X",
          )
          append_log_entry(tmp_assess_dir, entry)
          content = (tmp_assess_dir / "log.md").read_text()
          assert "## 2026-05-29" in content
          assert "(v" not in content
      
      
      def test_write_hotspot_page_creates_file(tmp_assess_dir: Path) -> None:
          write_hotspot_page(
              tmp_assess_dir,
              path="src/foo.go",
              first_flagged="2026-01-01",
              last_seen="2026-05-22",
              status="regressed",
              loc=600,
              ccn=30,
              commits=15,
              has_tests=False,
              history_rows="| 2026-01-01 | 500 | 25 | 8 | active |\n| 2026-05-22 | 600 | 30 | 15 | regressed |",
              briefing="Go API handler. Pairs with handler_test.go (which is missing).",
              actions="- Add `handler_test.go`\n- Split into smaller functions",
          )
          hotspots_dir = tmp_assess_dir / "hotspots"
          pages = list(hotspots_dir.iterdir())
          assert len(pages) == 1
          page = pages[0]
          assert page.name.startswith("src-foo-go-")
          assert page.name.endswith(".md")
          content = page.read_text(encoding="utf-8")
          assert "src/foo.go" in content
          assert "regressed" in content
          assert "handler_test.go" in content
      
      
      def test_write_hotspot_page_unknown_has_tests(tmp_assess_dir: Path) -> None:
          """When has_tests is None, the page shows 'unknown' (not 'no').
      
          Test pairing is a deferred feature; honest reporting beats false negatives.
          """
          write_hotspot_page(
              tmp_assess_dir,
              path="src/foo.go",
              first_flagged="2026-01-01",
              last_seen="2026-05-22",
              status="active",
              loc=600,
              ccn=30,
              commits=15,
              has_tests=None,
              history_rows="| 2026-05-22 | 600 | 30 | 15 | active |",
              briefing="Go API handler.",
              actions="- Investigate complexity",
          )
          page = next((tmp_assess_dir / "hotspots").iterdir())
          content = page.read_text(encoding="utf-8")
          assert "Has test file | unknown" in content
      
      
      def _hotspot_kwargs(**overrides: object) -> dict:
          """Baseline write_hotspot_page kwargs; overrides win."""
          base = dict(
              path="src/foo.go",
              first_flagged="2026-01-01",
              last_seen="2026-06-17",
              status="active",
              loc=600,
              ccn=30,
              commits=15,
              has_tests=None,
              history_rows="| 2026-06-17 | 600 | 30 | 15 | active |",
              briefing="Go API handler.",
              actions="- Investigate complexity",
          )
          base.update(overrides)
          return base
      
      
      def test_hotspot_page_includes_growth_profile_when_accreting(tmp_assess_dir: Path) -> None:
          """A file present in the accretion data gets one growth-profile line in the
          briefing - the monotonic-growth tendency named where an agent is briefed."""
          write_hotspot_page(tmp_assess_dir, **_hotspot_kwargs(accretion_data={
              "path": "src/foo.go", "net_additions": 420, "commit_count": 18,
              "deletion_fraction": 0.04, "time_span_months": 7.2, "reliable": True,
          }))
          page = next((tmp_assess_dir / "hotspots").iterdir())
          content = page.read_text(encoding="utf-8")
          assert "Growth profile: monotonic" in content
          assert "+420 LOC" in content
          assert "0 net reductions over 18 commits in 7 months" in content
          # No new section header - the line rides inside the existing briefing.
          assert "## Growth" not in content
      
      
      def test_hotspot_page_no_growth_profile_without_accretion_data(tmp_assess_dir: Path) -> None:
          """A file with no accretion entry (None) earns no line - growth that wasn't
          flagged as pure accretion is normal development, not a ratchet."""
          write_hotspot_page(tmp_assess_dir, **_hotspot_kwargs(accretion_data=None))
          page = next((tmp_assess_dir / "hotspots").iterdir())
          content = page.read_text(encoding="utf-8")
          assert "Growth profile" not in content
      
      
      def test_hotspot_page_growth_profile_defaults_to_none(tmp_assess_dir: Path) -> None:
          """accretion_data is optional: a caller that doesn't pass it still works and
          produces no growth line (back-compat with the pre-accretion call site)."""
          write_hotspot_page(tmp_assess_dir, **_hotspot_kwargs())
          page = next((tmp_assess_dir / "hotspots").iterdir())
          content = page.read_text(encoding="utf-8")
          assert "Growth profile" not in content
      
      
      def test_hotspot_page_growth_profile_disclaims_unreliable_history(tmp_assess_dir: Path) -> None:
          """reliable=False (shallow/squashed clone) still reports the profile but
          appends the incomplete-history disclaimer so the count isn't over-trusted."""
          write_hotspot_page(tmp_assess_dir, **_hotspot_kwargs(accretion_data={
              "path": "src/foo.go", "net_additions": 200, "commit_count": 5,
              "deletion_fraction": 0.02, "time_span_months": 3.0, "reliable": False,
          }))
          page = next((tmp_assess_dir / "hotspots").iterdir())
          content = page.read_text(encoding="utf-8")
          assert "Growth profile: monotonic" in content
          assert "history may be incomplete" in content
          assert "shallow/squashed repo" in content
      
      
      # --- run_id / schema_version provenance stamps (assess-obey-thyself) ----------
      
      
      def test_write_index_stamps_run_id_comment(tmp_assess_dir: Path) -> None:
          entries = [HotspotEntry(
              path="src/foo.go", first_flagged="2026-01-01", last_seen="2026-07-07",
              status="active", ccn=30, loc=600,
          )]
          write_index(
              tmp_assess_dir, entries, last_updated="2026-07-07",
              run_id="20260707120000-abcdef01", schema_version="1.0.0",
          )
          content = (tmp_assess_dir / "index.md").read_text()
          assert "<!-- assess:run_id=20260707120000-abcdef01 artifact_schema_version=1.0.0 -->" in content
      
      
      def test_write_index_no_run_id_is_byte_identical(tmp_assess_dir: Path) -> None:
          """Omitting run_id emits no comment - legacy callers get unchanged output."""
          entries = [HotspotEntry(
              path="src/foo.go", first_flagged="2026-01-01", last_seen="2026-07-07",
              status="active", ccn=30, loc=600,
          )]
          write_index(tmp_assess_dir, entries, last_updated="2026-07-07")
          content = (tmp_assess_dir / "index.md").read_text()
          assert "<!-- assess:run_id" not in content
      
      
      def test_append_log_entry_stamps_run_id_per_entry(tmp_assess_dir: Path) -> None:
          entry = LogEntry(
              run_date="2026-07-07", files_scored=100, readiness_score=0.0,
              maturity_label="(LLM fills in)", instructions_grade="B+",
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="x", plugin_version="1.54.0",
              run_id="20260707120000-abcdef01", schema_version="1.0.0",
          )
          append_log_entry(tmp_assess_dir, entry)
          content = (tmp_assess_dir / "log.md").read_text()
          assert "<!-- assess:run_id=20260707120000-abcdef01 artifact_schema_version=1.0.0 -->" in content
      
      
      def test_write_hotspot_page_stamps_run_id(tmp_assess_dir: Path) -> None:
          write_hotspot_page(
              tmp_assess_dir, path="src/foo.go", first_flagged="2026-01-01",
              last_seen="2026-07-07", status="active", loc=600, ccn=30, commits=5,
              has_tests=True, history_rows="| 2026-07-07 | 600 | 30 | 5 | active |",
              briefing="x", actions="- y",
              run_id="20260707120000-abcdef01", schema_version="1.0.0",
          )
          page = next((tmp_assess_dir / "hotspots").iterdir())
          content = page.read_text(encoding="utf-8")
          assert content.startswith("<!-- assess:run_id=20260707120000-abcdef01 artifact_schema_version=1.0.0 -->")
      
      
      # --- log.md integrity chain (assess-obey-thyself, task 11) --------------------
      
      
      def test_log_entry_carries_chain_marker(tmp_assess_dir: Path) -> None:
          """Every appended entry gets a 16-hex-char chain marker."""
          append_log_entry(tmp_assess_dir, _log_entry())
          content = (tmp_assess_dir / "log.md").read_text()
          import re
          markers = re.findall(r"<!-- chain:([0-9a-f]{16}) -->", content)
          assert len(markers) == 1
      
      
      def test_log_chain_verifies_valid(tmp_assess_dir: Path) -> None:
          """A log written only via append_log_entry verifies clean across many runs."""
          for i in range(3):
              append_log_entry(tmp_assess_dir, _log_entry(run_date=f"2026-07-0{i + 1}"))
          valid, broken_at = verify_log_chain(tmp_assess_dir)
          assert valid is True
          assert broken_at is None
      
      
      def test_log_chain_genesis_no_file(tmp_assess_dir: Path) -> None:
          """No log yet (fresh install) is vacuously valid - nothing to contradict."""
          valid, broken_at = verify_log_chain(tmp_assess_dir)
          assert valid is True
          assert broken_at is None
      
      
      def test_log_chain_first_entry_uses_genesis(tmp_assess_dir: Path) -> None:
          """The first entry chains off the literal 'genesis' - a lone entry verifies."""
          append_log_entry(tmp_assess_dir, _log_entry())
          valid, broken_at = verify_log_chain(tmp_assess_dir)
          assert valid is True
          assert broken_at is None
      
      
      def test_log_chain_hash_is_deterministic(tmp_path: Path) -> None:
          """Identical content in two fresh logs yields byte-identical chain markers."""
          import re
          a = tmp_path / "a"
          b = tmp_path / "b"
          a.mkdir()
          b.mkdir()
          append_log_entry(a, _log_entry())
          append_log_entry(b, _log_entry())
          marker_a = re.search(r"<!-- chain:([0-9a-f]{16}) -->", (a / "log.md").read_text())
          marker_b = re.search(r"<!-- chain:([0-9a-f]{16}) -->", (b / "log.md").read_text())
          assert marker_a is not None and marker_b is not None
          assert marker_a.group(1) == marker_b.group(1)
      
      
      def test_log_chain_detects_edited_prior_entry(tmp_assess_dir: Path) -> None:
          """Editing a prior entry breaks the chain; the next run detects the break at
          entry N and discloses it in the log."""
          append_log_entry(tmp_assess_dir, _log_entry(run_date="2026-07-01", top_action="First"))
          append_log_entry(tmp_assess_dir, _log_entry(run_date="2026-07-02", top_action="Second"))
          log_path = tmp_assess_dir / "log.md"
      
          # Tamper with the first entry's body after the fact.
          tampered = log_path.read_text().replace("First", "Tampered")
          log_path.write_text(tampered)
      
          valid, broken_at = verify_log_chain(tmp_assess_dir)
          assert valid is False
          assert broken_at == 1
      
          # Next run discloses the break in the log itself.
          append_log_entry(tmp_assess_dir, _log_entry(run_date="2026-07-03", top_action="Third"))
          content = log_path.read_text()
          assert "History integrity broken at entry 1" in content
      
      
      def test_log_chain_disclosure_added_once(tmp_assess_dir: Path) -> None:
          """A persistent break is disclosed once, not re-spammed on every later run."""
          append_log_entry(tmp_assess_dir, _log_entry(run_date="2026-07-01", top_action="First"))
          log_path = tmp_assess_dir / "log.md"
          log_path.write_text(log_path.read_text().replace("First", "Tampered"))
      
          append_log_entry(tmp_assess_dir, _log_entry(run_date="2026-07-02"))
          append_log_entry(tmp_assess_dir, _log_entry(run_date="2026-07-03"))
          content = log_path.read_text()
          assert content.count("History integrity broken at entry 1") == 1
      
      
      def test_log_chain_legacy_entry_without_marker_is_valid(tmp_assess_dir: Path) -> None:
          """A pre-chain log (entries with no markers) verifies as valid, and a new
          chained entry appended after it still verifies."""
          (tmp_assess_dir / "log.md").write_text(
              "# Assess Log\n\n## 2026-05-01\n\nOld entry.\n\n---\n"
          )
          valid, broken_at = verify_log_chain(tmp_assess_dir)
          assert valid is True and broken_at is None
      
          append_log_entry(tmp_assess_dir, _log_entry(run_date="2026-07-02"))
          valid, broken_at = verify_log_chain(tmp_assess_dir)
          assert valid is True and broken_at is None
      
      
      def test_log_heading_unique_within_minute(tmp_assess_dir: Path) -> None:
          """Three runs in the same second (same date, same version) with distinct run
          ids get three distinct headings (#317): HH:MM alone collided on the third
          same-minute run. The short run id is the disambiguator."""
          base = dict(
              run_date="2026-09-14", files_scored=1, readiness_score=1.0,
              maturity_label="x", instructions_grade="B",
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="none", plugin_version="9.9.9",
          )
          for rid in ("20260914101010-aaaa1111", "20260914101010-bbbb2222", "r7e8f9"):
              append_log_entry(tmp_assess_dir, LogEntry(**base, run_id=rid))
          content = (tmp_assess_dir / "log.md").read_text()
          headings = [line for line in content.splitlines() if line.startswith("## ")]
          assert len(headings) == 3
          assert len(set(headings)) == 3
          assert headings[0] == "## 2026-09-14 (v9.9.9, run aaaa1111)"
          assert headings[1] == "## 2026-09-14 (v9.9.9, run bbbb2222)"
          assert headings[2] == "## 2026-09-14 (v9.9.9, run r7e8f9)"
      
      
      def test_log_heading_unique_within_minute_no_version(tmp_assess_dir: Path) -> None:
          """The run id renders without a plugin version too."""
          entry = LogEntry(
              run_date="2026-09-14", files_scored=1, readiness_score=1.0,
              maturity_label="x", instructions_grade="B",
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="none", run_id="20260914101010-cccc3333",
          )
          append_log_entry(tmp_assess_dir, entry)
          content = (tmp_assess_dir / "log.md").read_text()
          assert "## 2026-09-14 (run cccc3333)" in content
      
      
      def test_log_heading_unique_within_minute_short_id_collision(tmp_assess_dir: Path) -> None:
          """Distinct run ids that share the 8-hex suffix still get distinct headings:
          the clash falls back to the full run id, then a counter."""
          base = dict(
              run_date="2026-09-14", files_scored=1, readiness_score=1.0,
              maturity_label="x", instructions_grade="B",
              graduated_count=0, regressed_count=0, new_count=0, persistent_count=0,
              top_action="none", plugin_version="9.9.9",
          )
          for rid in ("20260914101010-abcd1234", "20260914101011-abcd1234",
                      "20260914101011-abcd1234", "20260914101012-abcd1234"):
              append_log_entry(tmp_assess_dir, LogEntry(**base, run_id=rid))
          content = (tmp_assess_dir / "log.md").read_text()
          headings = [line for line in content.splitlines() if line.startswith("## ")]
          assert headings == [
              "## 2026-09-14 (v9.9.9, run abcd1234)",
              "## 2026-09-14 (v9.9.9, run 20260914101011-abcd1234)",
              "## 2026-09-14 (v9.9.9, run 20260914101011-abcd1234 #2)",
              "## 2026-09-14 (v9.9.9, run 20260914101012-abcd1234)",
          ]
      
    • __init__.py 0 B
  • pyproject.toml 3.2 KB
    [project]
    name = "assess"
    version = "0.1.0"
    description = "Deterministic core for /assess skill"
    requires-python = ">=3.11"
    dependencies = [
        "networkx>=3.0",
        "grimp>=3.0",
    ]
    
    [dependency-groups]
    dev = [
        "pytest>=8.0",
    ]
    
    [tool.pytest.ini_options]
    testpaths = ["tests"]
    # scripts/ holds the modules under test; tests/ is added so test modules can
    # import shared test helpers (e.g. `golden.py`, the dogfood-baseline normalizer).
    pythonpath = ["scripts", "tests"]
    addopts = "-v --tb=short"
    # Fixture repos under tests/fixtures/ contain test_*.py files that are scanned
    # as *data* by the test_pressure heuristics, not run as pytest tests. They
    # import from their own `src/` package which isn't on the path, so collecting
    # them errors. Exclude the whole fixtures tree from collection.
    norecursedirs = ["tests/fixtures"]
    
    [tool.ruff]
    target-version = "py311"
    # Test fixtures are deliberately messy sample repos consumed as *data*, not
    # code we own. The doc-graph and test-pressure scans already exclude them; the
    # linter must too, or it grades the fixtures instead of the core.
    extend-exclude = ["tests/fixtures"]
    
    [tool.ruff.lint]
    # Ruff's default rule set (E4/E7/E9 pycodestyle + F pyflakes) plus the mccabe
    # cyclomatic-complexity gate (C901). This is the Layer 3 "no linter" fix from
    # issue #77: a load-bearing complexity ratchet, not a style overhaul.
    select = ["E4", "E7", "E9", "F", "C901"]
    
    [tool.ruff.lint.per-file-ignores]
    # Package __init__.py files deliberately re-export names (some private, e.g.
    # test_pressure's shutil/subprocess/_parse_* surfaced for test monkeypatching);
    # F401 "imported but unused" is the wrong signal there.
    "**/__init__.py" = ["F401"]
    
    [tool.ruff.lint.mccabe]
    # Per-function cyclomatic complexity. 15 fences current reality: every function
    # already sits at or below it except four genuine offenders (ccn 17-21), which
    # carry an explicit ``# noqa: C901`` with a note - build_doc_graph and
    # authorship_analysis here, render in doc-graph-svg.py, and
    # build_standalone_skill_zip in scripts/. The gate fails the moment any other
    # function regresses past 15; ratchet down as the four are decomposed.
    max-complexity = 15
    
    [tool.mypy]
    # Issue #79: type hints existed but were unenforced. This gate makes them a
    # contract. Scoped to the deterministic core (lib/) where the annotations are
    # densest and most load-bearing, plus the orchestrator assess_core.py (the
    # most-churned file, ratcheted in here). The remaining orchestrator scripts
    # (assess_finalize.py, complexity-treemap.py) and the build pipeline are the
    # next ratchet step.
    files = ["scripts/lib", "scripts/assess_core.py"]
    # lib/ is a package; treat scripts/ as the import base so lib.<mod> resolves and
    # the back-compat shims (scripts/stats_diff.py, lib/assess_core.py) don't collide
    # as duplicate top-level modules.
    mypy_path = "scripts"
    explicit_package_bases = true
    namespace_packages = true
    # networkx and grimp ship no type stubs; their absence is not a code defect.
    # The lib modules carry defensive ``# type: ignore[assignment]`` on their
    # degrade-gracefully ``nx = None`` fallbacks so they also type-check when stubs
    # ARE installed; ``warn_unused_ignores`` is left off so those stay put.
    ignore_missing_imports = true
    warn_redundant_casts = true
    
  • SKILL.md 51.4 KB
    ---
    name: assess
    description: "Assess a codebase's readiness for AI agent contributors using the layered contract model, and generate a complexity hotspot SVG treemap (size = LOC, hue = cyclomatic complexity, saturation = recent git churn). TRIGGER when the user types /assess, asks for an AI-readiness review, wants a complexity heatmap or hotspot map, asks 'how complex is this code?', wants migration risk triage, or asks for a codebase snapshot/report. Produces an MD report + SVG that can be opened as a PR in the target repo."
    ---
    
    # AI Readiness Assessment + Complexity Hotspot
    
    Three artefacts in one pass against a target repo:
    
    1. **Layered contract assessment** - 0-8 score across navigability, runtime liveness, code design, linters, architecture tests, CI, coverage, review bots, and AI project management.
    2. **Complexity hotspot SVG** - Codecov-style treemap of the code. Size = LOC. Colour = cyclomatic complexity. Saturation = recent git churn. Vivid red = complex AND active = riskiest to change.
    3. **Doc navigability SVG** - a node-graph of the docs. Structure = connectivity (centre = entry, rim = unreachable, dashed ring = orphan, solid edge = link, dotted edge = reference); colour = staleness (vivid red = a frozen doc beside churning code = a *lying map*); size = file length. Folds navigability and the decaying-map signal into one artifact.
    
    Both SVGs are colour-blind-safe by default (OrRd ramp, no red-green).
    
    All land as files inside the target repo. The skill always writes them locally; after writing, **ask the user** whether to open a PR in the target repo with the artefacts.
    
    ## The model: truth-pressure, not presence
    
    Read this before scoring - it changes how you score. Across every layer, the real signal is never **presence**. It is whether a thing is under **active pressure to stay true**:
    
    - Tests keep **behaviour** honest (CI fails when it's wrong).
    - Retros / feedback loops keep the **process** honest (Layer 8 scores whether retros are *carried out*, not merely present).
    - Maintenance keeps **docs** honest (a wiki tracked against code churn).
    - Telemetry / liveness keeps **relevance** honest (is this code actually exercised).
    
    So **AI-readiness is the degree to which a codebase's self-descriptions are kept honest, not the degree to which scaffolding exists.** Score artefacts on *maintenance pressure*, not existence. A stale-but-present doc scores **at or below absent**: missing makes the agent go look; confidently-stale makes it navigate fast to a wrong, current-looking conclusion.
    
    The 9 layers (0-8) fall into three bands, ordered by dependency - what must hold for the next band to mean anything:
    
    - **Read-side foundation** (L0 navigability, L1 liveness) - can the agent form a *true picture* before it acts?
    - **Write-side enforcement** (L2-L7) - can the agent be trusted to produce good output? Only means something once you can trust that what you're reading is real and current.
    - **Meta** (L8 feedback) - does the system keep itself honest over time? Depends on a working enforced system to improve, so it stays last.
    
    ### The three write-side tendencies the layers guard against
    
    The write-side scores aren't abstract good practice - each traces to a known tendency of an AI contributor, observed across models. All three are the same defect: a self-description (the file's shape, a comment's promise, a gate's verdict) under no pressure to stay true. The deterministic core turns each into a cross-layer finding so the report names the specific files, not just the category:
    
    - **Accretion** - an agent does what is asked, and what is asked is feature after feature; nothing in that loop asks for a refactor, so files only grow. **Now fully instrumented** via the `accretion_ratchet` finding: a file whose accumulated line count ratcheted monotonically upward across multiple commits with almost no deletion pressure (deletions below ~15% of total churn). Only top complexity/size-band files are flagged, never documentation, so growth-but-simple is never noise; `archive/`, `archived/` and `attic/` paths stay out of the attention list, disclosed in `excluded_as_archive`. It surfaces on three surfaces - the `accretion_ratchet` block in `run-context.json`, the `accretion_ratchet` cross-layer finding (with its files in the attention list), and a *growth-profile* line on each flagged hotspot page (`hotspots/*.md`). The signal disclaims itself (rather than dropping the result) when the git history is degenerate - a shallow clone or squashed import has no meaningful net-delta sequence, so the block carries `reliable: false` and the hotspot line is marked as possibly incomplete.
    - **Unactioned intent** - an agent records promises it never returns to keep (`TODO` / `FIXME` / "remove after migration"). Instrumented via the `unactioned_intent` finding: markers aged by the edits they survived without being kept - a lying map of intent.
    - **Guardrail erosion** - under pressure to make red go green, an agent loosens the check instead of fixing the root (a suppression, a skipped test, a widened threshold), hollowing out the layers meant to protect it while they still read as Present.
    
    ### Repository archetype (not every repo is software)
    
    The 0-8 model assumes a software repo. A **knowledge / document base** - markdown sources, an LLM-maintained wiki, a `CLAUDE.md` schema, and no application code or runtime - has no code surface for the write-side layers (L2-L7). Scoring them Missing is itself a lying score: a well-run KB reads ~2.5/8 ("Not Ready") when it is in fact well-run, penalised for not testing code it doesn't contain.
    
    The deterministic core (`lib/archetype.py`) classifies the repo and writes an `archetype` block to `run-context.json`:
    
    - **Detection** is a heuristic - the code-file ratio (code vs markdown) and the absence of a runtime surface (`package.json`, `pyproject.toml`, `go.mod`, `Dockerfile`, ...). A documentation-heavy *application* (lots of markdown but a real build) stays software because of the runtime-surface gate.
    - **Override marker.** An `assess-archetype: knowledge-base` (or `software`) marker in any instruction file (`CLAUDE.md`/`AGENTS.md`/...) **forces or suppresses** detection, so a maintainer is never trapped by a misfire. Write it as an HTML comment, e.g. `<!-- assess-archetype: knowledge-base -->`.
    - **Scoring.** For a detected knowledge base the write-side layers (2-7) are scored **N/A** (not Missing) and **excluded from the denominator**; the headline renormalises over the applicable layers (L0, L1, L8 → denominator 3) and the maturity label names the archetype and the applicable-layer count (e.g. `Knowledge Base · Solid (3 applicable layers)`). A software repo is unaffected - all 0-8 layers, denominator 8.
    - **KB-maintenance signal.** `archetype.kb_maintenance` flags whether the repo documents *how the AI maintains the KB* - the [Karpathy LLM-wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) (immutable raw sources, the schema file as the product, an ingest workflow, query-as-filing, periodic lint/consolidation). It is both a detection signal and a scored read-side (Layer 0) quality signal; the gist is cited in the report as the best-practice pointer whether or not the workflow is documented.
    
    This is intentionally **one** archetype (knowledge base), structured as an extensible dispatch so more are cheap to add later - not a general archetype framework (YAGNI). The `assess-layer-scorer` agent reads the block (its Step 0) and the `assess-findings` skill renders N/A layers and the renormalised headline.
    
    <!-- chat-skip:start -->
    **$ARGUMENTS**
    <!-- chat-skip:end -->
    
    ## Step 1: Determine Repo Root and Output Directory
    
    ```bash
    git rev-parse --show-toplevel   # from the arg path if given, else pwd
    ```
    
    Set `$REPO_ROOT` to the result. All scanning happens from here.
    
    **Scoping a subtree (`/assess <path>`).** When the argument is a directory under the repo root, scope the whole run to it - metrics, score, badge, wiki, and gate all computed for and labelled with the scope, artifacts under `.assess/<scope-slug>/`, no signal from a sibling. Pass `--scope "$SCOPE"` to `complexity-treemap.py` and `assess_core.py` and swap `.assess/` for `.assess/<slug>/` throughout. Full recipe: `references/monorepo-scoping.md` (relative to this skill dir). A no-path run is whole-repo, unchanged.
    
    Decide the output directory (default: `$REPO_ROOT/.assess/`). Create it if needed:
    
    ```bash
    mkdir -p "$REPO_ROOT/.assess"
    ```
    
    Artefacts will land at:
    - `$REPO_ROOT/.assess/complexity-heatmap.svg`
    - `$REPO_ROOT/.assess/complexity-stats.json`
    - `$REPO_ROOT/.assess/doc-graph.svg`
    - `$REPO_ROOT/.assess/assess-report.md`
    
    > **Write-protected repo root?** `/assess` writes `.assess/` into `$REPO_ROOT`, and the treemap/core run as `uv` subprocesses that write there too. If your workflow keeps the repo root pristine and read-only (e.g. a `<repo>-main` clone that teammates branch from, with a hook blocking direct edits), a guard on *your* writes won't stop the subprocess - it just makes the run write into the directory you meant to protect. **Create a worktree first and run `/assess` from there.**
    
    ## Step 2: Generate the Code Heatmap + Doc Graph
    
    This step produces **two** views of the codebase, both colour-blind-safe (OrRd ramp, no red-green):
    
    - **Complexity heatmap** (`complexity-heatmap.svg`) - a treemap of the *code*. Size = LOC, colour = cyclomatic complexity, saturation = recent churn. Vivid red = complex AND active = "hard to change safely".
    - **Doc navigability graph** (`doc-graph.svg`) - a node-graph of the *docs*. Structure shows connectivity (centre = entry point, rings = link-distance, rim = unreachable; orphans carry a dashed ring; solid edges are links, dotted edges references); colour shows staleness in the same grammar as the code heatmap (vivid red = a frozen doc beside churning code = a lying map); size = file length. It folds both Layer 0 doc signals - navigability and the decaying-map - into one artifact. Beyond static wikilinks and CommonMark links, it counts a backticked path to an existing doc as a reference edge (a cited `.claude/` file included) and recognises Obsidian vault-native navigation - `.base` view hubs and `dataview` query blocks - as edges (resolved statically by folder / tag / frontmatter predicate), so a vault navigated by dynamic queries isn't mis-scored as orphaned. The SVG and the scored signal compute over the identical doc set: both honour the same excludes (`.assess/config.toml`).
    
    Feed the complexity stats into the linter/complexity layer (Layer 3) and the `doc_graph` / `doc_staleness` blocks of `run-context.json` into **Layer 0** (the graph SVG is the visual; the score reads the structured blocks).
    
    ### The consent lifecycle (read `references/consent-lifecycle.md`)
    
    Steps 2a/2b/2d and the assess-pr end-of-run offers share one consent model, **specified in full in `references/consent-lifecycle.md`** (relative to this skill dir) - load it before running the offers. Load-bearing hooks the steps rely on: decline markers carry provenance (write `.no-<tool>` JSON via the reference's `write_decline_marker` helper, never a bare `touch`; a mutation decline under an older plugin *major* sets `reoffer_mutation: true` so Step 2d re-asks once); three phases each a single batched question (Phase 1 tool installs 2a+2b, Phase 3 the separate mutation pass 2d, Phase 2 the assess-pr write-back offers); and the non-interactive contract - a headless/CI run makes **no AskUserQuestion calls in any phase**, Phase 1 as orchestration from your runtime context and Phases 2/3 from the core's `run-context.json .interactive` flag, which pre-records every skipped offer in `.offers`.
    
    ### 2a: Detect `scc` need (feeds Phase 1)
    
    The bundled treemap uses [`lizard`](https://github.com/terryyin/lizard) (Python, Go, JS, Java, C/C++, etc.) by default. Optional `scc` extends coverage to 200+ languages including markdown, JSON, YAML, SQL, and shell - useful when the repo's surface is more than just traditional source code.
    
    Before scanning, check three signals:
    
    ```bash
    # 1. Is scc already on PATH?
    command -v scc >/dev/null 2>&1 && SCC_PRESENT=1 || SCC_PRESENT=0
    
    # 2. Has the user previously declined for this repo?
    [ -f "$REPO_ROOT/.assess/.no-scc" ] && SCC_DECLINED=1 || SCC_DECLINED=0
    
    # 3. Is the repo mostly markdown/data/config (where lizard alone will be sparse)?
    #    Cheap heuristic: count non-code files vs code files. The `.` argument is
    #    the regex pattern (matches every path) and "$REPO_ROOT" is the search
    #    path - without `.`, fd treats $REPO_ROOT as the pattern itself, matches
    #    nothing, and silently returns 0.
    CODE_FILES=$(fd -t f -e py -e js -e ts -e tsx -e jsx -e go -e java -e kt -e rs -e rb -e cs -e swift -e dart -e cpp -e c -e h -e php . "$REPO_ROOT" 2>/dev/null | wc -l | tr -d ' ')
    NONCODE_FILES=$(fd -t f -e md -e json -e yaml -e yml -e toml -e sh -e sql . "$REPO_ROOT" 2>/dev/null | wc -l | tr -d ' ')
    ```
    
    **Add `scc` to the Phase 1 offer list only if all three are true:** `SCC_PRESENT=0`, `SCC_DECLINED=0`, and the repo looks lizard-sparse (`CODE_FILES < NONCODE_FILES` or `CODE_FILES < 10`). Otherwise it contributes nothing to Phase 1. Do **not** ask here - `scc` is batched with the dead-code tools into the single Phase 1 question in Step 2b. Its trade-off phrasing, for when the batched question is presented:
    
    > "This repo has <N> code files and <M> non-code files (markdown/JSON/YAML). `scc` would include the non-code files in the treemap; without it the treemap may be sparse. Install `scc`?"
    
    The three options are the shared Phase 1 shape (**Install** / **Skip for now** / **Skip permanently** via `write_decline_marker scc`). If the user accepts, run the platform-appropriate command (do **not** auto-install - `brew install` is a system mutation):
    
    ```bash
    # macOS (Homebrew)
    [ "$(uname)" = "Darwin" ] && command -v brew >/dev/null && brew install scc
    
    # Linux (try common package managers, fall back to go install or manual)
    [ "$(uname)" = "Linux" ] && {
      command -v apt >/dev/null && sudo apt install -y scc \
        || command -v dnf >/dev/null && sudo dnf install -y scc \
        || command -v go >/dev/null && go install github.com/boyter/scc/v3@latest \
        || echo "Install scc manually: https://github.com/boyter/scc#installation"
    }
    ```
    
    If the install fails or the platform isn't covered, fall back to lizard-only and continue - don't block the assessment.
    
    ### 2b: Phase 1 - batched analysis-tool install offer (capability-driven, detect-or-propose)
    
    `/assess` maps each Layer 1/Layer 3 analysis **capability** (liveness/dead-code, static module graph, linting, modernization) to a serving tool. Historically that map was a **hardcoded per-language allowlist** - `vulture` for Python, `ts-prune`/`knip` for TS/JS, `staticcheck`/`deadcode` for Go. The defect that allowlist created: when a repo's language **isn't enumerated**, every capability silently degraded to "unavailable" - the report read "this layer is absent here" rather than "a tool could serve this - install one?". A non-enumerated language was locked out with no resolution path inside the run.
    
    The flow is now **capability-driven detect-or-propose**, in three moves per capability:
    
    1. **Detect** whether a serving tool already exists (on PATH, or configured in build/lint config). If it does, **use it** - and if it's configured in the build, **credit it; never re-offer**.
    2. **Propose** an ecosystem-appropriate candidate when none exists. For an enumerated language this is the table below; for a non-enumerated one **you propose a fitting tool at runtime** (reasoned latitude - you are not locked out because the language isn't in a hardcoded list). Ask the user with the same **AskUserQuestion** pattern.
    3. **Honest-degrade** anything you can detect-but-not-serve: name the capability **and** a candidate tool in the report. This is a deliverable state distinct from both "Present" and a silent "Missing" - never let a capability vanish without naming what would serve it.
    
    The per-language dead-code offer below is the simplest instance (one capability, install-consent). When the tool is absent, the scan degrades to `tool_absent` and the user has no resolution path inside the skill - they'd have to know which tool fits the language, which package manager to use, and run the install themselves. The same install-offer pattern as Step 2a closes the loop without leaving them to figure it out.
    
    Detect languages with cheap `fd` counts (mirroring Step 2a's heuristic - the treemap script's own classification isn't exposed in the stats sidecar, and shelling out is fine here):
    
    ```bash
    PY_FILES=$(fd -t f -e py . "$REPO_ROOT" 2>/dev/null | wc -l | tr -d ' ')
    TS_FILES=$(fd -t f -e ts -e tsx . "$REPO_ROOT" 2>/dev/null | wc -l | tr -d ' ')
    GO_FILES=$(fd -t f -e go . "$REPO_ROOT" 2>/dev/null | wc -l | tr -d ' ')
    
    # Per-language candidate tool. Prefer the read-only tool first - `ts-prune` over
    # `knip` for TS, `staticcheck` over `deadcode` for Go - so the user isn't asked
    # twice for the same job and the chosen tool doesn't need to build the project.
    needs_offer() {
      # Args: tool, file count, min; 0 = ask. Braced: skill-arg substitution skips them.
      local tool="${1}" count="${2}" min="${3:-5}"
      [ "$count" -ge "$min" ] || return 1
      command -v "$tool" >/dev/null 2>&1 && return 1     # already installed
      [ -f "$REPO_ROOT/.assess/.no-$tool" ] && return 1  # user declined permanently
      return 0
    }
    
    OFFERS=()  # each entry: "language|tool|install_cmd"
    # Seed with scc first when Step 2a flagged it (the treemap-coverage tool shares
    # this one batched question with the per-language dead-code tools).
    [ "${SCC_PRESENT:-1}" = 0 ] && [ "${SCC_DECLINED:-0}" = 0 ] \
      && { [ "${CODE_FILES:-0}" -lt "${NONCODE_FILES:-0}" ] || [ "${CODE_FILES:-0}" -lt 10 ]; } \
      && OFFERS+=("coverage|scc|brew install scc (or apt/dnf/go install - see Step 2a)")
    needs_offer vulture "$PY_FILES"      && OFFERS+=("python|vulture|pip install vulture (or 'uv tool install vulture')")
    needs_offer ts-prune "$TS_FILES" && [ -f "$REPO_ROOT/tsconfig.json" ] && OFFERS+=("typescript|ts-prune|npm install -g ts-prune")
    needs_offer staticcheck "$GO_FILES"  && OFFERS+=("go|staticcheck|go install honnef.co/go/tools/cmd/staticcheck@latest (or 'brew install staticcheck')")
    ```
    
    If `OFFERS` is empty (no language hits the threshold, or every tool is already installed/declined), skip straight to 2c. **Non-interactive short-circuit:** Phase 1 precedes the core, so in a headless/CI run make **no** AskUserQuestion call and install nothing - proceed to 2c with lizard-only plus whatever is already on PATH (the core records the skip in `offers` at 2c).
    
    Otherwise, in an interactive run, batch **all** of Phase 1 into **a single AskUserQuestion call** - one question per entry in `OFFERS` (scc and each dead-code tool together), three options per question. This is the one tool-install decision surface; the user never faces scc and the linters as separate modals:
    
    - **Install <tool>** - run the cited install command and continue.
    - **Skip for now** - proceed without the tool. Don't write a marker; ask again next run.
    - **Skip permanently for this repo** - `write_decline_marker <tool>` so future runs don't ask. Recommended when the language only appears in scripts/configs that don't warrant symbol-level reachability.
    
    Phrase each question so the gain is concrete, e.g.:
    
    > "This repo has 47 Go files. `staticcheck -checks U1000` would let `/assess` flag unreachable Go funcs as Layer 1 candidates. Install? (`go install honnef.co/go/tools/cmd/staticcheck@latest` or `brew install staticcheck`)"
    
    When the user picks **Install <tool>**, run the platform-appropriate command from the offer. Surface any install failure as a chat message and continue - dead-code tools are degrade-don't-block (same contract as scc); a missing tool reduces Layer 1's precision but never gates the assessment. When they pick **Skip permanently**, `write_decline_marker <tool>` (e.g. `write_decline_marker staticcheck`). The user answers this one batched question once and the run proceeds with whichever tools they accepted.
    
    #### JVM / Maven capability offers (v1)
    
    When the deterministic core detects a Maven or Gradle project (a build file plus at least one `.java`, `.kt`, `.scala` or `.groovy` file outside platform-wrapper `android/` directories, Cordova's `platforms/android/` included: a Flutter, React Native, Capacitor or Cordova shell is not a JVM codebase) it emits a `capability_offers` block in `run-context.json` - the first proof of the capability-driven flow on a non-enumerated ecosystem. Read it after Step 2c's core run, before scoring, and act on each capability's `state`:
    
    ```bash
    jq '.capability_offers' "$REPO_ROOT/.assess/run-context.json"
    ```
    
    - **`liveness` → `state: "offer"`** - Maven was detected but `mvn dependency:analyze` (coarse module-level dead-dependency detection) has not run. The `consent` field names the shape: `run` (`mvn` is on PATH - offer to **run** it against the project; `dependency:analyze` needs a *compiling build*, so this is a **run-consent**, heavier than a static scan) or `install` (`mvn` absent - offer to **install** Maven first). Use **AskUserQuestion** exactly as Step 2b, phrasing the trade-off (a build that resolves dependencies and may hit the network) - but honour the non-interactive contract: when `run-context.json .interactive` is `false`, skip this offer and honest-degrade the capability instead of prompting. On accept and a `run` consent, run `mvn dependency:analyze`, capture its output, and re-run the core with the served result so the candidates feed Layer 1. On decline, the capability stays honestly named, not silently dropped.
    - **`linting` / `modernization` → `state: "credited"`** - an already-configured pom.xml plugin serves it (`served_by` lists which: Checkstyle, SpotBugs, PMD, error-prone, OpenRewrite, Modernizer). **Credit it in the report; do not re-offer.**
    - **Any capability → `state: "honest_degrade"`** - nothing serves it yet (module graph, linting/modernization without a configured plugin, and **all** capabilities under Gradle in v1). The block carries a `candidate_tool` and `gloss`. **Name both in the report's Layer 1/Layer 3 prose** ("module-graph analysis is unserved here; `jdeps` would provide it"). Honest-degrade is a deliverable - surfacing the candidate is the point.
    
    **Boundary (v1).** Only Maven liveness is *served*. Module graph (`jdeps`), linting, and modernization honest-degrade; Gradle honest-degrades entirely. The `candidate_tool` values are deterministic defaults - you may propose a better-fitting ecosystem tool at runtime (the detect-or-propose latitude above); that choice is human-judged, not CI-tested. CI tests only **signal consumption**: given a tool's output, the scorecard feeds correctly.
    
    ### 2c: Run the treemap
    
    Run the bundled treemap script alongside the deterministic core - see the chained block below.
    
    The script prints a one-line summary (file count, lizard vs scc coverage, churn window chosen, top 5 biggest files). The stats sidecar contains percentiles (p50/p95/max for LOC, CCN, churn) and ranked lists of the top 10 files by hotspot score, raw CCN, and raw LOC. Both feed the report.
    
    **Dependencies:** the script uses PEP 723 inline metadata (`lizard`, `squarify`, `matplotlib`, `numpy`). `uv` resolves them on first run.
    
    **Build artifacts, generated test reports and generated code are filtered by default** (full list in `complexity-treemap.py`'s `EXCLUDE_DIRS`, `EXCLUDE_FILE_PATTERNS` and `EXCLUDE_NESTED_PATH_PATTERNS`; pass `--include-artifacts` to score them, e.g. to visualise how much of the repo is generated). The script excludes three classes of files:
    
    - **Build artifacts**: `main.dart.js`, Flutter canvaskit/skwasm runtime bundles (`canvaskit.js`, `skwasm*.js`), `*.min.js`, `*.bundle.js`, `*.chunk.js`, `*.map`, sourcemaps, service workers, and files under `node_modules/`, `dist/`, `build/`, `.next/`, `.nuxt/`, `.output/`, `coverage/`, etc.
    - **Generated test reports**: `html-report/`, `playwright-report/`, `lighthouse-report.html`, `lighthouse-results.json`, `zap-report.*`, and `*.jsonl` under a `fixtures/` directory below the top level. When the 5 largest files are all JSON, YAML or JSONL scored by scc with complexity 0, stderr hints at `.assess/config.toml` excludes.
    - **Generated code**: protobuf bindings (`*.pb.go`, `*_grpc.pb.go`, `*.pb.gw.go`, `*.connect.go`, `*_pb.ts`, `*_pb.d.ts`, `*_pb2.py`, `*.pb.cc`, `*.pb.h`), Go generators (`*.gen.go`, `wire_gen.go`, `zz_generated_*.go`, `bindata.go`), .NET source generators (`*.designer.cs`, `*.g.cs`), Dart/Flutter codegen (`*.freezed.dart`, `*.g.dart`, `*.gr.dart`), `*.generated.*`, `*.gen.ts`, `database.types.ts`, and any file with a comment in its first 5 lines carrying a generator marker (`DO NOT EDIT`, `@generated`, etc.; reason `generated-header`) or whose average line exceeds 1,000 characters (reason `long-lines`). Content-matched files are listed in `excluded_generated` (stats file and `run-context.json`), which the report and gate disclose.
    
    **Dominance warning.** If a single file still holds >30% of total scoreable LOC after filtering (the threshold compiled bundles typically cross), the script prints a warning to stderr identifying the file. When you see this, the right next step depends on *why* the file is large:
    
    - **Compiled bundle or committed build output** (`main.dart.js`, a bundled JS file, etc.): surface in the report's "Hotspot snapshot" section as "`<file>` holds X% of LOC and is likely a build artifact - recommend adding to `.gitignore` and re-running." Add a Top 3 Action of the same shape.
    - **Intentionally-tracked reference data** (regulatory raw exports, vetted-context corpora, seed datasets, large CSV/JSON reference tables): the file is *meant* to be in git but isn't source code. The fix is `--exclude`, not `.gitignore`. Recommend the user persist the rule in `.assess/config.toml` so subsequent runs apply it automatically (see "Custom excludes" below). Do not push toward `.gitignore` in this case.
    - Either way, do NOT skip the rest of the assessment - the layered scan still produces useful signal.
    
    **Custom excludes for vetted-context / reference data.** When the repo intentionally tracks large non-source files, two mechanisms extend the built-in defaults (the built-ins always apply; both layers are additive). **The same excludes apply across every scan** - the heatmap, the doc-navigability graph, the doc-staleness pass, and the liveness scan all honour the same list, so "this is reference data, not source" is a single statement, not a per-layer toggle:
    
    1. **CLI flag** `--exclude PATTERN` (repeatable, ad-hoc). A plain string is matched as a directory name; a glob is matched against the basename. The flag exists on the treemap script for one-off runs:
    
       ```bash
       <!-- chat-replace:treemap-exclude-example -->
       uv run "${CLAUDE_SKILL_DIR}/scripts/complexity-treemap.py" "$REPO_ROOT" --exclude regulatory-raw --exclude vetted-context --exclude '*.csv'
       ```
    
    2. **Per-repo config** `.assess/config.toml` (durable, version-controllable, applies to **every** scan via the orchestrator). Recommended for any exclude the user will want to apply every run:
    
       ```toml
       exclude_dirs = ["regulatory-raw", "vetted-context", "seed-data"]
       exclude_patterns = ["*.csv", "*.parquet"]
       ```
    
       No section header is needed - the file is already namespaced by living under `.assess/`. Missing or malformed files degrade silently to no extra excludes; the assessment never blocks on a broken config.
    
    **Provenance for generated docs (staleness measured against the source).** A *generated* doc - a Jira note dump, an API reference, codegen output - is not stale because the file is old; it is stale when the **source it was derived from has moved on**. The mtime/age model gets this backwards: a freshly regenerated dump of 1,200 notes shares one recent mtime (looks fresh) even when its source changed afterwards, and an old-but-accurate generated doc reads as a lying map when it is not. Declare provenance and doc-staleness is computed as "is the source newer than the doc?" instead - a generated doc whose source is quiet is never flagged as a `lying_map`, regardless of how busy the surrounding code is. Two ways to declare it (frontmatter wins when both name a source for the same doc):
    
    1. **Frontmatter** on the generated doc - a `source:` key (a string or a list), resolved relative to the repo root first, then to the doc's own directory. An optional `generated_by:` records the generator for humans (it does not affect staleness):
    
       ```markdown
       ---
       source: data/jira.tsv
       generated_by: scripts/dump-jira-notes.py
       ---
       ```
    
    2. **Per-repo config** `.assess/config.toml` `[[generated]]` array-of-tables, for bulk-generated trees that cannot each carry frontmatter. `path` is a folder relative to the repo root; every doc under it inherits the mapping. `source` is a string or list of strings relative to the repo root:
    
       ```toml
       [[generated]]
       path = "notes"
       source = "data/jira.tsv"
       ```
    
       When a generated doc's source is newer than the doc, the staleness verdict is a direct, high-confidence source-vs-doc comparison (git commit time, falling back to mtime) - so a stale generated doc over complex code still surfaces as a `lying_map`, while a fresh one never does.
    
    The script's own output directory `.assess/` is excluded automatically - prior runs' `run-context.json` and SVGs never feed the next run's heatmap, the doc graph, or the dead-code scan. Test fixtures under `**/tests/fixtures/**` are likewise excluded automatically - they are inputs that exercise the scanners (sample `CLAUDE.md` / monolithic-instruction files), not navigational docs or live code, so counting them would inflate the orphan rate and depress the Layer 0 navigability read.
    
    **Raw-source-tree exclusion.** The read-side metrics (orphan rate, reachability, broken links) describe the **curated wiki** - the navigable layer an agent traverses. A repo can also track trees of raw, machine-extracted source documents (a disclosure / SAR export of hundreds of `.msg`/`.pdf`/`.docx` files converted to markdown). Those are immutable raw sources: they legitimately have no inbound wiki links and carry machine-extracted, non-navigational links (`mailto:`/`tel:`/footer URLs), so counting them as orphans / broken links inflates the figures and masks the curated signal. The doc graph auto-detects such subtrees - threshold-based: a large subtree that is almost entirely link-isolated *and* carries the machine-extraction fingerprint (`lib/raw_source.py`) - and **excludes** them from the headline metrics, reporting each excluded tree + its file count (`doc_graph.excluded_raw_trees`) and the raw layer's own figures separately (`raw_source_doc_count` / `raw_source_orphan_rate` / `raw_source_broken_links`). A second fingerprint excludes working-notes trees the same way: pattern-named notes (plans, session logs, tickets) mostly linked once from one or two index files, reported as `excluded_working_notes_trees` / `working_notes_doc_count`; in `.assess/config.toml`, directories relative to the repo root (a prefix, not a name matched anywhere like `exclude_dirs`): `working_notes_dirs = ["journal"]` excludes one from the headline and `working_notes_ignore = ["docs/chapters"]` keeps one counted. A repo with neither tree is unaffected. The detection reuses the link graph already built, so there is no second parse.
    
    **If the script fails** (no `uv`, no scoreable files, etc.), record the error in the report under "Hotspot snapshot" as "could not be generated - <reason>" and continue with the layered assessment. The treemap is additive; assessment still runs without it.
    
    Run the full sequence - rotate the prior sidecar first, then the treemap, then the deterministic core:
    
    ```bash
    # Rotate the prior stats sidecar so the diff has something to compare against next run
    if [ -f "$REPO_ROOT/.assess/complexity-stats.json" ]; then
      cp "$REPO_ROOT/.assess/complexity-stats.json" "$REPO_ROOT/.assess/complexity-stats.prior.json" 2>/dev/null || true
    fi
    
    
    # Run the complexity treemap (produces fresh complexity-stats.json)
    # (single line: the standalone transform replaces the marker + one following line)
    <!-- chat-replace:uv-treemap -->
    uv run "${CLAUDE_SKILL_DIR}/scripts/complexity-treemap.py" "$REPO_ROOT" -o "$REPO_ROOT/.assess/complexity-heatmap.svg" --stats "$REPO_ROOT/.assess/complexity-stats.json"
    
    # Run the doc navigability graph (connectivity + staleness in one SVG; feeds Layer 0)
    <!-- chat-replace:uv-doc-graph -->
    uv run "${CLAUDE_SKILL_DIR}/scripts/doc-graph-svg.py" "$REPO_ROOT" -o "$REPO_ROOT/.assess/doc-graph.svg"
    
    # Run the deterministic core (instruction grading, doc link-graph, doc staleness,
    # liveness/dead-code, observability rungs, stats diff, wiki files, run-context.json)
    # On a headless/CI run (as in Phase 1), append `--non-interactive` so every consent
    # offer records as skipped; a normal interactive /assess omits the flag.
    <!-- chat-replace:uv-core -->
    uv run "${CLAUDE_SKILL_DIR}/scripts/assess_core.py" "$REPO_ROOT"
    ```
    
    Either SVG is additive: if a script fails (no `uv`, no scoreable files, no docs), record "could not be generated - <reason>" in the report and continue. The doc graph shares its data with the deterministic core's `doc_graph` / `doc_staleness` blocks, so even when the SVG can't render, Layer 0 still scores from `run-context.json`.
    
    Interactivity is an explicit signal, never a stdin probe: pass `--non-interactive` only on a headless/CI run, otherwise the run is interactive by default. See `references/consent-lifecycle.md` for why (`isatty()` misreads a subprocess).
    
    Now `$REPO_ROOT/.assess/run-context.json` contains the structured data you need for the prose sections. Read it before writing the report.
    
    The `plugin_version` field in `run-context.json` tells you which plugin version produced this run. Surface it at the top of the report (e.g., "Generated by `/assess` v1.8.0") so readers can spot it if a stale cached version of the plugin produced unexpected output.
    
    ### 2d: Phase 3 - the bounded mutation pass (opt-in, kept separate)
    
    **This is its own phase, asked on its own - never batched with the Phase 1 tool installs.** The default core run is read-only - it never mutates or runs code, so `test_pressure` carries the cheap hollow-test heuristics and mutation-config detection but no survivor data. The decisive Layer 1 signal (would a test actually fail if the code were wrong?) needs this bounded pass, which **modifies your source files and runs your test suite over the mutated code** - a different risk class, so it gets a dedicated question with explicit code-modification framing rather than being waved through inside a bundle of install prompts, and honours the non-interactive contract (skip when `run-context.json .interactive` is `false`).
    
    The `test_focus` block is the single source of focus targets - the risky files that most need test work, already cross-joined from hotspot risk, coverage, and the hollow-test heuristics. Read it; don't recompute it:
    
    ```bash
    jq '.test_focus' "$REPO_ROOT/.assess/run-context.json"
    ```
    
    <!-- chat-replace:mutation-offer-intro -->
    **Only when `test_focus.entries` holds at least one entry with test evidence** (`test_signal` of `covered_but_hollow` or `sibling_test_only`, on a hot file that is not itself a test) is there anything to deepen - mutating a file with no test yields all survivors and measures the missing test, not an existing one, so `unsupported` / `no_covering_test` / `unknown_no_coverage` entries stay in the report table but out of the mutation scope. If no entry qualifies, skip straight to Step 3. When it has entries, follow the same detect-or-offer-install pattern as Steps 2a/2b: first detect a mutation tool, then ask the user whether to run the bounded pass.
    
    <!-- chat-skip:start -->
    **Detect a mutation tool for the repo's language.** Mirror the Step 2b heuristic - `mutmut` for Python, `stryker` for TS/JS - and check PATH plus the permanent-decline marker:
    
    ```bash
    # Scope: entries with test evidence, ranked, minus test files (the core's mutation_scope;
    # regex mirrors sibling_tests.IS_TEST_RE). Tool by dominant focus-file language
    # (Python -> mutmut, TS/JS -> stryker); mutmut when mixed.
    FOCUS_FILES=$(jq -r '.test_focus.entries[] | select(.test_signal == "covered_but_hollow" or .test_signal == "sibling_test_only") | .path | select(split("/") as $p | (($p[-1] | sub("\\.[^.]*$"; "") | test("(^test_|_test$|\\.test$|\\.spec$|_spec$|Tests?$)")) or any($p[:-1][]; . == "__tests__")) | not)' "$REPO_ROOT/.assess/run-context.json" | head -5)
    case "$FOCUS_FILES" in
      *.ts|*.tsx|*.js|*.jsx) MUT_TOOL=stryker ;;
      *) MUT_TOOL=mutmut ;;
    esac
    command -v "$MUT_TOOL" >/dev/null 2>&1 && MUT_PRESENT=1 || MUT_PRESENT=0
    [ -f "$REPO_ROOT/.assess/.no-$MUT_TOOL" ] && MUT_DECLINED=1 || MUT_DECLINED=0
    
    # Re-offer once when the existing decline was made under an older plugin major
    # (the mutation pass may have changed materially since). The core computes this.
    REOFFER_MUT=$(jq -r '.reoffer_mutation // false' "$REPO_ROOT/.assess/run-context.json" 2>/dev/null)
    ```
    
    **Offer with AskUserQuestion** as a **standalone question** (skip it entirely when `run-context.json .interactive` is `false`, or when `MUT_DECLINED=1` **unless** `REOFFER_MUT=true`, in which case ask once more - the prior decline predates a major bump). Frame the code modification explicitly - not "run a deeper test analysis?" but "**this will modify your source files** (mutating up to 5 focus files) and run your test suite over the changes, time-boxed, then revert". When re-offering, say so: _"You previously declined mutation testing under an older version; the pass has since changed - run it now?"_. Three options, same shape as Steps 2a/2b:
    
    - **Run mutation analysis** - run the bounded pass on the focus files (installing `$MUT_TOOL` first if `MUT_PRESENT=0`, exactly as Step 2b installs a dead-code tool: run the platform-appropriate install, surface any failure as a chat message, and fall back to no-mutation on failure - never block).
    - **Skip for now** - continue with the cheap read intact. Don't write a marker; ask again next run.
    - **Skip permanently for this repo** - `write_decline_marker "$MUT_TOOL"` so future runs don't ask. Re-declining restamps the marker at the current version, so the re-offer won't repeat within this major.
    
    **On accept (tool available):** run the opt-in mutation pass, then regenerate the heatmap with the survivor overlay. The core re-run reads the `test_focus` targets with test evidence itself, runs `scan_test_pressure(..., opt_in=True)` scoped to them, and rewrites the `test_pressure` block in `run-context.json` in place:
    
    <!-- chat-skip:end -->
    ```bash
    # 1. Re-run the test-pressure scan with the bounded mutation pass enabled
    <!-- chat-replace:uv-core-mutation -->
    uv run "${CLAUDE_SKILL_DIR}/scripts/assess_core.py" "$REPO_ROOT" --opt-in-mutation
    
    # 2. Regenerate the heatmap with the survivor overlay so covered-but-unpinned
    #    files get hatched and stop reading as safe green
    <!-- chat-replace:uv-treemap-overlay -->
    uv run "${CLAUDE_SKILL_DIR}/scripts/complexity-treemap.py" "$REPO_ROOT" -o "$REPO_ROOT/.assess/complexity-heatmap.svg" --stats "$REPO_ROOT/.assess/complexity-stats.json" --test-pressure "$REPO_ROOT/.assess/run-context.json"
    ```
    
    <!-- chat-skip:start -->
    With no mutation data the `--test-pressure` flag is a silent no-op, so the overlay regeneration is harmless even if the pass produced nothing.
    
    **On decline or no tool available:** continue with the cheap read intact - the assessment is complete without it. State in the report that the deep mutation pass was **not** run and why (declined, or no mutation tool for the language), so the Layer 1 read is honest about its depth rather than implying the focus files were proven well-tested.
    <!-- chat-skip:end -->
    
    ## Step 3: Score the Layers
    
    The deterministic core has written the data bus (`.assess/run-context.json`). Assigning each layer Present / Partial / Missing is judgement-heavy work that benefits from a fresh context window applying the layer methodology - so it runs as a dedicated unit, not inline here.
    
    **Layer 6 (truth pressure) is capped at Partial when mutation testing did not run.** Read `mutation_not_run_cap` from `run-context.json`: when `applies` is true (the default read-only pass leaves it true - mutation only runs on the opt-in Step 2d accept), Layer 6 **cannot** be scored Present. A Present verdict there claims the suite *proves* behaviour, which only a mutation run substantiates - absent it, the strongest honest verdict is Partial, annotated with `mutation_not_run_cap.annotation` (`truth-pressure unproven (mutation not run)`). This is enforced deterministically: `assess_finalize.py` refuses a finalize-input whose Layer 6 score exceeds Partial while `mutation_run` is false, so scoring it Present will fail the finalize step, not merely read wrong.
    
    <!-- chat-replace:layer-scorer-delegate -->
    Spawn the `assess-layer-scorer` agent (`subagent_type: "assess-layer-scorer"`), passing `REPO_ROOT`. It reads `.assess/run-context.json`, scores every layer, and returns the 0-8 score, the per-layer verdicts with evidence, the maturity label, and the structured `evidence` list Step 4 re-checks. Hold that scorecard for Step 4.
    
    ## Step 3.5: Read Cross-Run Context
    
    Before the report is written, check what changed since the last run (the findings-writer renders this into the report's diff section):
    
    ```bash
    jq '.diff, .diff_detail' "$REPO_ROOT/.assess/run-context.json"
    ```
    
    If `prior` was None (first run), skip this section in the report.
    
    **Check `diff_reliable` first.** The reliability check is schema- and version-aware, not a blunt exact-version match: a MINOR/PATCH plugin bump keeps `diff_reliable: true` and the trend armed, but the diff is voided (`diff_reliable: false`, with `diff_version_note` naming the cause) when the stats `schema_version` changed, a complexity backend moved (`lizard`/`scc` version delta - the note names the tool), the prior snapshot never stamped a version, or the plugin MAJOR version changed. A MAJOR bump also sets `diff_trend_reset: true` - the report renders an explicit "Trend baseline reset" disclosure so a suppressed diff isn't misread as an unchanged run. **Suppress the "What Changed Since Last Run" section** whenever `diff_reliable` is false and surface the `diff_version_note` (as the deterministic renderer already does) rather than the transition lists. Otherwise, populate the section:
    
    - **Graduated** (good): list paths from `diff_detail.graduated` - hotspots that left the top list
    - **Regressed** (bad): list paths from `diff_detail.regressed` with their `ccn_delta` / `commits_delta`
    - **New** (watch): list paths from `diff_detail.new`
    - **Persistent** (structural debt if N runs in a row): list paths from `diff_detail.persistent`
    
    The wiki files at `.assess/index.md` and `.assess/hotspots/*.md` are already updated by `assess_core.py` - you don't need to write them. You only write the prose summary in `assess-report.md`.
    
    ## Step 4: Write the Report
    
    <!-- chat-replace:evidence-check -->
    **Verify the scorecard's evidence first** - the only point where a false claim can still be kept out of the report. Write the scorer's `evidence` list to `$REPO_ROOT/.assess/.cache/evidence.json` (after `mkdir -p "$REPO_ROOT/.assess/.cache"`), then run the check: `uv run "${CLAUDE_SKILL_DIR}/scripts/lib/evidence_check.py" "$REPO_ROOT" "$REPO_ROOT/.assess/.cache/evidence.json" --json "$REPO_ROOT/.assess/.cache/evidence-checked.json"`. It prints `verified N, rejected M` and exits 0 (all hold) or 1 (some rejected); without that line, or with no `evidence-checked.json`, the check did not run - fix it before writing, never read it as a pass. The output's `evidence` replaces the scorer's list in the scorecard handed on; entries under `evidence_rejected` (each with a `reason`) are handed on beside it as the record of refuted claims, never cited as fact. When every entry a layer cited was rejected, re-score that layer yourself from `run-context.json` and its remaining verified entries, not from the scorer's prose. Delete both files once read. Assembling `.assess/assess-report.md` - the scorecard, the snapshots, the verbatim cross-layer findings, the lying signals, and the mandatory Top 3 Actions - is a reusable, mostly-deterministic procedure. It runs as a sub-skill.
    
    <!-- chat-replace:findings-delegate -->
    Use the assess-findings skill, handing it the scorecard the layer-scorer returned. It assembles `.assess/assess-report.md` from the data bus plus the scorecard: the verbatim findings section, the lying signals, and the Top 3 Actions (the attention list is mandatory). Then continue to Step 7.5.
    
    ## Step 7.5: Finalize the wiki (required)
    
    After writing `assess-report.md`, write `finalize-input.json` to the transient cache and invoke `assess_finalize.py` so the wiki files reflect the score and actions you chose.
    
    The input file lives under `.assess/.cache/` rather than directly in `.assess/` because it is a one-off LLM-authored input consumed immediately - it has no future utility and only creates noisy diffs if committed. `assess_finalize.py` reads the cache path first (and falls back to the legacy in-tree location if a prior run wrote one there), then **deletes** it on success so it cannot leak into a commit either way.
    
    ````bash
    mkdir -p "$REPO_ROOT/.assess/.cache"
    cat > "$REPO_ROOT/.assess/.cache/finalize-input.json" <<'EOF'
    {
      "run_id": "<copy run_id verbatim from run-context.json>",
      "score": 6.0,
      "maturity_label": "Solid",
      "denominator": 8,
      "layer_scores": {"0": 1.0, "1": 0.5, "2": 1.0, "3": 0.5, "4": 1.0, "5": 0.5, "6": 0.5, "7": 1.0, "8": 0.5}, "evidence": [{"layer": 0, "kind": "path_exists", "path": "CLAUDE.md"}],
      "top_action": "Add cyclop rule (threshold 15) to .golangci.yml",
      "hotspot_actions": {
        "src/foo.go": [
          "Split parseLine into smaller functions",
          "Add a test file at src/foo_test.go"
        ]
      },
      "actions": [
        {
          "rank": 1,
          "action": "Add cyclop rule (threshold 15) to .golangci.yml",
          "layer": 3,
          "effort": "small",
          "files": [".golangci.yml"],
          "first_step": "Add 'cyclop' with max-complexity: 15 under linters",
          "done_when": "golangci-lint run passes with the rule active; no new suppressions added",
          "scope_fence": "Only .golangci.yml; do not edit source files to chase pre-existing violations"
        }
      ]
    }
    EOF
    
    <!-- chat-replace:uv-finalize -->
    uv run "${CLAUDE_SKILL_DIR}/scripts/assess_finalize.py" "$REPO_ROOT"
    ````
    
    This replaces:
    - The `**AI Readiness:** 0.0 / 8 ((LLM fills in))` and `**Top action:** Deterministic ranker not yet wired ...` placeholders in this run's `log.md` entry (found by its `assess:run_id` stamp) with your score, maturity label and Top 1 action; the log chain is re-computed.
    - Each `hotspots/<slug>.md`'s `Suggested actions` section with the actions you derived for that file.
    
    The optional `denominator` field is **8** for a software repo (the default when omitted) or the applicable-layer count for a detected archetype (3 for a knowledge base - see "Repository archetype" above). `assess_finalize.py` renormalises the `log.md` AI-Readiness line over it, so a KB reads `2.5 / 3` rather than a misleading `2.5 / 8`. If finalize exits 1 because an earlier same-date entry is still unfilled (a run on an older commit that was never finalized), run the `--drop-entry <run_id>` command its message prints (it leaves a one-line tombstone), then re-run finalize. Never delete a log entry by hand: it breaks the chain.
    
    **`assess_finalize.py` reconciles this input against `run-context.json` before writing anything, and refuses (writing nothing, exiting non-zero) on any violation.** So the fields must be internally honest:
    - `run_id` - **copy it verbatim** from `run-context.json`. It proves the input was authored against *this* run; a mismatch is treated as a torn write and rejected.
    - `denominator` must equal `archetype.denominator` in `run-context.json`.
    - `score` must not exceed `denominator`, and `maturity_label` must name the tier the score earns (≥0.875 AI-Native, ≥0.625 Solid, ≥0.375 Basic, else Not Ready over the denominator) - a label that overstates the score is rejected.
    - Every key in `hotspot_actions` must be a real top hotspot from `stats_summary.top_hotspots` - a fabricated path is rejected, naming the path.
    - `layer_scores` maps each layer id to its band (Missing 0.0 / Partial 0.5 / Present 1.0). Layer 6 must not exceed **0.5** when `mutation_not_run_cap.applies` is true (see Step 3). Include it so the cap is enforced; a legacy input omitting it skips only the Layer 6 check. Set `evidence` to the verified list the pre-report `evidence_check` run kept (flat `{layer, kind, path[, needle]}` entries), never the scorer's raw list; finalize re-checks it against the repository: a layer whose entries are all rejected is refused, naming each entry by kind, path and needle; a layer with at least one verified entry finalises, and each rejected entry prints a `finalize: warning:` line on stderr, which you relay to the user. An input without `evidence` skips the check.
    
    The live README badge (`.assess/badge.json`, shields.io endpoint schema) is deterministic: `assess_core.py` writes the findings-count form on every run and links it to `assess-report.md`. Your LLM-derived score is *not* written to the badge - it appears inside the report the badge links to, so the badge only ever claims what a deterministic run can reproduce. When offering the PR (assess-pr), include the embed snippet if the repo's README has no badge yet:
    
    ```markdown
    ![AI-readiness](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2F<owner>%2F<repo>%2F<default-branch>%2F.assess%2Fbadge.json)
    ```
    
    The `actions` array mirrors the report's Top 3 Actions table one-to-one and **must carry every table row** - `rank`, `action`, `done_when`, and `scope_fence` are required per entry (`layer`, `effort`, `files`, `first_step`, `finding` recommended). Set `finding` to the finding type the action addresses (e.g. `hidden_coupling`, `lying_map`, `unexplained_complexity`, `untrusted_hotspot`, `self_referential_tests`, `refactor_boundary`) so `assess_finalize.py` can stamp the deterministic execution `mode` an executor should take; an action with no `finding` defaults to the conservative `characterize_first` mode. `assess_finalize.py` writes it to `.assess/actions.json`, the *durable* machine-readable contract (schema 2): unlike this input file (consumed and deleted), `actions.json` persists so an executing agent - including a smaller, cheaper model - can pick up the work with its exit criteria, fences, execution mode, and lifecycle status intact, without parsing the report's markdown. Re-running `/assess` preserves each action's `status`/`claimed_by`/`completed_sha`, so a completed action stays done. See [`references/actions-schema.md`](references/actions-schema.md) for the full schema.
    
    Without this step, the `log.md` placeholders above carry forward forever. Hotspot pages you don't supply actions for keep a neutral pointer (`This file is flagged but outside this run's Top 3. See the report's Top 3 Actions, or run a focused /assess pass for file-specific guidance.`) rather than an unfinished-work placeholder - a flagged-but-not-Top-3 page reads as intentional.
    
    The hotspot_actions dict should include at minimum the files mentioned in your Top 3 Actions. You can include more if you have specific suggestions for them; any file you omit keeps the neutral pointer.
    
    
    ## Step 8: End-of-Run Offers
    
    With the report written and the wiki finalized, run the end-of-run offers - open a PR, track the Top 3 Actions, freeze the assessment into a CI gate, the tool-feedback prompt, and the **uninstall** escape hatch (remove everything this run wrote, per `run-context.json .uninstall_instructions_path`). These are batched into Phase 2's single question and honour the non-interactive contract. This is a reusable procedure (akin to `pr-review-merge`), so it runs as a sub-skill.
    
    <!-- chat-replace:pr-delegate -->
    Use the assess-pr skill. It runs the write-back offers (PR, issue tracking, freeze-into-CI) plus the tool-feedback prompt and the uninstall offer, reading the written `.assess/assess-report.md` artifact - notably mutating the Top 3 Actions table's `Issue` column in place when the user creates tracking items.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related