Fresh-eyes risk review with ripwire
Routing: • Any DIFF — your own, or an incoming PR you're reviewing — about to push or merge → ripwire-change-check. • You have the measurement and now need the FIX — which refactor a measured shape calls for, its precondition, and the fix-then-prove loop → ripwire-quality-bar's shape → refactor playbook. • Architecture health (deps metrics, layering rules, modules) → ripwire-layers. • Finding where a specific BUG lives → ripwire-find-bug. • Not sure which skill? → ripwire-router.
Scope it to one unfamiliar subsystem you're about to touch, instead of the whole tree, two ways: point
<dir>at the subdirectory (ripwire src/net --hotspots), or keep the repo root and--excludethe rest.--dead-code=DIRalso scopes directly. This is how you turn the repo-wide sweep into a "what's-gnarly-in-code-I-didn't-write" read of one module.
<dir> = repo root. Add --exclude=build --exclude=.git (repeatable) to keep build artifacts out — they
otherwise show up as false clones and dead code. This composes descriptive passes into one snapshot; run the
sections your question needs.
Inheriting this subsystem? Check ripwire <dir> --notes --legend=compact first — a prior agent's pinned gotcha
(--note-add'd trap, flake, or invariant) is the cheapest fact you can pick up before you start reading.
Core health passes
- Maintenance hotspots —
ripwire <dir> --hotspots --legend=compact<hotspots window="12mo" ranked="33"><f p="./src/main.cpp" churn="53" ccx="1365" score="72345" top="main:1306"/> …score = churn × ccx(commits × Σ cognitive complexity). Top files = where developers keep working and the code is hard — the highest-leverage places to improve.top=names the worst function. Recent regression, not an all-time read?--hotspots --since="2 weeks ago"(or--since=HEAD~20) scopes churn to commits after that point; unresolvable/absent--sincedegrades silently to all-history. On C-family/C# code, discount a body carryingppalt=: itscx=/ccx=/nest=/loc=/locals=are summed over EVERY#else/#ifdefbranch (deterministic, but an over-count vs any single build), so a high score on appalt=-heavy function can be the preprocessor fan-out, not the logic — readppalt=before calling a row the worst in the file.
1a. "What changed recently / who touched this / what is moving here?" — ripwire <dir> --rank-by=churn-decay --legend=compact
<recent n="40" of="1857" merge_bombs_skipped="5"><rc p="src/graph.h" w="0.91" age="2"/> …
The HISTORY question, not the working-tree one: --situ answers what you changed and have not
committed, this answers what the repository has been moving, newest-and-heaviest first.
Scope it to a directory: ripwire <dir> --rank-by=churn-decay --in=DIR (root-relative, and it must
exist under the root) — a second <recent scope="DIR" …> block follows the global
one and the symbol map collapses to a counted stub, so the answer is the window rather than a whole-repo
map. merge_bombs_skipped="N" is the disclosure that N commits touching more than 100 indexed files
were left out of the weighting — if the commit you are hunting is a giant rename, it is in that N and
not in the rows. --since=REV|DATE bounds the window; --limit/--offset page the scoped block.
ripwire <dir> --help-task="what changed recently in DIR" --legend=compact routes here on its own.
1b. What is BUILT but DARK here? — ripwire <dir> --flags --legend=compact (=SUBSTR to narrow)
<flags gates="98" dark_gates="90" compile="32" cmake="9" env="57"><gate name="X_HARMONY_SFX" kind="compile" default="0" dark="1" regions="7" loc="45" reads="8" p="sound/audioWiringFlags.h" l="47"/> …
Inheriting a repo, the invisible half is the code that ships compiled OUT. This harvests all three gate
patterns — #ifndef/#define header gates, CMake option(), getenv() reads — with each gate's
default and the size of the code it guards, dark first. Two things --grep cannot tell you: when a
name is BOTH a header gate defaulting to 0 and a CMake option(... ON), the CMake default wins
(it is what the build passes) and the header appears as an <also> row — the contradiction a reader who
greps the header gets wrong; and a gate whose default IS another gate's name resolves through the alias
chain, so a master switch reports <aliases n=…> instead of a misleading loc="0".
Limits: lexical, not preprocessed — this is the in-repo default, never the value your build used.
A gate needs a value (#ifndef F / #define F 0); valueless pairs are include guards and are excluded.
A gate read as a value (constexpr bool k = F != 0; then if constexpr) honestly reports regions="0"
here — --flip below lifts exactly that limit.
1b-ii. If I flip THIS one, what lights up? — ripwire <dir> --flags --legend=compact --flip=NAME
<flip gate="X_RRF_ALL" kind="cmake" default="ON" dark="0" family="12" regions="0" loc="0" branches="43" bindings="11" hosts="11" downstream="95" dependents="146" tests="23" untested="0"> <member name="X_RRF_ISLANDS" via="alias" branches="12"/> … <lights r="0" b="43"><b p="canyon/simLevel.cpp" l="133" gate="X_RRF_WIDTHWAVE" via="kWidthWave" sym="buildStripTable"/> …
A list of ~90 dark gates is a map, not a decision. This is the decision: for ONE gate, the code that
becomes live (#if regions and the if constexpr branch sites a value-style gate governs), the
symbols holding it, what those transitively call (downstream — what starts executing), who depends
on them (dependents), the tests that reach them, and untested — the hosts no test covers. That
last number is the honest answer to "is it safe to flip?".
Alias chains run both ways: flipping a master rolls up every child that #defines to it, flipping a
child lights only that child and names its <parent> plus the siblings the parent's flip would add.
kind="cmake" also steers the build graph (an if(NAME) target_sources(...) can add whole files) —
those sites are listed as <c> rows and deliberately not followed. kind="env" is runtime="1":
no delimited region, so the hosts are the symbols that consult the variable.
Limits: lexical and single-line, never preprocessed. A binding split across two lines is missed. The
value lane reads C-family source only and treats a file declaring its own constant of that name as
shadowing the gate's (C++ scoping) — but a third header's same-named constant, included rather than
redeclared, would still count. A lit site inside no indexed def (a guarded member field, a file-scope
constexpr, a test-macro body) counts into filescope= instead of a host. --detail=N lifts the row
caps. Report only — always exit 0; an unknown gate name refuses (exit 1) and names the near-misses.
1c. Can I TRUST this repo's docs? — ripwire <dir> --doc-drift --legend=compact (=SUBSTR to narrow to one doc)
<doc-drift docs="129" clean="110" anchors="1995" checked="716" unchecked="1279" drift="110"> <doc p="docs/ARCHITECTURE.md" anchors="50" checked="34" drift="9"><a k="file-line" l="186" c="80" why="line-moved" ref="src/main.cpp:317" sym="isGitUrl" got="communityPresentation"/> …
Inheriting a repo, the design docs and audits are how you learn it — and the stale ones are how you learn
it wrong. Run this before you read them: it verifies the CHECKABLE anchors (file:line refs,
backticked symbol mentions, = N constants, [N] array extents) against the live index and reports only
what no longer holds, per doc. A doc with drift="9" gets read with suspicion; the 110 clean ones do not.
why= names the cause — missing-file, past-eof, line-moved (with got= naming the symbol that now
occupies the line), undefined, const-value, array-extent.
Limits: anchors, not prose — §Status lines, dates and "N of M done" tallies are NOT checked, and it
says so rather than pretending. Every lane under-reports deliberately (a name is stale only if it occurs
nowhere in the code as a token; a number only if the corpus binds it uniquely in a declaration), and
checked + unchecked = anchors with each declined check named in an <unchecked> row. Exit is always 0:
drift is a report, not a gate.
Want it to BECOME a gate? — ripwire <dir> --doc-drift --legend=compact --gateability. The reason CI can't gate on
drift is usually a handful of UNDATED design docs: with no date the lexical lanes can't tell "stale" from
"a record of what was true then", so their rows stay unclassifiable. This lists exactly those docs and
what one annotation each would fix:
<gateability docs="17" projected_drift="0"> <fix p="docs/ARCHITECTURE.md" live="6"/> …
live= is that doc's still-failing anchors; projected_drift= is the repo-wide drift you'd be left with
if every listed doc got one ISO date in its H1 or a front-matter self-date line. Treat it as an upper
bound on the win, not a mandate — dating a doc that is genuinely live hides real rot rather than
resolving it. Use it to turn "CI stays non-gating forever" into a finite to-do list.
Add --with-history when the mention lane matters. undefined only means "defined nowhere here",
which on a repo whose PLAN docs name unbuilt features is expected, not rot — measured on a 2900-file
repo, 243 of 325 undefined rows were names that had never existed there. --with-history makes one
git log pass over everything reachable from HEAD and splits the lane: why="deleted" with got="removed in <commit> (<date>)" and at= the file (real rot, ~75% fewer rows but every true positive kept), versus
<unchecked r="never-in-history"> for names the repo never had. It is opt-in because the walk costs ~3 s
on that repo against a 0.64 s default; the result is memoized per (repo, HEAD sha), so every later
question on the same commit — including --whereis --with-history — is a cache load.
1d. Did a task in this plan just never get launched? — ripwire <dir> --plan-lint=FILE --legend=compact. Where 1c
explicitly does NOT check §Status lines, dates or "N of M done" tallies, this is the narrow
structural check that closes that one gap — a wave-closer's job, mechanized. It is opt-in per FILE
(never a directory sweep) because the house PLAN dialect it recognizes is deliberately narrow: a task
card is exactly an H3 heading opening with a task id (T5, T10, T7b), a status ledger is exactly
one heading (any level) reading "Status" once a leading section mark is stripped. A file showing
neither an H3 card nor a ledger heading reports dialect="0" and nothing further runs — most plans
are exactly that file, and this is not a failing lint for them.
<plan-lint file="wave.md" dialect="1" cards="6" ledger="1" git="1" stale_commits="20" gating="1"> <card id="T5" line="40" status="missing" why="unlaunched" gating="1"/> …
Once dialect="1": a card's status is satisfied EITHER by a glyph on the LAST non-blank line of its
own body OR by a §Status ledger line naming its id — folded by DIGITS, so a bare card T7 is also
answered by a lettered ledger mention T7a/T7b (the real house shape when a card's own body names
lettered sub-tasks and the ledger tracks each separately) — with the card's own body winning when it
has one. Unresolved is status="missing", with why= naming the exact shape: unlaunched (a ledger
exists and never names this id at all — the exact field failure this verb exists to catch: a card that
was planned but never started, silent until a human happened to notice), unresolved (the ledger names
it but no line carries a glyph), or no-glyph (this document carries no ledger at all, so the card's
own body was the only possible source). An hourglass line whose git-blamed commit sits more than
stale_commits= commits behind HEAD is stale="1" (never claimed outside a git repo; blames whichever
line the status actually resolved to, marked src="ledger" when that is the ledger and not the card);
a task id named in the ledger with no matching card (same digit fold) is a ledger-orphan; an owed/OWED
mention with no check-mark or cross anywhere LATER in the SAME document is undischarged.
Limits, stated up front: never semantics — it does not judge whether a card's claims are true (that
stays 1c's job); single-document only (a successor plan's discharge of an owed item is invisible here);
substring matching on "owed"/"OWED" with no disambiguation (a doc that merely quotes the word reads the
same as a real marker); a document that uses card headings as plain labels with NO status mechanism
anywhere (no ledger, no glyph on any card) still reads every card as missing — a known, disclosed gap,
not a guessed-at fix. Unlike --doc-drift's always-0 report, this gates: exit 2 when dialect="1"
and any row carries gating="1", exit 0 clean or dialect="0", exit 1 only when FILE could not be read.
-
Dead code —
ripwire <dir> --dead-code[=DIR] --legend=compact<dead-code count="N" confidence="high" evidence="internal-linkage+zero-callers"><d n="orphan" …/>— source-defined free functions with explicit internal linkage and in-degree 0 in the indexed call graph.--dead-code=DIRscopes to a sub-tree. Methods, declarations, headers, and external-linkage entry points are excluded, socount="0"means "no high-confidence candidates," not "no dead code." Name-based and tree-local — verify before deleting: confirm against the compiler's unused-symbol diagnostics or a linker map; ripwire narrows the field, the toolchain proves it. -
Duplicate bodies —
ripwire <dir> --clones --legend=compact<clones groups="N" type3="M"><group type="2" tokens="161" n="2"><f n="line" p="…:31"/><f n="line" p="…:28"/></group>.type="2"= exact/renamed (identifiers + literals normalized, so a renamed copy still matches);type="3"= a gapped NEAR-miss (similarity 0.80–1.0, an inserted/changed statement) — checktype=before assuming two members are byte-identical. Largertokens== more dedup value; a fix to one likely belongs in all. Rule of Three: extract on the third occurrence, not the second — a little duplication beats the wrong abstraction. A group carryingidiom="…"(threshold-ladder,switch-name-table,builder-chain) plusdemoted="1"is one the detector judged an IDIOM COLLISION, not shared logic: same shape, no shared domain identifiers, different scopes — five unrelated bucketing ladders in five subsystems match each other's token stream and consolidating them would be the wrong abstraction. Demoted groups still print and still count; they stop gating. The idiom is named precisely so you can overrule it by reading the bodies — treat it as the tool showing its work, not as a row it decided for you. -
AST smells —
ripwire <dir> --lint --legend=compact<lint findings="N"><rule name="magic-number" count="82"/> …then per-finding<f rule= p= …>with the enclosing symbol. No-build, AST-only — a fast complement to clang-tidy, not a replacement. Skim the per-rule counts for the dominant smell.Two rule prefixes are also family evidence.
naming-*(naming-case/naming-confusable/naming-predicate/naming-series/naming-setter/naming-short/naming-underscore/naming-wordy) is exactly--ensemble's/--quality-panel'slexicalfamily;atom-*(atom-assign-as-value/atom-comma-operator/atom-embedded-crement/atom-implicit-predicate/atom-nested-ternary/atom-octal-literal/atom-reversed-subscript) isconfusion. A--lintfinding under either prefix is the identical predicate the family join runs — reading it here just skips the join. Not a moment to reach for mid-task:--naming-calibrationscores both spellings of this repo's own historical renames (old identifier vs. what it was renamed to) against the samenaming-*predicates — it is how those rules were validated, a maintainer calibration harness (test/namingcalibrationcheck.shpins the per-rule floor) with apairs=sample size and its own legend calling itself a noisy proxy, not a verdict on any function you're looking at. It always exits 0.What rules exist, and are they even structurally possible on this corpus? —
ripwire <dir> --lint-catalog. The full built-in registry: every rule's severity, category, one-line rationale, its registered languages, and the release it shipped in — no corpus needed, so it answers "what could--lintever find here" before you run it. A plain--lintrun then discloses the corpus-specific half:applicable="0"on a<rule>row means none of that rule's registered languages are present at all (itscount="0"is structural inertness, not a measurement — e.g.magic-numberis C/C++/ObjC-only and reportsapplicable="0"on a pure-Go tree), and the root'sinert_rules=Ntallies how many rows that is true for. Scoping a--lintrun to one family —--lint-select=PREFIX[,...]/--lint-ignore=PREFIX[,...](comma-separated,*for "every rule"):--lint-select=cache-runs only thecache-*family, disclosingselected="K of N"on the root so a filtered zero is never confusable with an unfiltered one; an unresolvable PREFIX refuses with a near-miss rather than silently matching nothing.The one naming lens that proposes a FIX, not just evidence —
ripwire <dir> --naming-consistency --legend=compact. Every other pass on this page tells you WHAT is wrong; this one is the deliberate exception, and only for one narrow reason: case-CONVENTION is Tier A (the research record's own tiering for "propose a fix only where the correct replacement is derivable from the corpus" — seeripwire-quality-bar's zoom-in table for the other nine--quality-deltakinds, which stay evidence-only on purpose). It votes the corpus's OWN dominant case style (camel/pascal/snake/screaming) per(language, kind)group — decided only past a 20-name sample floor AND 90% agreement,UNAVAILABLEwithwhy=otherwise, never a guessed winner — and every off-convention name in a decided group getspropose=: its own subtokens mechanically recombined into that style. No dictionary, no synonym judgment — which is exactly why this is the one place a rename suggestion is safe to make. It is still only a suggestion, not a safe-to-blind-apply rename: applying one for real needs--usesto prove the complete reference set first. Exit 0 always, a lens like the rest of this page — abbreviation inconsistency and synonym unification (the other two Tier-A categories) stay unimplemented on purpose; both need a dictionary or a semantic judgment call this verb's design deliberately avoids.Local-variable naming, opt-in and explicitly not the default —
ripwire <dir> --lint --legend=compact --naming-locals. A--lintmodifier, not a standalone verb (a no-op without--lint): runsnaming-short/naming-wordy/naming-underscore/naming-case— the same tags, same predicates as the Symbol-scoped checks above — against LOCAL variable names too, C/C++ only, and only where the risk is real: inside a function that already clears an existing size/complexity gate (loc>80 OR nest>4 OR ccx>=15) AND haslocals>=8(measured, not guessed: median 9 among this repo's own 377 gated functions).naming-shortadditionally requires the local's own declaration depth>=2— a depth-1int n = argc;never fires, a nestedint xtwo blocks deep can. This deliberately reversesnaminglens.h's own stated invariant ("an un-indexed loop local can never be flagged") — read the WITHDRAWN note atop that file before trusting it. NOT enabled inside a plain--lintrun and not a candidate for that yet: the plan's own hard blocker (a hand-curated fixture corpus AND a manual real-corpus audit for idiomatic-short-name skew —i/j/k/buf/tmp/err) has not run. Treat a--naming-localsfinding as a candidate to eyeball, not a verdict, until that audit lands.Your own rules —
ripwire <dir> --lint-rules=DIRloads YAML ast-grep-style rules fromDIRand runs them alongside (or instead of) the built-ins; findings share the same<f rule= sev= p= …>message</f>shape. A malformed rule alerts to stderr and is skipped (sibling rules still load); zero loaded rules exits 1. Writing a rule (the query grammar, theinside/not-inside/not-matchescombinators, a worked example) →lint-rules.md. -
Ownership / knowledge risk —
ripwire <dir> --owners[=SYM] --legend=compact<owners files="N"><f p="…" authors="1" bf="1" top="…@…" share="1.00"/>(recency-weighted, 6-month half-life).authors=distinct authors;share=the top author's fraction of weighted commits;bf="1"= one person holds >80% (bus-factor risk);top== who to ask / who should review.--owners=SYMresolves to just that symbol's defining file — the "who should review my change tobuildGraph" answer. Measures git authorship (recency-biased), not who should own the code. Worst case: a file that's BOTH a top hotspot (step 1) ANDbf="1"— gnarly code only one person understands.Per-function readability —
ripwire <dir> --readability --legend=compact<readability functions="N" shown="40" capped="1"><fn p="…:512" n="buildGraph" lines="1244" toks="6753" ops="4359" vocab="382" vol="57923.4" ent="6.26" posnett="0.000"/>— the Posnett/Hindle/Devanbu (MSR 2011) closed-form model, least readable first:vol=Halstead volume,ent=Shannon token entropy,lines=the definition's line span,posnett=the fitted sigmoid. Complements step 1:--hotspotsranks by complexity × churn (where pain has been paid), this ranks by how hard the text is to read right now, with no git history needed. Two caveats it states in its own legend and you should carry: one token-class table serves every language, sovol=is a cross-language approximation; and the formula was fitted on snippets of 20 lines or fewer, so on a 1,000-line functionposnett=saturates at0.000— read the ORDER of the rows, never the number as a grade.Reachable non-local mutable state —
ripwire <dir> --nonlocal-state --legend=compact<fn p="src/infra/profilePmc.h:288" n="ensure_global_init" writes="2" reads="3" direct_writes="1" direct_reads="3" cells_total="3"><cell n="g_perf" p="…:284" dir="rw" at="…:339" at_dir="rw"/>…— per function, the globals / file-scope statics / function-local statics / Python module globals it or its transitive callees touch, most writes first, with reads and writes kept apart.writes=/reads=fold in the callee closure;direct_*is what this body does itself; each<cell>names the declaration and either the use site here (at=, withat_dir=for what THIS body does) or the callee it came through (via=). Answers a different "is it safe to touch this?" than--impact:--impactis who calls you, this is what state you can perturb from here — the reason a change with a tiny caller set still breaks something across the repo.const/constexprdeclarations are not cells, so a bigwrites=is genuinely shared mutable state, not a table of constants. Three things to carry: it is unsound by construction — no indirect calls, no pointer aliasing, no macro-named cells — so every count is acounts_floor="1"floor and a zero means none found; a local shadowing a cell's name can be charged to the cell; and it covers C++/ObjC/Python only, with every other indexed language named inunanalyzed_langs=on the root, which is not measured, not measured zero.THE SINGLE COMMAND —
ripwire <dir> --quality-panel[=strict|default|lenient] --legend=compact. When the question is just "where is the rot", start HERE, not at step 1. It is the whole panel in one ranked report: the four families--ensemblejoins, pluscolocation(how much of what you must read to understand this function lives outside its own file) andstate(this function's OWN BODY touching non-local mutable state) — six families, ranked by the count of distinct families and never by a composite.<s p="./src/graph.h:462" n="buildGraph" fam="4" of="6" fired="structural,confusion,historical,colocation" uncounted="" unavail="" join="deep+untested"><e f="structural" counted="1" why="ccx=724 loc=1244 nest=9 humps=30 deep=308 rrank=1"/><e f="colocation" counted="1" why="crank=33"/>…Pick the preset by what you are doing, and note that a preset only selects families and cuts on the count — it never weights:lenient(all six, 1 must agree) is a reading order, about a third of any tree;default(all six, 2 must agree) is a review list;strict(the four families measured stable enough to stand behind a gate, 2 must agree) is the only rung to point CI at.historicalandcolocationare deliberately out ofstrict— each is a fixed-size worst-40 cut over a ranking whose population moves, so both re-shuffle across commits on code that did not change. A row still SHOWS them, asuncounted=withcounted="0", so you can see what the preset set aside. Everything the--ensembleparagraph below says about ordinal cuts and UNAVAILABLE applies here unchanged.Read the structural
why=as a PROFILE —nest=alone is a max, and on a subsystem you did not write that is exactly the wrong number to act on.nest=9is one deepest line; it cannot tell a tangled body from a long blocked-sequential one whose max is a single inner loop. The row carries the profile beside it:humps=(how many maximal control-nesting regions reach the bar — CodeScene's "bumpy road", EXACT) anddeep=(how many LINES lie inside them — a FLOOR,deep_floor="1"), against theloc=already on the row. Both are absent exactly whennest <the bar — not-deep, never a hidden0— anddeepbelowhumpsis legal output, not a defect (deepcounts lines,humpscounts regions, and a one-lineif(c){x;}else{y;}at the bar is two regions on one line). Two ratios do the discriminating:deep/locseparates tangled (high — depth is sustained) from blocked-sequential (low — a dispatch table or a setup block), anddeep/humpsseparates one giant tangle (high — the expensive fix) from many tiny touches (low — repeated missing abstractions, each its own cheap extraction).locals=on the same row is the reader's working set — a FLOOR (locals_floor="1"), C/C++ only, absent rather than0elsewhere.ev=on the same row (essential complexity, McCabe: absent/1= single-entry/single-exit, extract-method applies mechanically;2+= a jump — seeev_why=) — tells you whether the shape you are about to prescribe a fix for is a cheap lift or an expensive rewrite before you commit to one. Which fix each shape calls for, and its precondition → ripwire-quality-bar's shape → refactor playbook. The numbers in the example rows on this page are ILLUSTRATIVE OF A SHAPE, not values to expect — these counters are under active calibration (an else-clause over-count fix in flight moveshumps=down substantially, andccx=/nest=with it, on else-heavy functions). Durable is what you do with the row:humps=1vs many, the two ratios, and the semantics above. Read the row in front of you.join="deep+untested"is an annotation, not a seventh family: this row carriesdeep=and no indexed test reaches it. It changes nothing — notfam=, notof=, not the ordering, not which rows appear — and it is exactly the pair where a refactor is most wanted and least safe (test first, → ripwire-write-tests). It is suppressed on every row whentested_scope="0", because on a corpus whose tests were never crawled "untested" describes the crawl and not the code, so readtested_scope=on the root before treating its absence as good news;deep_untested=counts them across the whole row set, which thelimit=window does not change.The
historicalfamily's unit is the FILE, not the symbol.churn=andhrank=are file facts inherited verbatim by every symbol in that file, so a symbol in a busy file collects this family without any property of its own. On a subsystem read that is worth discounting explicitly: confirm at the symbol (git log -p <file>, or--hotspots --since=) before calling one function the churny one.Corroborated rot, four families only —
ripwire <dir> --ensemble --legend=compact. The narrower join, and the one whose numbers are published; reach for it when you want exactly the calibrated four plus the per-file rollup. One lens firing is an opinion; the same function flagged by several kinds of evidence at once is a finding.--ensemblejoins four orthogonal families —structural(the shape:ccx/loc/nest/paramsbars plus the readability rank),lexical(thenaming-*rules on the identifier text),confusion(theatom-*rules on the syntactic construct),historical(git change frequency) — and ranks by the count of distinct families:<s p="./src/graph.h:462" n="buildGraph" fam="3" of="4" fired="structural,confusion,historical" unavail=""><e f="structural" why="ccx=724 loc=1244 nest=9 humps=30 deep=308 rrank=1"/>…— plus a per-file rollup whosetop_fam=is the strongest single-symbol corroboration in that file. (Thehumps=/deep=profile and the per-FILEhistoricalunit read exactly as in the panel above; this verb has nojoin=annotation.) There is deliberately no composite score: averaging correlated metrics re-weights one signal and calls it three (the Maintainability-Index failure), sofam=is ordinal and every row carries the evidence behind it — you never need a second command to see WHY. Two things to carry:rrank=/hrank=are relative cuts (the worst decile of this corpus, so something always fires), while theccx/loc/nest/paramsbars are absolute and printed on the root; and a family that could not be measured is named inunavail=withof=dropping to 3 — UNAVAILABLE is not the same as clean, so on a corpus with no git history do not read the missinghistoricalas "nothing is churning".How much is NOT in front of you —
ripwire <dir> --context-ratio --legend=compact. The other lenses ask how hard a function is to read once you have it open; this one asks how much you must open besides it.<s p="src/main.cpp:10177" n="main" sites="1050" ents="131" ents_out="85" ent_ratio="0.649" files="37" files_out="36" rtok="165317" rtok_out="61968" read_ratio="0.375" ext="141" amb_names="19"/>—ents=distinct in-corpus definitions its reference sites resolve to,ents_out=/ent_ratio=how many live outside its own file, andrtok=/rtok_out=/read_ratio=the same split weighted by the tokens a reader must actually read. Read the two ratios together:mainabove needs 65% of its entities from elsewhere but only 37% of its reading volume, because the biggest things it touches are next door. A per-file rollup follows the symbol rows and is a union over the whole file (includes and imports included), not the sum of those rows. Three things to carry: it credits its own prior art — the fraction is Beck & Diehl's per-class congruence (FSE 2011) flipped, and the refinement is the reader weighting plus the use of every reference role, not just calls; resolution is name-based, soamb_names=names resolved to several definitions andents=is a floor; andext=is dominated by locals and parameters, so it is not an external-dependency count and never enters either ratio.Comments that say nothing —
ripwire <dir> --comment-coherence --legend=compact. Per function/method with a doc comment, two content measures, most name-restating first:<fn p="…:41" n="computeTotal" c_coeff="1.000" words="2" restate="2" cic="0.667" c_terms="2" i_terms="3" shared="2"/>.c_coeff(Steidl/Hummel/Juergens, ICPC 2013) is the fraction of the comment's words within Levenshtein distance 2 of a word in the name — HIGH is BAD, it means the comment just restates the name (// compute totalabovecomputeTotalscoresc_coeff="1.000"), the opposite of the naive "high coherence sounds good" reading.cic(Scalabrino, ICPC 2016/JSEP 2018) is the Jaccard overlap between the comment's vocabulary and the method's own identifier vocabulary (params, locals, callees, fields) — a different axis, expected to disagree withc_coeff, so read both rather than either alone. UNAVAILABLE, never a zero, where no doc comment exists at all (counted inno_comment=on the root) — this pass never guesses at an absent comment's quality. Different question from--doc-driftabove: doc-drift asks whether a markdown claim is still true; this asks whether a source doc-comment carries information — neither checks the other's axis.
Hidden-coupling pass — "a change here keeps breaking unrelated files" (Fowler's Shotgun Surgery, in its measurable form: change coupling)
-
Behavioural coupling —
ripwire <dir> --cochange --legend=compactBare--cochangeemits ONLY the surprising pairs — files that change together in git but share no static dependency either way:<cochange pairs="N" sub_windows="3"><pair a="…" b="…" together="17" deg="1.00" conf_ab="1.00" conf_ba="0.34" driver="a" recur="3" surprising="1"/>…(together= commits co-appearing,deg= fraction of the rarer file's commits). Pure behavioural coupling — shared global state, parallel ownership, or an implicit protocol; statically-linked pairs are filtered out as expected. For ONE file's full partner list,--cochange=FILE.Read
recur=before you act on a row. It is how many of the header'ssub_windows=equal-commit-count slices of the window the pair actually co-changed in.recur="1"at anytogether=is a single burst — a refactor sprint, a rename wave — not a standing coupling, and it is the biggest source of rows that look alarming and are not.ripwire <dir> --cochange --legend=compact --cochange-recur=2drops them; the header then carriesmin_recur=so the shorter list is explained rather than mysterious.driver=names the file to look at.conf_abis "of a's commits, the fraction that also touched b";conf_bais the reverse.driver="a"means a is the one that never moves alone, so a is where the implicit dependency lives.deg=is just the larger of the two, anddriver=is omitted on a tie.ripwire <dir> --cochange --legend=compact --cochange-groupscollapses the violating pairs around the file each one implicates:<group core="…" partners="3">says "this file co-changes with three files it does not depend on" in one row instead of three. That is the row to act on — it names the fix target. The cover is greedy (cover="greedy"), sogroups=is an upper bound on the minimum, not the minimum. -
Structural coupling —
ripwire <dir> --deps --legend=compact<deps>with god-files ranked byafferent(dependents) and each file'sinstab(1 = leaf, 0 = core). High-afferent, high-instability files are unstable hubs — every change there fans out. Cross with the step-6 surprising partners: a clone group (step 3) that also co-changes is a maintenance coupling — a fix must land in both copies.
Planning a refactor / "this class is a god object"
When the job is "I'm about to restructure this — where's the seam and what will it break?", chain three passes into a refactor brief:
-
Find the high-complexity cluster —
ripwire <dir> --communities --legend=compact(cohesive call-graph modules) orripwire <dir> --metrics --legend=compact --top-k=40and readccx/lcom4.lcom4>1is a god object by the numbers — N disjoint method clusters that want to be N types; the community/--zoomview names the members that belong together. Pick the cluster to break apart. -
Its blast radius —
ripwire <dir> --impact=SYM --legend=compacton the cluster's lead symbol →reaches="N"= every caller a signature change would touch. Largereaches=→ stage behind a shim, don't big-bang it.--affected=<files>names the tests to keep green through the move. -
Its co-change seams —
ripwire <dir> --cochange=FILE --legend=compacton the file you're splitting → files that historically move with it. Asurprising="1"partner (no static dependency) is hidden coupling the refactor must preserve or explicitly cut — fold it into the plan before you start. -
Pick the fix from the SHAPE, not the score —
ripwire <dir> --quality-panel --legend=compact(or--metrics) on the cluster, then readhumps=/deep=/locals=as above: many shallow humps → extract each one (cheap); one deep tangle → guard-clause inversion then state extraction (expensive); small-and-dense → read it first, density is often the algorithm;join="deep+untested"or high fan-in → test before you touch it. The full shape → fix → precondition table, and the fix-then-prove loop that closes it (--quality-delta→--edit-check=SYM→--affected), are in ripwire-quality-bar.
→ Refactor brief: the cluster to split (with its lcom4/ccx evidence and its nesting profile), the
blast radius + test gate, the co-change partners to keep in sync, and the named fix each shape calls for. For
CI-enforcing the boundary you extract → ripwire-layers.
Output
A one-page health snapshot:
- Hotspots — top 3 files by
score, each with its worst function. - Cleanup wins — count of high-confidence dead-code candidates (all still marked verify) + the largest clone group (biggest dedup payoff).
- Dominant smell — the highest-count lint rule and where it clusters.
- Knowledge risk —
bf="1"files, flagging any that are also hotspots. - Hidden coupling — top surprising co-change pairs + any that overlap a clone group.
- One-line verdict — healthy / has-debt / needs-attention, with the single highest-priority item first.
This is the structural read — it doesn't run tests or a compiler. Pair with the build's warnings and test suite; use ripwire-change-check for a specific diff rather than the whole repo.
Date the snapshot. A health report is quoted back weeks later, long after the numbers moved. The
git-aware verbs stamp the commit they measured at — --map-diff/--doc-drift carry at="<sha>[+dirty]",
--stray-content carries head="<sha>" — but most of this skill's passes (--hotspots, --clones,
--lint, --dead-code, --cochange, --owners) carry none. Open the snapshot with the sha you ran
at (git rev-parse --short HEAD) so a reader can tell a live finding from an archaeological one.

