#pragma once #include "infra/emit.h" // rw::emitTo / emitRaw / formatTo — THE emitter and its siblings #include // %.*s (precision, pointer) collapses to one view // dmm.h — `--dmm`: the Delta Maintainability Model, one comparable scalar per change. // // `--quality-delta` answers WHICH KINDS of debt a change added. It cannot answer "was this change, on the // whole, better or worse than the last one" — a per-kind report has no scale. DMM is that scale: ONE number // in [0,1] per commit (or per working diff), trendable across commits and comparable across authors, tools // and repos. // // THE MODEL. di Biase, Rastogi, Bruntink & van Deursen, "The Delta Maintainability Model: measuring // maintainability of fine-grained code changes", TechDebt 2019 (SIG). The arithmetic and the three risk // thresholds below are PyDriller's `deltamaintainability` reference implementation, verbatim // (pydriller/domain/commit.py, `Method.is_low_risk` / `ModifiedFile._risk_profile` / // `Commit._good_change_proportion`) — read from the package, not paraphrased from the paper: // // A UNIT is a function/method DEFINITION WITH A BODY. Its VOLUME is its line span. // Per property, a unit is LOW risk iff size: loc <= 15 complexity: cx <= 5 interfacing: params <= 2 // A side's RISK PROFILE is (sum of volume over low units, sum of volume over high units). // delta_low = low_target - low_base delta_high = high_target - high_base // good = max( delta_low, 0 ) + max( -delta_high, 0 ) added low-risk code, or removed high-risk code // bad = max( -delta_low, 0 ) + max( delta_high, 0 ) removed low-risk code, or added high-risk code // DMM = good / ( good + bad ) // // WHY THAT SHAPE IS THE POINT. Deleting a god function is the best thing a commit can do, and it scores 1.0. // Adding lines to one is the worst, and it scores 0.0. And — the property this verb exists to protect — // EDITING bad code without growing it moves nothing: the unit sits in the same bin with the same volume on // both sides, so it contributes exactly zero to both good and bad. DMM is a DELTA measure, never a level // measure over a tree. A gate that punished you for touching an already-bad function is a gate people route // around, and that is precisely the failure this model was designed to avoid. // // WHAT `good + bad == 0` MEANS, and what it must never be reported as. A commit that renames a local, edits // a string literal, reorders statements or reflows a comment moves no unit's loc/cx/params. Its total change // is zero and the ratio is 0/0. PyDriller returns None; this verb reports UNAVAILABLE on the attribute and // says so in `reason=`. It is NOT 1.0 ("a perfect commit") and NOT 0.0 ("a terrible one"): it is a commit // this model cannot score, and the house rule is to say that rather than pick a flattering default. The same // applies per property — a change that only adds parameters leaves `size` and `complexity` UNAVAILABLE while // `interfacing` is measured. // // THE COMBINED SCORE IS OURS, NOT THE PAPER'S. PyDriller exposes three separate properties and no // aggregate. `dmm=` on the root is a POOLED ratio — (sum of good over the three properties) / (sum of good + // sum of bad) — which weights each property by how much change it actually saw, and stays defined when one // property is UNAVAILABLE. It is labelled `combine="pooled"` on the root so nobody mistakes it for a // published number. The three sub-scores are emitted alongside it because they are separately actionable. // // THE ONE DEVIATION FROM THE REFERENCE, stated here rather than discovered later. PyDriller's volume is // lizard's `nloc` (non-blank, non-comment lines); ripwire's volume is `Symbol::loc`, the definition's // PHYSICAL line span, which is what this index already carries. Physical span >= nloc, so a heavily // commented or airily formatted unit crosses the size threshold here EARLIER than it would in PyDriller. // The thresholds are unchanged (they are the SIG risk-profile boundaries); the measure under them is // ripwire's. The root discloses this as `size_metric="physical-loc"` and the legend spells it out. // // DETERMINISM. Both profiles are integer sums over the symbol table in index order — no float accumulation, // no hash iteration, no thread timing. Only the final division is floating point, and it is printed at three // decimals from two exact integers that are themselves emitted, so the ratio is auditable rather than // trusted. #include "model.h" #include "quality.h" // materializeCommitTree / TmpTreeGuard / gitResolveCommitSha / gitHeadSha / the HEAD ingest-cache keys #include "gitstamp.h" // stampAt — the at="[+dirty]" anchor (M10: --dmm read git and carried no anchor) #include "ingest.h" // ingest — the second side is a real parse of a materialized tree #include "serialize.h" // escapeXml #include "infra/Diagnostics.h" // DISCLOSE — every failure here degrades to UNAVAILABLE, never aborts #include #include #include #include #include #include namespace rw::dmm { // The SIG risk-profile boundaries, as PyDriller spells them (Method.UNIT_*_LOW_RISK_THRESHOLD). Named, not // inlined as three literals, so a reader can check them against the package without decoding an expression. // // NOT an OUTPUT-class cap, so no `*_capped` attribute belongs beside them (2026-09-10 lift-disclosure // audit). A truncating cap silently DROPS content past a ceiling — kDefsPerNameCap, kSymbolRowCap, the // truncating caps LIMITS.md lists elsewhere. These three do the opposite job: they CLASSIFY every unit // measured (never drop one), and the thresholds they classify against are themselves unconditionally on // the root every run — `low_loc="{kUnitSizeLowRiskMax}" low_cx="{kUnitComplexityLowRiskMax}" ` // `low_params="{kUnitInterfacingLowRiskMax}"` (writeDmmReport, below) — so there is no silent state for a // `_capped` bit to distinguish; the classification boundary is printed on every single report, capped or // not. docs/limits_build.py's static scan still lists them (their names contain "Max", its one keyword // filter), which is why LIMITS.md's "Discloses" column reads "none" for this file — that column tracks // only the `*_capped` shape, and disclosure here takes the always-present-attribute shape instead. inline constexpr std::uint32_t kUnitSizeLowRiskMax = 15; // lines inline constexpr std::uint32_t kUnitComplexityLowRiskMax = 5; // cyclomatic complexity inline constexpr std::uint32_t kUnitInterfacingLowRiskMax = 2; // parameters // The three properties, in the order they are emitted. Index-addressed rather than switched: every loop in // this file walks all three, and a fourth would be a row here plus a threshold above. inline constexpr std::size_t kPropCount = 3; inline constexpr const char* kPropNames[] = { "size", "complexity", "interfacing" }; static_assert( std::size( kPropNames ) == kPropCount, "kPropNames: one name per property — a spelled extent zero-fills a missing one" ); // Why the run could not produce a score. Ok and NoParent/NoGit/… are NOT the same thing as BadRev: the first // group is the environment (degrade to an UNAVAILABLE report, exit 0), BadRev is the user's typo (a refusal // that names the offending token, exit 1). Keeping them apart is the difference between "this tree cannot be // scored" and "you asked for a revision that does not exist". enum class Status : std::uint8_t { Ok, BadRev, BadRange, NoGit, NoParent, MaterializeFailed }; struct RiskProfile { std::uint64_t lowVolume = 0; std::uint64_t highVolume = 0; }; // One side of the comparison (base tree or target tree). struct SideProfile { RiskProfile byProp[kPropCount] {}; std::uint64_t unitCount = 0; // definitions with a body that were measured std::uint64_t volume = 0; // their total line span — the denominator a reader needs to size the deltas }; // One property's answer. The four integers are emitted because the ratio alone is not auditable. struct PropScore { std::int64_t deltaLow = 0; std::int64_t deltaHigh = 0; std::uint64_t good = 0; std::uint64_t bad = 0; bool available = false; // false ⇔ good + bad == 0 ⇔ this property saw no risk-profile change double score = 0.0; }; struct Result { Status status = Status::Ok; std::string badToken; // the revision spelling that did not resolve (BadRev/BadRange only) std::string reason; // the sentence the report prints when there is no score std::string baseSha; // resolved, full std::string targetSha; // resolved, full; empty ⇔ the target is the working tree std::string atStamp; // M10: gitstamp::stampAt(root) — the tool's OWN anchor (HEAD+dirty), // distinct from base=/target= (the compared revisions); "" on a non-git root bool targetIsWorkingTree = false; SideProfile base; SideProfile target; PropScore props[kPropCount] {}; bool available = false; // the COMBINED score exists double score = 0.0; std::uint64_t good = 0; std::uint64_t bad = 0; // The DISCLOSE sink for a side (base or target) whose commit tree could not be materialized or ingested: // the report still prints an answer here (available="0" dmm="UNAVAILABLE" reason="…", exit 0), so this is // NOT a refusal (Diagnostics::answerRefused) — the emitter reads status/reason for exactly this fact. enum class DisclosureWhy : std::uint8_t { BaseTreeUnavailable, TargetTreeUnavailable, }; void disclose( DisclosureWhy why ) noexcept { status = Status::MaterializeFailed; reason = why == DisclosureWhy::BaseTreeUnavailable ? "the base commit's tree could not be materialized or parsed" : "the target commit's tree could not be materialized or parsed"; } }; // Is this symbol a UNIT? A definition with a body, of a callable kind. A prototype, an abstract declaration // and a markdown heading are not units — they have no volume to move, and counting a one-line prototype as a // low-risk unit would let a header reshuffle manufacture a score. Same body test `--readability` uses. inline bool isUnit( const Symbol& s ) noexcept { if( s.kind != SymKind::Function && s.kind != SymKind::Method ) { return false; } return s.endByte > s.sigEndByte; } inline bool isLowRisk( const Symbol& s, std::size_t propIndex ) noexcept { if( propIndex == 0 ) { return s.loc <= kUnitSizeLowRiskMax; } if( propIndex == 1 ) { return s.cx <= kUnitComplexityLowRiskMax; } return std::uint32_t( s.params ) <= kUnitInterfacingLowRiskMax; } // The risk profile of a whole ingested tree. Deliberately NOT keyed by symbol identity: DMM compares two // VOLUMES, and an unchanged unit lands in the same bin with the same volume on both sides, so it cancels to // zero without ever being matched. That is also what makes a pure file move free — the volume leaves one // path and arrives at another inside the same sum. inline SideProfile profileOf( const IngestResult& ing ) { SideProfile out; for( const Symbol& s : ing.symbols ) { if( !isUnit( s ) ) { continue; } ++out.unitCount; out.volume += s.loc; for( std::size_t propIndex = 0; propIndex < kPropCount; ++propIndex ) { RiskProfile& p = out.byProp[propIndex]; if( isLowRisk( s, propIndex ) ) { p.lowVolume += s.loc; } else { p.highVolume += s.loc; } } } return out; } // PyDriller's `_good_change_proportion`, transcribed. The asymmetry is the model: low-risk volume ARRIVING is // good and LEAVING is bad; high-risk volume arriving is bad and leaving is good. inline PropScore scoreProperty( const RiskProfile& base, const RiskProfile& target ) noexcept { PropScore out; out.deltaLow = std::int64_t( target.lowVolume ) - std::int64_t( base.lowVolume ); out.deltaHigh = std::int64_t( target.highVolume ) - std::int64_t( base.highVolume ); if( out.deltaLow >= 0 ) { out.good += std::uint64_t( out.deltaLow ); } else { out.bad += std::uint64_t( -out.deltaLow ); } if( out.deltaHigh >= 0 ) { out.bad += std::uint64_t( out.deltaHigh ); } else { out.good += std::uint64_t( -out.deltaHigh ); } const std::uint64_t total = out.good + out.bad; out.available = total != 0; out.score = out.available ? double( out.good ) / double( total ) : 0.0; return out; } // Join the two sides. Pure arithmetic on two profiles — no git, no IO — so the gate can reason about it and // so `computeDmm` below is the only place that has to be careful about trees. inline void scoreSides( Result& r ) { for( std::size_t propIndex = 0; propIndex < kPropCount; ++propIndex ) { r.props[propIndex] = scoreProperty( r.base.byProp[propIndex], r.target.byProp[propIndex] ); r.good += r.props[propIndex].good; r.bad += r.props[propIndex].bad; } const std::uint64_t total = r.good + r.bad; r.available = total != 0; r.score = r.available ? double( r.good ) / double( total ) : 0.0; if( !r.available ) { r.reason = "no unit's size, complexity or parameter count moved — this change is outside what the model measures"; } } // Ingest the tree at `sha`, materialized out of `root`'s object store. The HEAD side reuses the SAME // incremental ingest-cache family `--quality-delta` already maintains (keyed on repo + excludes + sha, with // per-file content hashes, so it can never serve a foreign or stale parse) — the common case is then a warm // blob read instead of a full parse. Any OTHER revision is parsed cold on purpose: that family is capped at // two files per (repo, excludes), and letting an arbitrary rev-range sweep evict `--quality-delta`'s entry // would trade this verb's speed for that one's on every run. inline bool ingestCommitTree( const std::string& root, const std::string& sha, const std::vector& excludes, std::size_t maxFileBytes, IngestResult& out ) { const std::string tmpRoot = quality::materializeCommitTree( root, sha, "dmm" ); if( tmpRoot.empty() ) { return false; // materializeCommitTree already alerted } quality::TmpTreeGuard guard{ tmpRoot }; std::string cachePath; if( sha == quality::gitHeadSha( root ) ) { const std::string repoHex = quality::cacheRootKeyHex( root ); const std::string exclHex = quality::headSnapExclHex( excludes, maxFileBytes ); cachePath = quality::headSnapCachePath( repoHex, exclHex, sha ); } // ingest() writes single-writer process-global query caches; the sibling HEAD-tree readers in quality.h // serialize on this same mutex, so this one does too rather than inventing a second lock order. std::lock_guard ingestLock( quality::headSnapshotIngestMutex() ); out = ingest( tmpRoot.c_str(), excludes, cachePath.empty() ? std::string_view {} : std::string_view( cachePath ), maxFileBytes ); if( out.symbols.empty() && out.files.empty() ) { // Not a refusal: the caller (computeDmm) still prints available="0" dmm="UNAVAILABLE" reason="…" for // this — the disclosure belongs to the Result the caller owns, via Result::DisclosureWhy (below). return false; } return true; } // Split `--dmm=VALUE`. The three accepted spellings — "" (working tree vs HEAD), "REV" (vs its first // parent), "A..B" (B against A) — and their refusals are NOT parsed here: `quality::resolveRefSpec` owns // them, because `--quality-delta=VALUE` accepts the identical grammar and a second copy of a git-resolution // rule is how the two --quality-delta floors once drifted into disagreeing by 31 findings. This function // keeps only the mapping from that shared verdict onto this verb's own Status vocabulary, so the wording of // every message and the exit behaviour are byte-identical to before the extraction. inline Result computeDmm( const std::string& root, std::string_view spec, const IngestResult& workingIng, const std::vector& excludes, std::size_t maxFileBytes ) { Result r; r.atStamp = gitstamp::stampAt( root ); // M10: the tool's own anchor (HEAD+dirty) — independent of base/target, which name the COMPARED revisions, not when the comparison ran const quality::RefSpec ref = quality::resolveRefSpec( root, spec ); switch( ref.status ) { case quality::RefSpecStatus::NoGit: r.status = Status::NoGit; r.reason = ref.reason; return r; case quality::RefSpecStatus::BadRange: r.status = Status::BadRange; r.badToken = ref.badToken; return r; case quality::RefSpecStatus::BadRev: r.status = Status::BadRev; r.badToken = ref.badToken; return r; case quality::RefSpecStatus::NoParent: r.status = Status::NoParent; r.reason = ref.reason; return r; case quality::RefSpecStatus::Ok: break; } r.baseSha = ref.baseSha; r.targetSha = ref.targetSha; r.targetIsWorkingTree = ref.targetIsWorkingTree; IngestResult baseIng; if( !ingestCommitTree( root, r.baseSha, excludes, maxFileBytes, baseIng ) ) { DISCLOSE( r, Result::DisclosureWhy::BaseTreeUnavailable, "dmm: the base commit's tree could not be materialized or parsed" ); return r; } r.base = profileOf( baseIng ); if( r.targetIsWorkingTree ) { r.target = profileOf( workingIng ); // already parsed, with the same excludes this run was given } else { IngestResult targetIng; if( !ingestCommitTree( root, r.targetSha, excludes, maxFileBytes, targetIng ) ) { DISCLOSE( r, Result::DisclosureWhy::TargetTreeUnavailable, "dmm: the target commit's tree could not be materialized or parsed" ); return r; } r.target = profileOf( targetIng ); } scoreSides( r ); return r; } // The legend the reader meets FIRST. Every attribute this verb emits is DEFINED here in the house `name=` // form. No `--` digraph anywhere in it — illegal inside an XML comment — so flags are named bare. inline constexpr const char* kDmmLegend = ""; // Print one score attribute: the number, or the honest token. Shared by the root and the property rows so the // two can never drift into disagreeing about what "no score" looks like — which is also why the value is // formatted into ONE buffer and emitted from ONE printf rather than through a two-armed branch: there is a // single place that decides how a score is spelled, and UNAVAILABLE is its default rather than its else. // 16 bytes holds both spellings with room to spare ("UNAVAILABLE" is 11 + NUL; "%.3f" of a ratio in [0,1] is 5). inline void printScoreAttr( const char* name, bool available, double score ) { char value[16] = "UNAVAILABLE"; if( available ) { rw::formatTo( value, sizeof value, "{:.3f}", score ); } rw::emitTo( stdout, " {}=\"{}\"", name, rw::cstr( value ) ); } // Emit the report. Returns the process exit code — always 0. This is a MEASUREMENT, not a gate: it has no // threshold and renders no verdict, and the moment a maintainability score gates a merge is the moment // people start writing code to the score. inline int writeDmmReport( const Result& r ) { std::vector escReason; std::vector escBase; std::vector escTarget; std::fputs( kDmmLegend, stdout ); std::fputs( "", reason.c_str(), atAttrStr.c_str() ); return 0; } const std::string base( escapeXml( r.baseSha, escBase ) ); const std::string target( escapeXml( r.targetIsWorkingTree ? std::string( "working-tree" ) : r.targetSha, escTarget ) ); // P8 (L7): the three low-risk thresholds beside the numbers they judge (PyDriller's, verbatim — see the constants) rw::emitTo( stdout, " base=\"{}\" target=\"{}\"{} available=\"{}\" combine=\"pooled\" size_metric=\"physical-loc\" low_loc=\"{}\" low_cx=\"{}\" low_params=\"{}\"", base.c_str(), target.c_str(), atAttrStr.c_str(), r.available ? 1 : 0, kUnitSizeLowRiskMax, kUnitComplexityLowRiskMax, kUnitInterfacingLowRiskMax ); printScoreAttr( "dmm", r.available, r.score ); rw::emitTo( stdout, " good=\"{}\" bad=\"{}\"", static_cast( r.good ), static_cast( r.bad ) ); rw::emitTo( stdout, " base_units=\"{}\" base_volume=\"{}\" target_units=\"{}\" target_volume=\"{}\"", static_cast( r.base.unitCount ), static_cast( r.base.volume ), static_cast( r.target.unitCount ), static_cast( r.target.volume ) ); if( !r.available ) { const std::string reason( escapeXml( r.reason, escReason ) ); rw::emitTo( stdout, " reason=\"{}\"", reason.c_str() ); } std::fputs( ">", stdout ); for( std::size_t propIndex = 0; propIndex < kPropCount; ++propIndex ) { const PropScore& p = r.props[propIndex]; rw::emitTo( stdout, "

", static_cast( p.good ), static_cast( p.bad ), static_cast( p.deltaLow ), static_cast( p.deltaHigh ) ); } std::fputs( "", stdout ); return 0; } } // namespace rw::dmm