#include "infra/emit.h" // rw::emitTo / emitRaw / formatTo — THE emitter and its siblings #include "gitcmd.h" // rw::gitCmd — every git child starts with --no-optional-locks -c core.fsmonitor=false #include // %.*s (precision, pointer) collapses to one view #include // std::async — the history walk that runs beside the ingest // main.cpp — ripwire entry point: parse args → ingest → graph → PageRank → minified XML, // plus --map-diff (teleport toward git-changed files) and --pack-top-n (append source). // ingest() comes from ingest.cpp (real, tree-sitter) or stub_ingest.cpp (test). #include "model.h" #include "nextverb.h" // P3 (L7): next= on every enumerated root (the verbs_*.h fragments read it from here) #include "infra/stdinline.h" // R4: readByteSafeLine — the ONE byte-safe stdin line reader (--from-trace=- / --batch=-) #include "ingest.h" #include "workspace.h" // multi-root workspaces: root hygiene + labels + the id-offset merge #include "graph.h" #include "scip.h" // SCIP precision overlay (--scip=index.scip) #include "serialize.h" #include "pageview.h" // §P8: the ONE --limit/--offset window + root-element shown=/capped= disclosure #include "graphlegend.h" // §H4 §3.4: the ONE counts_floor= marker + the shared graph-count legend wording #include "pathgaps.h" // PATH-GAP: --path's gap analysis + clause, shared with MCP path_between #include "columnar.h" // RESEARCH lever 1: opt-in columnar re-serialization for the flat list verbs (--format=columnar) #include "redact.h" // RedactCounts + reportRedactions for the emitted-body secret redaction #include "filter.h" #include "eval.h" #include "skilleval.h" #include "lexical.h" #include "recall.h" #include "situ.h" #include "handoff.h" // --handoff: the continuation packet (verified + heuristic sections) #include "dmm.h" // --dmm: the Delta Maintainability Model scalar — the trendable complement to --quality-delta #include "readability.h" // --biggest-first (was --readability): the Posnett (MSR 2011) per-function readability lens #include "commentcoherence.h" // --comment-coherence: Steidl c_coeff + Scalabrino CIC, per documented function/method #include "contextratio.h" // --context-ratio: the LOCAL-REASONING lens (outside-the-file share of a unit's context) #include "nonlocalstate.h" // --nonlocal-state: per function, the non-local MUTABLE state it reaches (reads vs writes) #include "renamemine.h" // --naming-calibration: the naming-* rules scored against the repo's own rename history (§9.5) #include "namingconsistency.h" // --naming-consistency: §9.2 TIER A convention normalization (corpus-derived case-style vote) #include "ensemble.h" // --ensemble: the family join over structural / lexical / confusion / historical evidence #include "qualitypanel.h" // --quality-panel: THE SINGLE COMMAND — the ensemble's four families plus colocation and state, under a preset #include "testmap.h" // §P11.2/§P11.4: the test<->code map both ways (--affected=SYM seeding) #include "packtask.h" // L4: the shared --pack-task / MCP explore/pack_task bundle assembler (packTaskBundleText) #include "partition.h" // --pack-task --partition=N — the fan-out form (core + N slices), same assembler. // BEFORE mcp.h so mcpverbs.h's explore verb can reach packTaskPartitionText (same rule packtask.h follows). #include "tracelocus.h" // L4: the shared --from-trace / MCP from_trace bundle assembler (fromTraceBundleText) #include "callhierarchy.h" // H14/M13: the shared --callers/--callees / MCP find_* 1-hop computation (CLI == MCP rows) #include "countfloor.h" // count-floor: a row's callers=/implementors= floor marker and its follow-up (CLI and MCP share it) #include "editcheck.h" // L4: the shared --edit-check / MCP edit_check contract-comparison core (editCheckBundleText) #include "slice.h" // lane/paper-slice: --slice=SYM[:VAR] — the ARISE-motivated def-use slice core (sliceBundleText) #include "editpreview.h" // card A1: the PRE-APPLY contract preview (editpreview::run) — BEFORE mcp.h, which // is what lets the MCP edit_check verb mirror it from mcpverbs.h #include "slicediff.h" // card A4: --slice=SYM:VAR --since=REV — the def-use slice as a DEPENDENCE diff #include "mcp.h" #include "compactlegend.h" // P1 (L7): the --legend=compact dialect layer for every XML verb (runWithCompactLegend) #include "mcpserver.h" // the optional remote MCP transport (--listen), picked below #include "editplan.h" // CLI-first versioned multi-edit transactions #include "wrap.h" // P8 (L7): the test-gate root's ccx_bar= (situ.h kTestGateCcxBarMirror) is quality.h's kCcxBar — one bar, two spellings, // pinned equal in the one TU that sees both (quality.h is also compiled standalone by the bench/probe targets). static_assert( rw::kTestGateCcxBarMirror == rw::quality::kCcxBar, "situ.h kTestGateCcxBarMirror must equal quality.h kCcxBar" ); #include "infra/profileScope.h" // PROFILE_SCOPE self-profiling — gated by PROFILE_ENABLED (off unless -DRIPWIRE_PROFILE=ON) #include "arch.h" #include "search.h" #include "query.h" #include "pattern.h" // R2: the pattern surface's compiler + disclosures (the matcher runs inside the ingest walk) #include "verify.h" // G4 verify-a-claim: the --verify closed claim grammar + verdict/limit vocabularies (runVerify below) #include "forpage.h" // forWidenNext — the ONE spelling of the --for widening page #include "taskroute.h" // --help-task: deterministic task -> one safe CLI recommendation or abstention #include "quality.h" #include "cloneidiom.h" // idiom-class demotion for clone findings — the closed 3-idiom shape classifier both --clones and the quality-delta duplication kind annotate rows with #include "gitstamp.h" #include "githarden.h" // harvest 2026-09-09: the git-config trust boundary — one call in main(), before any thread // r26-stamp Task A: gitstamp::atAttr — the at="[+dirty]" root anchor, shared by // --hotspots / --quality-delta / --doctor below (each verb's own file pulls it too) #include "binstale.h" // --doctor's tracked-binary-staleness check (git-order, not mtime) #include "codexdoctor.h" // --doctor --agent=codex: live binary/skills/hooks/MCP surface parity #include "crossref.h" // --stray-content / --whereis — the cross-branch content index #include "darkflags.h" // --flags — the dark-content (compile/cmake/env gate) dashboard #include "flipimpact.h" // --flags --flip=NAME: the blast radius of turning ONE of those gates ON #include "layout.h" // --layout=STRUCT — computed field offsets + tripwires + mirror drift #include "fieldaffinity.h" // --field-affinity — the cache-locality lens (co-access graph vs declared order) #include "abicheck.h" // --stray-content --abi — the cross-branch ABI-BREAK gate (layout x stray-content) #include "docdrift.h" // --doc-drift — the markdown doc-anchor verifier #include "planlint.h" // P3.2: --plan-lint=FILE — the house PLAN format's STRUCTURE check (cards vs the // status ledger, terminal glyphs, stale hourglass lines, undischarged owed mentions) #include "gitoracle.h" // --with-history: the shared "was this name ever here" git-history oracle #include "mergescout.h" // L1: --merge-scout=REF[,REF...] — read-only cross-branch overlap + landing order #include "landingplan.h" // --stray-content --plan — composes crossref's sweep with mergescout's overlap oracle #include "lanes.h" // --plan-lanes=N --task / --plan-lanes --brief — the PRE-HOC lane plan (JSON on stdout) #include "exemplar.h" // A3-F5: shared --exemplar selection (ccx ceiling + fixture penalty + task→kind confidence) #include "didyoumean.h" // §P12.1 / §B6 M8: the ONE near-miss suggester, now shared with the MCP refusal table #include "selectorrefuse.h" // §B4.2: the ONE file:name selector not-found refusal — all six SYM-taking verbs #include "gitmine.h" #include "historyread.h" // K51: the disclosure of a git history walk that could not be read (churn=/amp=) #include "ownersview.h" // §P6.4: countUniformOwnership/ownershipRowsToPrint — shared with mcpverbs.h's `owners` verb #include "mention.h" // B8: query-mention anchoring — files/modules/symbols NAMED in the --for text #include "siblift.h" // r4 EXPERIMENT: env-gated same-directory sibling lift (inert by default) #include "filepool.h" // r5 EXPERIMENT: env-gated file-level evidence pooling (inert by default) #include "expand.h" // r6 EXPERIMENT: env-gated structural expansion from top-ranked files (inert by default) #include "tracein.h" // L2: --from-trace=FILE — table-driven stack-trace/sanitizer/compiler frame extraction #include "clones.h" #include "skillscan.h" #include "htmlexport.h" #include "lintrules.h" #include "lintcatalog.h" // L7: --lint-catalog + --lint-select=/--lint-ignore= — the built-in rule registry #include "atoms.h" // --lint: the atoms-of-confusion pack (Gopstein FSE 2017), C-family only #include "cachelint.h" // --lint: the cache-friendliness pack (access-pattern half; layout half is --field-affinity) #include "naminglens.h" // identifier-naming lens v1: the naming-* built-in --lint rules (deterministic, dictionary-free) #include "verbtable.h" // V6: known-verb dictionary for the community/zoom label verb-histogram suffix #include "sarif.h" // --sarif: SARIF 2.1.0 re-serialization of --lint's findings (github code-scanning UI) #include "prcontext.h" #include "ccjson.h" #include "cli.h" #include "embedded_queries.h" // configure-generated tags.scm table shared with ingest and --doctor #include "infra/hashutil.h" // sanitizer-clean modulo-2^64 FNV multiplication #include "infra/charconvcompat.h" // rw::parseFloating — FP from_chars is `= delete` on older libc++, unavailable below macOS 26 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // VT-1 --run-trace: the wall clock behind duration_ms/timeout (steady_clock) #include // VT-1 --run-trace: SIGKILL for the timeout's process-group kill #include // --doctor's grammar-probe check (ts_query_new against each grammar's tags.scm) #include "infra/os.h" // rw::os — getpid, the --run-trace child (spawn_sh/poll/waitpid/kill), --doctor's exepath namespace { using rw::shSingleQuote; // defined in gitmine.h, shared with the MCP server // The per-user cache-dir ladder + the git-baseline helpers now live in quality.h (the baseline home) so BOTH // the CLI --quality-* paths and the MCP quality_delta/quality_baseline verbs call ONE copy — no duplicated // git-archive / stale-vs-HEAD logic. Re-exported here so every existing call site below is byte-unchanged. using rw::quality::cacheDirLadder; using rw::quality::gitHeadSha; using rw::quality::gitRepoHasHistory; using rw::quality::computeHeadSnapshot; using rw::quality::resolveCacheBlobPath; // Y4: shard-aware blob path — see quality.h // 2026-08-29 main.cpp split: the cross-family helpers promoted to their domain headers, re-exported so // every existing call site below (and in the verb-family sections this file includes) is byte-unchanged. using rw::gitChangedFiles; // situ.h — the working-tree changed-file mask, one mask builder for CLI + MCP using rw::gitChurnCounts; // gitmine.h — per-file commit counts over a window using rw::mineChurnPerFile; // gitmine.h — THE shared per-file churn miner (single- and multi-root) using rw::quality::isHeaderPath; // quality.h — the --dead-code eligibility trio, shared with --safe-delete using rw::quality::sourceHasStaticToken; using rw::quality::deadCodeEligibleKind; // Warm-by-default cache location: a per-root file keyed by the root's ABSOLUTE path, under the hardened // cacheDirLadder(), so repeated invocations on the same tree re-parse only changed files. // // The 16-hex root field comes from quality.h's `cacheRootKeyHex` — the ONE canonical spelling every cache // family now shares (lean/rich here, `ripwire-mcp--.cache`, and shaKeyedCachePath's qheadsnap/qsnap/ // qbody/qhist/qms/qchurn/stier). This function used to open-code the hash with a TRUNCATED FNV-1a offset // basis while quality.h used the real one, so one root minted two key families and the byte-budget pin // (evictBySizeBudget) could only ever see half of them. The seed that survived is this function's, because // it is the one that keeps the 1.76 GB llvm parse cache warm; the full argument lives beside // `kCacheRootKeySeed`. // Absolute path so two different dirs both invoked as "." don't collide (a collision would only ever // cost a cold re-parse — the cache is keyed per-file-path internally — but absolute keeps each tree's // cache distinct and warm). Cache content is content-hashed + parserVer-gated, so a stale/foreign cache // self-heals to a correct cold parse. STABLE per (user, root): same root + same env → same path, so // warm-cache reuse still works across runs. // // A4-P4: the cache file is SPLIT by verb class (lean vs rich). --for/--metrics/--uses/--exemplar // ingest "rich" (captureValueUses=true); everything else "lean". Both classes are parserVer-gated to // DIFFERENT versions (parserVerFor), so a single shared file forced a full re-parse + full rewrite on // every class switch (measured: plain-map-after --for 0.81 s vs 0.16 s warm). Suffixing the filename // with the class gives each class its OWN warm cache, so alternating verb classes never thrashes. // (MCP has its own separate file, mcpCachePath in mcp.h — unaffected.) // // Y4: resolved through resolveCacheBlobPath (quality.h) — the shard-aware, backward-compatible // choke point every ripwire-*.bin blob path now routes through. See its comment for the full rationale. // // THE EXCLUDE SET AND --max-file-size ARE DELIBERATELY NOT IN THIS KEY, and that is a measured decision, // not an oversight. Folding them in (one blob per exclude configuration per root) was built and it met // every band it was registered against — and it was REVERTED, because the un-excluded root of a real // development tree is 158,202 files, twelve gate configurations then want twelve 686 MB blobs, and the // cache directory's 2 GiB cap (kMaxCacheDirBytes) evicts the blob a running gate is about to reuse: the // full battery went from 7 minutes to 62. docs/EVALS.md, "The auto-cache key ignores --exclude", RUN // 2026-09-03, is the registered NEGATIVE; do not bring the key change back. // // The two costs that key change was written against are paid off by the BLOB'S SHAPE instead // (kCacheVersion 15, src/ingest_cache.h): one superset blob per (root, class) carries a record OFFSET // TABLE, so a run deserialises only the records for the files it actually crawled, and a save carries // over verbatim the records for files it did not crawl — so a narrower configuration is cheap to load // and can no longer truncate the shared blob. Gate: test/cacheoffsetcheck.sh. // // #334 follow-up — THE BUILD TAG. The name also carries (kCacheVersion, the class's parserVer): // `ripwire--lean-cp.bin`. Keyed by root and class alone, two builds of different formats that // alternate on one tree (an installed release and a local build, or two installed versions) refused and rewrote // each other's blob on every run. This is NOT the reverted key change above: that one multiplied blobs per root // by CONFIGURATION inside one build, which every gate battery exercises; this one adds a blob per root only per // FORMAT that actually ran on it, the budget sweep unpins another build's blobs (quality.h evictBySizeBudget), // and the age pass retires a version nobody runs any more. See quality.h rootBlobTail. std::string defaultCachePath( const std::string& root, bool captureValueUses ) { return rw::quality::rootKeyedCachePath( root, captureValueUses ? rw::quality::RootBlobFamily::Rich : rw::quality::RootBlobFamily::Lean ); } // computeHeadSnapshot / gitHeadSha / gitRepoHasHistory / cacheDirLadder now live in quality.h (the // baseline home) and are re-exported via the `using` aliases above — one shared copy for CLI + MCP. // gitRepoHasHistory / gitHeadSha moved to quality.h (re-exported via the `using` aliases above) so the CLI // --quality-* paths and the MCP quality_delta/quality_baseline verbs share ONE copy of the HEAD probes. // Wave-4 remote ergonomics: `ripwire ` — if the positional arg is a git URL (https:// or // git@), shallow-clone it (git clone --depth=1, core.quotepath=false) into a stable per-URL cache dir // under $TMPDIR (reused if it already exists) and return the local path to map. On success, prints one // stderr line telling the user where it cloned. On failure, prints a clear error and returns "" (the // caller degrades: error + exit 1). A non-URL arg is returned unchanged (no clone attempted). // // A3-F15: a leading '-' is rejected here even if it otherwise matches a scheme prefix (it can't, since // none of the four prefixes start with '-', but this also blocks a bare "-..." that some future scheme // addition might otherwise accept) — the load-bearing option-injection defense lives at the clone // call site (`--` before the URL + the protocol.ext/protocol.file allow-list below); this check is a // second, cheap gate on the "is this even a URL" recognizer itself. bool isGitUrl( std::string_view s ) noexcept { if( s.empty() || s.front() == '-' ) { return false; // never treat a dash-leading arg as a URL (option-injection guard) } return s.rfind( "https://", 0 ) == 0 || s.rfind( "http://", 0 ) == 0 || s.rfind( "git@", 0 ) == 0 || s.rfind( "ssh://", 0 ) == 0; } // T2 pagination window + the §P8 root-element disclosure both live in pageview.h now — three serializers // need them (main.cpp's verbs, crossref.h's --whereis, docdrift.h's --doc-drift) and the vocabulary must not // drift between them. `using` rather than a re-declaration so there is exactly one definition. using rw::PageWindow; using rw::pageWindow; using rw::pageDisclosure; using rw::pagingDisclosure; using rw::effectiveRowCap; // §P8 G1: the local pageAttr() that used to live here — a bare ` offset="M" limit="N"` for --callers/ // --callees/--tree — is DELETED, not deprecated. It cut rows correctly but disclosed neither the total nor // has_more/next_offset, so those three verbs looked paginated while a loop over them could never terminate: // a second, strictly weaker paging vocabulary sitting next to the real one. Every caller now uses // pageDisclosure() (or pagingDisclosure() where a noun-prefixed shown_= already serves), so there is // exactly ONE spelling of "this response is a page" in the tool. See src/pageview.h. // octocode partial-fetch + the §P8 seam-1 selector: split one --expand/--outline token into the SELECTOR // it resolves through and an optional 1-based [start,end] line range. The grammar (documented in --help): // // NAME whole body (original) // NAME:START-END body slice (original) // FILE:NAME file-qualified selector (§P8 seam 1) // FILE:LINE:NAME a pasted `p="path:line"` locator + the row's n= (§P8 seam 1) // FILE:NAME:START-END selector AND slice (§P8 seam 1) // // The text after the LAST ':' decides, by ONE rule: **it is a range attempt iff it starts with a digit** // — no identifier in any grammar we parse starts with a digit, so the two readings can never collide. // Everything else leaves the WHOLE token as the selector, which then resolves through the same // resolveAllByNameQualified() --callers/--callees/--impact use (one resolver, not a second implementation): // bare name → every def of that name; canonical id → exact; file:name → that file's def. This is what makes // `--callers=X` → pick a row → fetch its body a real chain — before it, the `:` in the row's own `p=` was // read as a range, warned "malformed range", and the leftover path was refused as a typo'd symbol name. // // A range attempt that does not parse (non-numeric, empty half, START==0) still DEGRADES to whole-body — // never a hard error, never a crash — with a one-line stderr note naming `verb` (house style: recoverable // input ⇒ degrade + note, not ASSUME/throw). `verb` exists because --outline routes through here too and // used to emit a note blaming --expand (§P10 X7). A reversed range (START>END) is NOT rejected here — // sliceBodyLines() swaps it defensively — so only truly unparseable text degrades. struct ExpandToken { std::string selector; rw::LineRange range; }; inline ExpandToken parseExpandToken( const std::string& token, const char* verb = "--expand" ) noexcept { ExpandToken out; out.selector = token; // the default for every non-range shape // Split at the LAST ':' — a canonical id (path::scope::name, exactly what --for/--pack-task emit in id=) // is full of ':' and every one of them is scope syntax, never a range seam. Splitting at the FIRST ':' // turned "./src/serialize.h::XmlWriter::write" into the name "./src/serialize.h" and then refused it. const std::size_t colon = token.rfind( ':' ); if( colon == std::string::npos ) { return out; // no range → whole-body, unchanged } const std::string_view rangeStr = std::string_view( token ).substr( colon + 1 ); // The disambiguator. A tail that does not open with a digit is a NAME, so the whole token is a // file-qualified selector — return it untouched and silently (this is a CORRECT input, not a botch). if( rangeStr.empty() || rangeStr.front() < '0' || rangeStr.front() > '9' ) { return out; } // An @FILE:LINE line seed (lane/at-seed) carries its own trailing digits: on an @-led token a // digit-led tail with NO dash is the seed's line, not a range attempt — the whole token is the // selector and resolveAtSeed reads the line half itself. "@src/f.cpp:120:5-10" still slices: its // tail has the dash, so the range strips here and "@src/f.cpp:120" resolves as the seed. if( token.front() == '@' && rangeStr.find( '-' ) == std::string_view::npos ) { return out; } out.selector = token.substr( 0, colon ); const std::size_t dash = rangeStr.find( '-' ); const auto parseU32 = []( std::string_view s, std::uint32_t& v ) noexcept { if( s.empty() ) { return false; } for( char c : s ) { if( c < '0' || c > '9' ) { return false; // digits only — no sign, no whitespace } } char* end = nullptr; const long n = std::strtol( std::string( s ).c_str(), &end, 10 ); if( n <= 0 || n > 0x7FFFFFFFL ) { return false; // START/END are 1-based; 0 or overflow is malformed } v = std::uint32_t( n ); return true; }; std::uint32_t startLine = 0, endLine = 0; const bool parsed = dash != std::string_view::npos && parseU32( rangeStr.substr( 0, dash ), startLine ) && parseU32( rangeStr.substr( dash + 1 ), endLine ); if( !parsed ) { // A "::" anywhere means the tail is a scope segment, not a botched range: the whole token is the // name. Silent by design — "path::scope::name" is a CORRECT input, so warning about it would train // the reader to ignore a warning that fires on the happy path. if( token.find( "::" ) != std::string::npos ) { out.selector = token; return out; } rw::emitTo( stderr, "ripwire: {}={}: malformed range '{}' (want START-END, e.g. 5-10) — emitting the whole body\n", verb, token.c_str(), std::string_view( rangeStr.data(), rangeStr.size() ) ); return out; // degrade: selector only, hasRange stays false } out.range.startLine = startLine; out.range.endLine = endLine; out.range.hasRange = true; return out; } // S3: how old is a cached clone, in whole days, from the cache DIR's mtime (no network call — the dir's // mtime is bumped by the clone itself and untouched by a read-only map, so it approximates "when we last // fetched"). Clamped to >= 0 so a clock skew (mtime in the future) never prints a negative age. long cloneAgeDays( const std::string& cacheDir ) { rw::os::stat_t st{}; if( rw::os::stat( cacheDir.c_str(), &st ) != 0 ) { return 0; } const long long ageSec = static_cast( std::time( nullptr ) ) - static_cast( st.st_mtime ); return ageSec > 0 ? static_cast( ageSec / 86400 ) : 0; } // returns { localPath, ok }. ok=false ⇒ clone failed (caller exits 1). Non-URL ⇒ { url-as-is, true }. // refetch=true forces a fresh clone even if a cached one already exists (S3: the cache otherwise never // refetches — a months-old clone would be silently mapped forever). std::pair resolveRemoteRoot( const std::string& urlOrPath, bool refetch = false ) { if( !isGitUrl( urlOrPath ) ) { return { urlOrPath, true }; } // cache key = FNV-1a-64 of the URL → /ripwire-remote-. Reuse if the dir // already exists (idempotent: a second run on the same URL is instant, no re-clone) — S4: same // $TMPDIR → $XDG_CACHE_HOME/ripwire (0700) → /tmp ladder as defaultCachePath/mcpCachePath. std::uint64_t h = 1469598103934665603ull; for( const char c : urlOrPath ) { h = rw::hashutil::fnv1aAbsorb( h, c ); } char tail[ 48 ]; rw::formatTo( tail, sizeof( tail ), "/ripwire-remote-{:016x}", static_cast( h ) ); const std::string cacheDir = cacheDirLadder() + tail; namespace fs = std::filesystem; std::error_code ec; if( !refetch && fs::exists( fs::path( cacheDir ) / ".git", ec ) && !ec ) { const long ageDays = cloneAgeDays( cacheDir ); rw::emitTo( stderr, "ripwire: reusing cached clone of {} ({} day{} old); pass --refetch to update\n", urlOrPath.c_str(), ageDays, ageDays == 1 ? "" : "s" ); return { cacheDir, true }; } // fresh clone (either no cache yet, or --refetch forced one). Remove any existing/partial dir first // so a half-clone (or a stale one, under --refetch) can't poison the cache. // // A3-F15 hardening: `-c protocol.ext.allow=never` blocks the `ext::` transport (arbitrary command // execution via a crafted "URL") and `-c protocol.file.allow=user` keeps `file://` limited to the // invoking user's own privilege (no cross-user local-clone tricks); `--` before the URL stops a // URL string that starts with `-` from ever being parsed as a `git clone` OPTION even if isGitUrl's // own leading-dash guard were ever bypassed or loosened — belt-and-suspenders, not the only gate. fs::remove_all( fs::path( cacheDir ), ec ); const std::string cmd = rw::gitCmd( " -c protocol.ext.allow=never -c protocol.file.allow=user -c core.quotepath=false " "clone --depth=1 -q -- " ) + rw::shSingleQuote( urlOrPath ) + " " + rw::shSingleQuote( cacheDir ) + " 2>&1"; rw::emitTo( stderr, "ripwire: cloning {} → {}\n", urlOrPath.c_str(), cacheDir.c_str() ); std::FILE* pipe = rw::os::popen( cmd.c_str(), "r" ); if( !pipe ) { rw::emitRaw( stderr, "ripwire: could not launch git clone (is git on PATH?)\n" ); return { std::string(), false }; } char line[ 4096 ]; while( std::fgets( line, sizeof( line ), pipe ) ) { rw::emitTo( stderr, " {}", rw::cstr( line ) ); // surface git's own diagnostics } const int rc = rw::os::pclose( pipe ); if( rc != 0 || !( fs::exists( fs::path( cacheDir ) / ".git", ec ) && !ec ) ) { rw::emitTo( stderr, "ripwire: git clone failed for {}\n", urlOrPath.c_str() ); fs::remove_all( fs::path( cacheDir ), ec ); return { std::string(), false }; } return { cacheDir, true }; } // §P12.1 / A3-F16a: the bounded-edit-distance near-miss suggester moved to src/didyoumean.h (§B6 M8) so // the MCP arms can reach the SAME one — their not-found refusals used to carry no suggestion at all // because this code was unreachable from a header. Pulled back in under its original three names, so // every call site below (17 of them) is unchanged. using rw::boundedEditDistance; using rw::didYouMean; using rw::withDidYouMean; using rw::selectorNotFoundMessage; // §B4.2: the shared file:name selector refusal (selectorrefuse.h) // ── verb-dispatch context (Phase B7.2) ─────────────────────────────────────────────────────────────── // The shared, post-graph state every verb handler reads. Bundling it into one views-at-the-seam struct // keeps each handler to a single parameter; each handler rebinds only the fields it uses, so the moved // bodies below are byte-identical to the pre-B7.2 inline dispatch blocks in main(). // §P4.1 — the --grep scan phases, computed AHEAD of this struct (see verbs_grep.h). Forward-declared so // MainDispatch can carry a pointer to them without the grep section having to precede it; nullptr is the // ordinary case (any run whose answering verb is not grep) and the verb then computes them inline. struct GrepScanPhases; // ── card A1: --edit-check=SYM --edit-payload=FILE --dry-run — WHO OWNS THE COMBINATION ──────────────── // --edit-payload and --dry-run were each spoken for: the first by the three CLI write verbs, the second by // --edit-plan. Both of those handlers dispatch BEFORE the ingest pipeline, so without this predicate the // preview never reaches --edit-check at all — it lands on "--edit-payload requires one of // --replace-symbol-body…", a refusal that is true of yesterday's flag table and useless about what was typed. // One predicate, read by all three handlers, so the ownership is stated once. bool editPreviewRequested( const rw::Config& c ) noexcept { return !c.editCheckSym.empty() && !c.editPayload.empty() && c.editPlanDryRun && !c.editPlanApply; } // the half-typed forms, refused where the caller can still see which flag is missing. Returns nullopt for // every combination that is somebody else's business (including the complete preview, which the ordinary // --edit-check handler answers after the tree is parsed). std::optional runEditPreviewGuard( const rw::Config& c ) { if( c.editCheckSym.empty() || !c.editPlan.empty() ) { return std::nullopt; } if( !c.editPayload.empty() && c.editPlanApply ) { rw::emitTo( stderr, "ripwire: --edit-check --edit-payload is a PREVIEW and never writes — pass --dry-run, or apply the " "payload with --replace-symbol-body={} --edit-payload={}\n", std::string_view( c.editCheckSym.data(), c.editCheckSym.size() ), std::string_view( c.editPayload.data(), c.editPayload.size() ) ); return 1; } if( !c.editPayload.empty() && !c.editPlanDryRun ) { rw::emitRaw( stderr, "ripwire: --edit-check --edit-payload previews an unwritten payload and requires --dry-run " "(add --dry-run to preview; use --replace-symbol-body to actually write it)\n" ); return 1; } if( c.editPayload.empty() && c.editPlanDryRun ) { rw::emitRaw( stderr, "ripwire: --edit-check --dry-run needs the bytes to preview — pass --edit-payload=FILE " "(or --edit-payload=- for stdin)\n" ); return 1; } return std::nullopt; } struct MainDispatch { const rw::Config& cfg; const rw::IngestResult& ing; const rw::Graph& g; const std::string& root; bool multiRoot; const std::vector& ws; const std::vector& fanIn; const std::vector* fanInPtr; const rw::QMetrics& qmetrics; const std::vector* ampPtr; const std::vector* cboPtr; const std::vector* testedPtr; const std::vector* lcom4Ptr; const std::vector* impurePtr; std::vector& forChurn; rw::RedactCounts& redactCounts; rw::RedactCounts* redactPtr; const rw::notes::NoteIndex* notesPtr; // L3: field-notes surfacing index (nullptr ⇒ inert: no/empty file, or multi-root) bool notesDegraded = false; // L3 follow-up (CodeRabbit 4053600616): the sidecar // read left something out this run — set once, // beside notesPtr, before notesPtr is nulled for // emptiness (so a fully-degraded, zero-note read // still reaches the map/--expand / roots) const GrepScanPhases* grepPhases = nullptr; // §P4.1: prefetched grep scan (nullptr ⇒ compute inline) bool valueUses = false; // card A1: the captureValueUses this run's ingest actually used, so the // pre-apply preview re-parses its one spliced file the SAME way }; } // namespace — part 1: the shared preamble helpers + the verb-dispatch context // ── the verb-family sections (2026-08-29 split) ────────────────────────────────────────────────────── // Each verbs_*.h below is a SECTION of this translation unit, not a library header: it reopens the // unnamed namespace above (one TU, one unnamed namespace), sees every #include and preamble helper // defined so far, and is included exactly once, right here. RIPWIRE_MAIN_TU is the enforcement: any // other includer is a compile error. Order matters — a later section may call an earlier one (the // change family calls the for family's computeLensRanking; everything may call the promoted domain // helpers re-exported at the top of this file). #define RIPWIRE_MAIN_TU 1 #include "verbs_doctor.h" #include "verbs_lint.h" #include "verbs_for.h" #include "verbs_navigate.h" #include "verbs_quality.h" #include "verbs_change.h" #include "verbs_report.h" #include "verbs_grep.h" #include "memguard.h" // #350 layer 3: the memory guard's limit (installed in main) and its verdicts after ingest #include "lsp.h" // the --lsp navigation-server section (Phase 1 PoC — docs/LSP.md); after the verb families so it can reuse the shared use-site scan // ── LANGUAGE-REGISTRATION COMPLETENESS, at compile time ────────────────────────────────────────────────────────── // Appending a Lang is not one table. 02f798e3 left Dart out of kUnanalyzedLangs, kCatalogLangs and langFromToken's map; // 9418e35e found five languages the unanalyzed disclosure never named; PR #233 nearly shipped without its `.gd` // langOfPath row. Each stayed one language short because nothing but a gate run on the right fixture could notice. // This is the one translation unit that includes every one of those tables, so the checks live here. Each returns the // FIRST Lang index that is wrong and kLangCount when clean, so the compiler's note names the language // ("'21 == 23'" is Dart). A value a zero-filled row can also produce, such as an empty string, would pass the defect. // model.h's isCodeLang is the one declared exemption: a data or document format joins none of these tables. // ingest_crawl.h holds the sibling check for langOfPath's extension table, which needs kLangTable. namespace rw::langreg { // every code language is either analysed by --nonlocal-state or named by it as unanalysed, never both and never neither constexpr std::size_t firstLangNonlocalMisclassifies() noexcept { for( std::size_t index = 0; index < kLangCount; ++index ) { const Lang lang = Lang( index ); const bool isAnalyzed = std::ranges::find( nonlocal::kAnalyzedLangs, lang ) != nonlocal::kAnalyzedLangs.end(); const bool isUnanalyzed = std::ranges::find( nonlocal::kUnanalyzedLangs, lang, &nonlocal::UnanalyzedLang::lang ) != nonlocal::kUnanalyzedLangs.end(); if( isCodeLang( lang ) ? isAnalyzed == isUnanalyzed : ( isAnalyzed || isUnanalyzed ) ) { return index; } } return kLangCount; } // every code language is spellable in a rule's `language:`, carried by the lint catalog and reachable by langOfPath; // a data or document format is in none of the three constexpr std::size_t firstLangLintCannotName() noexcept { for( std::size_t index = 0; index < kLangCount; ++index ) { const Lang lang = Lang( index ); const bool hasToken = std::ranges::find( kLangTokenRows, lang, &LangTokenRow::lang ) != std::end( kLangTokenRows ); const bool isCatalog = std::ranges::find( lintcatalog::kCatalogLangs, lang ) != lintcatalog::kCatalogLangs.end(); const bool hasExt = std::ranges::find( kLintExtRows, lang, &LintExtRow::lang ) != std::end( kLintExtRows ); if( isCodeLang( lang ) ? !( hasToken && isCatalog && hasExt ) : ( hasToken || isCatalog || hasExt ) ) { return index; } } return kLangCount; } } // namespace rw::langreg static_assert( rw::langreg::firstLangNonlocalMisclassifies() == rw::kLangCount, "a code Lang is in neither (or both) of nonlocal::kAnalyzedLangs / kUnanalyzedLangs, or a data Lang is in one — " "--nonlocal-state's unanalyzed_langs= disclosure would be silent about it" ); static_assert( rw::langreg::firstLangLintCannotName() == rw::kLangCount, "a code Lang is missing from lintrules.h kLangTokenRows, lintcatalog::kCatalogLangs or lintrules.h kLintExtRows " "(or a data Lang is in one)" ); static_assert( std::string_view( rw::langTag( rw::Lang( rw::kLangCount ) ) ) == "?", "an enumerator was appended after the one kLangCount names — move kLangCount to the new last enumerator" ); // L1: defined with the compact-legend layer below; the --token-budget gate prices a compact-posture map with it. static std::string_view compactLegendHint( const rw::Config& c, std::string_view doc ); namespace { std::optional runEvalViews( const MainDispatch& d ) { using namespace rw; const Config& cfg = d.cfg; const IngestResult& ing = d.ing; const Graph& g = d.g; const std::string& root = d.root; // --eval: deterministic self-eval (co-change recovery vs a BM25 baseline) — does the structural // ranking surface the rest of a change's files better than lexical? Needs a git repo with changes. if( cfg.eval ) { std::vector changed( ing.files.size(), 0 ); gitChangedFiles( root, ing, changed ); // current diff = the n=1 fallback; history is primary return runEval( root, ing, g, changed ); } // --eval-retrieval: KNOWN-ITEM retrieval eval — validates query-TIME ranker choice (name-exact / routing / // anchoring) that the seed-based --eval structurally cannot measure. No git history needed (the gold item // is in the corpus by construction), so it runs on any parsed tree. Deterministic. if( cfg.evalRetrieval ) { return runEvalRetrieval( ing, g ); } // --eval-mined=FILE: session-trace-mined retrieval eval — consumes a // bench/mine_traces.py minedpair.jsonl artifact, file-level gold, reuses recallAtK/rankFiles. if( !cfg.evalMined.empty() ) { return runEvalMined( root, ing, g, std::string( cfg.evalMined ) ); } // --eval-skills=FILE: labelled skill-ROUTING eval — ROOT is the skills // directory; FILE is the prompt→permitted-skill(s) corpus. Runs the shipping --for computation over // the skills ingest as one arm, so it dispatches here where ing+g are already built. Deterministic. if( !cfg.evalSkills.empty() ) { return runEvalSkills( root, ing, g, std::string( cfg.evalSkills ) ); } return std::nullopt; } // ── H1 (capture-audit 2026-09-04) — A NOTE TARGET IS A SELECTOR ─────────────────────────────────────── // // THE DEFECT. `--note-add="lessByScoreDescId: …"` — the bare-name spelling the documented agent protocol // teaches, and 4 of 4 real --note-add calls in the routing log — stored the target VERBATIM, while the // surfacing index keys on the canonical id (serialize.h::symbolNoteTarget = canonicalIdRelTo). So the row // landed `dangling="1"` and rode on nothing: --for and --expand of that very symbol emitted no // child. The write returned 0 and said nothing. A write-side memory that silently stores dead entries is // worse than no memory, because the agent believes it recorded the gotcha. // // THE FIX. The write side resolves through resolveAllByNameQualified — the SAME resolver --expand / // --callers / --uses / --impact / --edit-check resolve with, so every spelling those verbs accept (bare // name, file:name, Scope::name, canonical id, @FILE:LINE) keys the identical note. Unique ⇒ store the // canonical id and SAY SO (a silent rewrite of a caller's input is the other half of the same dishonesty); // N>1 ⇒ refuse naming every candidate, the posture --slice/--edit-check/the edit verbs already take over // this resolver ("an ambiguous selector is refused, never silently narrowed"); zero ⇒ a PATH-shaped target // still writes (a note on a file that does not exist yet is legal) with a loud dangling warning when it // matches no indexed file, and a NAME-shaped one is refused with the read verbs' own did-you-mean. // // Lifted out of runNotes rather than inlined into it: the handler was already at the verbosity bar, and // this is one decision with one output, which is what a function is. Gated by test/notecanoncheck.sh. struct NoteTargetResolution { std::string target; // what to STORE — the canonical id when the selector resolved bool refused = false; // the refusal is already on stderr; the caller exits 1 }; // The UNIQUE-definition arm of resolveNoteAddTarget: store the canonical id and SAY SO when it differs from what was // typed. Lifted out when H1's residue line joined it, because the handler already sat at the verbosity bar. // // H1 — the decl→def residue, on the one surface this verb has: its stderr. `unprovenDefs` is what the resolver dropped // for a file:name target — same-named definitions it could not tie to the file it named — and `consequence` says what // that means for THIS outcome: a note stored on the declaration, or a refusal that stored nothing. One line for both, so // the count and the remedy cannot drift apart between them; nothing is printed at zero. void emitNoteAddUnprovenDefs( std::size_t unprovenDefs, const char* consequence ) { if( unprovenDefs == 0 ) { return; } rw::emitTo( stderr, "ripwire: --note-add: unproven_defs={} — the target also matched {} same-named definition(s) it could not tie to " "the file it named; {}. Widen the target to the bare NAME, or to Scope::name, to see them\n", unprovenDefs, unprovenDefs, consequence ); } // The one definition left is the declaration: the note keys it, and it will not surface on the dropped definitions. NoteTargetResolution noteTargetForDefinition( const MainDispatch& d, rw::NodeId def, const std::string& rawTarget, const std::string& normalized, std::size_t unprovenDefs ) { using namespace rw; const IngestResult& ing = d.ing; const Symbol& s = ing.symbols[ def ]; // EXACTLY the key --notes' liveness set and serialize.h's surfacing lookup build (canonicalIdRelTo), // spelled from the same three fields, so "stored" and "found" can never be two different strings. const std::string canon = canonicalId( relForHash( ing.files[ s.fileId ], d.root ), s.scope, s.name ); if( canon != normalized ) { rw::emitTo( stderr, "ripwire: --note-add: target '{}' canonicalised to '{}' — that is the id --for/--expand key notes by\n", rawTarget.c_str(), canon.c_str() ); } emitNoteAddUnprovenDefs( unprovenDefs, "the note keys the declaration above and will not surface on them" ); return { canon, false }; } NoteTargetResolution resolveNoteAddTarget( const MainDispatch& d, const std::string& rawTarget, std::string normalized ) { using namespace rw; const IngestResult& ing = d.ing; std::size_t naUnprovenDefs = 0; // H1: the residue both outcomes below disclose const std::vector defs = resolveAllByNameQualified( ing, rawTarget, &naUnprovenDefs ); if( defs.size() == 1 ) { return noteTargetForDefinition( d, defs[0], rawTarget, normalized, naUnprovenDefs ); } if( defs.size() > 1 ) { // H1 on the REFUSAL: the candidates it lists are the definitions the resolver could PROVE, so a caller who retypes // one of them never learns the dropped ones exist. Said first, and worded for a path that stores nothing // (decltodefcheck E2y2). emitNoteAddUnprovenDefs( naUnprovenDefs, "they are not among the definitions listed below, and no note was stored" ); const std::vector groups = editCheckGroups( ing, d.g, defs ); std::string msg = "ripwire: --note-add: target '" + rawTarget + "' is ambiguous — it matches " + std::to_string( defs.size() ) + " definitions in " + std::to_string( groups.size() ) + " distinct contracts, and a note keys ONE canonical id (it would surface on one of them and " "look absent on the rest). Qualify one: "; const std::size_t shownCount = std::min( groups.size(), rw::kEditCheckSpellingsShown ); for( std::size_t groupIndex = 0; groupIndex < shownCount; ++groupIndex ) { msg += ( groupIndex ? ", " : "" ) + groups[ groupIndex ].spelling; } if( groups.size() > shownCount ) { msg += " (+" + std::to_string( groups.size() - shownCount ) + " more contracts)"; } msg += " — e.g. --note-add=\"" + groups[0].spelling + ": \""; rw::emitTo( stderr, "{}\n", msg.c_str() ); return { std::string{}, true }; } // ── resolved NOTHING ──────────────────────────────────────────────────────────────────────────────── const bool onDisk = [ & ] { rw::os::stat_t st{}; const std::string abs = normalized.empty() || normalized.front() == '/' ? normalized : d.root + "/" + normalized; return rw::os::stat( abs.c_str(), &st ) == 0; }(); if( !notes::noteTargetIsPathShaped( normalized ) && !onDisk ) { rw::emitTo( stderr, "{}\n", ( selectorNotFoundMessage( ing, "ripwire: --note-add: target not found: ", rawTarget, "--note-add=" ) + " — a note keys the canonical id a read verb resolves; to note a FILE instead, pass a path " "(one with a '/' or an extension), which may name a file that does not exist yet" ).c_str() ); return { std::string{}, true }; } const bool indexedFile = std::any_of( ing.files.begin(), ing.files.end(), [ & ]( const std::string& p ) { return relForHash( p, d.root ) == normalized; } ); if( !indexedFile ) { // LOUD, but not a refusal: a forward-looking note on a file about to be created is the case this // path exists for. The honesty is in saying, at write time, exactly what --notes will report later. rw::emitTo( stderr, "ripwire: --note-add: WARNING: target '{}' matches no indexed file or symbol — the note is stored DANGLING\n" " (--notes lists it, nothing surfaces it) until that path is indexed; run --notes to prune it if it was a typo\n", normalized.c_str() ); } return { std::move( normalized ), false }; } // L3 — repo field notes: the WRITE side (surfacing at retrieval is wired into // packSignatures/packBodies above). Two verbs share this handler: // --note-add="TARGET: text" — append a note to the committed, sorted root/.ripwire_notes and print the exact // written line. The date is git's committer clock (HEAD, %cs), NOT wall time, so the line is a pure // function of (commit state, target, text) — deterministic and stable across machines; a non-git root // degrades to the fixed epoch 1970-01-01 + a DISCLOSE (nothing to date against). MUTATES one // file and touches nothing else; the multi-root refusal lives with its siblings earlier in main(). // --notes — list every note grouped by target; a target matching no indexed symbol canonical-id / file path // is flagged dangling="1" (legal — surfaced nowhere, listed here so the human can prune it). Read-only. std::optional runNotes( const MainDispatch& d ) { using namespace rw; const Config& cfg = d.cfg; const IngestResult& ing = d.ing; // ── --note-add: split "TARGET: text" on the FIRST ": " (a canonical id's "::" pairs carry no space, so // they never false-match), sanitize both fields to the single-line/tab-free format invariant, then // append+re-sort+write and print the written line verbatim. ──────────────────────────────────────── if( cfg.noteAddFlag ) { const std::string spec( cfg.noteAdd ); const std::size_t sep = spec.find( ": " ); if( sep == std::string::npos ) { rw::emitTo( stderr, "ripwire: --note-add: want \"TARGET: text\" (a canonical id path::scope::name or a file path, then ': ', then the note) — got '{}'\n", spec.c_str() ); return 1; } // §S3 — sanitizeNoteField sanitizes AND decides (notes.h's header has the full finding): the old // `sanitizeField(...).empty()` pair refused an ASCII-blank note and accepted six other blank classes, // committing an invisible line into `.ripwire_notes`. `hasContent` is rw::hasVisibleContent, the same // derived predicate the MCP edit verbs' payload check reads. const notes::NoteField targetField = notes::sanitizeNoteField( std::string_view( spec ).substr( 0, sep ) ); const notes::NoteField textField = notes::sanitizeNoteField( std::string_view( spec ).substr( sep + 2 ) ); const std::string& rawTarget = targetField.text; const std::string& text = textField.text; if( !targetField.hasContent || !textField.hasContent ) { // §S3 — the refusal SPELLS an invisible value instead of echoing it, for blankPayloadSpelling's // own reason: echoing the bytes pastes a C1 control or a bidi override into the caller's terminal, // and `U+200E` is something they can grep for while the character itself is not. An EMPTY field // has nothing to spell, so it keeps the original sentence byte-for-byte — a caller who typed // `--note-add="alpha: "` sees exactly what they saw before. // blankPayloadSpelling's contract is "call me on a payload you have ALREADY ruled all-blank" — // on any other value it spells every code point in it, which would render a perfectly good // target as `U+0061 U+006C …`. So the three cases are separated here rather than inferred from // an empty spelling: a field that HAS content and the field that is genuinely absent both keep // the original quoted echo, and only the present-but-invisible one is spelled. const auto describeField = []( const notes::NoteField& field ) -> std::string { if( field.hasContent || field.text.empty() ) { return "'" + field.text + "'"; } const auto [ blankCodePointCount, blankSpelling ] = rw::blankPayloadSpelling( field.text ); return std::to_string( blankCodePointCount ) + ( blankCodePointCount == 1 ? " invisible code point: " : " invisible code points: " ) + blankSpelling; }; rw::emitTo( stderr, "ripwire: --note-add: both a target and a note text are required (got target={} text={})\n", describeField( targetField ).c_str(), describeField( textField ).c_str() ); return 1; } // R6: a gentle stderr NUDGE (never a refusal — the add proceeds either way) when the text carries // no causal/decision marker. Decision-shaped notes retrieve better than plain prose; this is advice // on the text itself, so it prints regardless of what the target-normalization step below decides. // stderr only — never stdout, so it can never contaminate --note-add's printed line or a later // --for/--expand/default-map XML emission (notescheck.sh's det-gate covers this). if( !notes::isDecisionShaped( text ) ) { rw::emitRaw( stderr, "ripwire: --note-add: tip: notes that say \"chose X over Y because Z\" surface better — consider adding the why\n" ); } // D5: canonicalize the target's path component to ROOT-RELATIVE before it ever touches disk — the // portable-notes contract (.ripwire_notes is committed and must resolve on any other checkout). An // absolute target outside this root can never be reached from another checkout's crawl, so refuse // loudly instead of silently writing a note that surfaces nowhere, ever. bool outsideRoot = false; std::string normalizedTarget = notes::normalizeNoteTarget( rawTarget, d.root, outsideRoot ); if( outsideRoot ) { rw::emitTo( stderr, "ripwire: --note-add: target '{}' resolves outside the root '{}' — refusing (notes must stay root-relative/portable)\n", rawTarget.c_str(), d.root.c_str() ); return 1; } // H1: the target is a SELECTOR — resolve it the way every read verb does before anything is stored. const NoteTargetResolution resolved = resolveNoteAddTarget( d, rawTarget, std::move( normalizedTarget ) ); if( resolved.refused ) { return 1; } const std::string& target = resolved.target; std::string date = rw::quality::gitCommitterDateIso( d.root ); if( date.empty() ) { // 2026-09-06 stranger audit: this used to store 1970-01-01 — an epoch nobody explained, read as a // real date by every consumer. "undated" is the honest value: there is no committer date to anchor to. DISCLOSE( "notes: non-git root — the note is stored undated" ); date = "undated"; rw::emitTo( stderr, "ripwire: --note-add: {} is not a git checkout — the note is stored undated (d=\"undated\"; a git checkout stamps the committer date)\n", d.root.c_str() ); } // provenance stamp (the day's costliest lesson): anchor the note to the commit it was written under. // gitHeadSha resolves empty exactly when date's own gitCommitterDateIso lookup would have (same // non-git-root / no-HEAD condition, already alerted above) — no second DISCLOSE needed. // "no sha shown rather than a wrong one": an empty sha here means addNote writes the plain LEGACY // 3-field line, never a hollow or guessed stamp. const std::string sha = rw::quality::gitHeadSha( d.root ); const std::string branch = sha.empty() ? std::string() : notes::sanitizeField( rw::quality::gitOneLine( d.root, "rev-parse --abbrev-ref HEAD 2>/dev/null" ) ); const std::string path = notes::notesPath( d.root ); notes::NotesReadStats noteStats; const std::string line = notes::addNote( path, noteStats, target, date, text, sha, branch ); if( noteStats.linesSkipped != 0 ) { rw::emitTo( stderr, "ripwire: --note-add: {} holds {} line(s) that are not \\t\\t — refusing to rewrite it, " "which would delete them (fix or remove those lines first; --notes counts them as lines_skipped=)\n", path.c_str(), noteStats.linesSkipped ); return 1; } if( line.empty() ) { rw::emitTo( stderr, "ripwire: --note-add: could not write {}\n", path.c_str() ); return 1; } std::fwrite( line.data(), 1, line.size(), stdout ); std::fputc( '\n', stdout ); return 0; } // ── --notes: list grouped by target, dangling targets flagged. ───────────────────────────────────────── if( cfg.notesList ) { // D5: read + normalize every stored target to ROOT-RELATIVE (readNotesRelative) — a legacy absolute // entry from before this fix keeps matching correctly instead of always reading dangling="1". notes::NotesReadStats noteStats; std::vector all = notes::readNotesRelative( notes::notesPath( d.root ), d.root, noteStats ); notes::sortNotes( all ); // the set of LIVE targets in the indexed tree: every symbol's canonical id + every file path, BOTH // relativized against d.root (relForHash) to match the root-relative `all` targets above. HashMap live; live.reserve( ing.symbols.size() + ing.files.size() ); for( NodeId i = 0; i < ing.symbols.size(); ++i ) { const Symbol& s = ing.symbols[i]; if( s.fileId < ing.files.size() ) { live[canonicalId( relForHash( ing.files[s.fileId], d.root ), s.scope, s.name )] = 1; } } for( const std::string& fp : ing.files ) { live[std::string( relForHash( fp, d.root ) )] = 1; } std::size_t targetCount = 0, danglingCount = 0; for( std::size_t i = 0; i < all.size(); ) { std::size_t j = i; while( j < all.size() && all[j].target == all[i].target ) { ++j; } ++targetCount; if( live.find( all[i].target ) == live.end() ) { ++danglingCount; } i = j; } { XmlWriter w( stdout ); std::vector esc; // The note-row attribute definitions live HERE because this header is the --notes reader's ONLY // legend (legendcoveragecheck arm A): d= ISO date, sha=/branch= the provenance stamp, whose // omitted-not-empty contract is appendOneNote's (serialize.h). NB: no `--` inside an XML comment. char hdr[ 512 ]; rw::formatTo( hdr, sizeof( hdr ), "", all.size(), targetCount, danglingCount ); w.write( hdr ); // The read's own shortfall, on itself and ONLY when there is one (a clean sidecar's bytes are // unchanged), each carrying its definition in the same write so the attribute is never undefined where met. if( noteStats.symlinkRefused ) { w.write( "" "" ); } else if( noteStats.linesSkipped != 0 ) { w.write( "" ); w.write( "" ); } else { w.write( "" ); } for( std::size_t i = 0; i < all.size(); ) { std::size_t j = i; while( j < all.size() && all[j].target == all[i].target ) { ++j; } const bool dangling = ( live.find( all[i].target ) == live.end() ); w.write( "" : "\" dangling=\"0\">" ); for( std::size_t k = i; k < j; ++k ) { appendOneNote( w, all[k], esc ); } w.write( "" ); i = j; } w.write( "" ); } std::fputc( '\n', stdout ); return 0; } return std::nullopt; } // §P6.8: when --token-budget is set, the map body must be MEASURED before any byte of it reaches the real // stdout — the whole reason a caller sets a budget is to keep an oversized artifact out of a CI log, and // streaming it anyway (even behind a non-zero exit) defeats that; a CI log then holds the exact artifact // the gate just rejected. Mirrors --recall's own fix for the identical class (src/recall.h's // emitRecallBudgeted: "measure, decide, then write — the order lives here, beside buildRecall, so no // caller can re-order it") via the SAME open_memstream technique --max-tokens' own binary search uses. // Lifted out of runDefaultMap (same reason as lintSymbolLevelChecks/dedupeLintFindings above it) so that // function stays under the complexity/verbosity bar. // // Open the buffer into `stream` (a MemoryStream the caller owns for the whole map, so an early return still closes // and frees it) and return what the caller should write the map body to: the buffer when budgeting, else `real`. A // memstream-open failure degrades to `real` directly (DISCLOSE) rather than losing the map — the budget is // still ASSERTED afterward by finishTokenBudgetGate, it just can no longer WITHHOLD an over-budget map on that one run // (the stream never opened, so finishTokenBudgetGate's write-or-withhold branch is a no-op and the content — already // streamed straight to `real` — is left exactly where it is). // // The buffer opens through rw::openChargeStream, the tree's one charge-buffer seam, so the fault switch that reaches every // other measuring buffer reaches this one too (it was the one that bypassed it). // // TokenBudgetStream is the DISCLOSE sink for both degrades. An unopened buffer means an over-budget map has ALREADY been // streamed, so finishTokenBudgetGate must not say it withheld it (it used to print withheld_est_tokens= on stderr beside // the very map it claimed to withhold); a buffer that lost a write means the map is withheld and the run exits 1. struct TokenBudgetStream { enum class DisclosureWhy : std::uint8_t { OpenFailed, // the map streams straight to `real`: the budget is still asserted, but nothing can be withheld LostWrite, // the buffered map is not whole: withheld, never printed short, exit 1 }; bool isUnbuffered = false; bool isLost = false; void disclose( DisclosureWhy why ) noexcept { switch( why ) { case DisclosureWhy::OpenFailed: isUnbuffered = true; break; case DisclosureWhy::LostWrite: isLost = true; break; } } }; inline std::FILE* openTokenBudgetBuffer( rw::MemoryStream& stream, TokenBudgetStream& sink, std::size_t tokenBudget, std::FILE* real ) { if( tokenBudget == 0 ) { return real; } if( std::FILE* const buffer = rw::openChargeStream( stream ) ) { return buffer; } DISCLOSE( sink, TokenBudgetStream::DisclosureWhy::OpenFailed, "openTokenBudgetBuffer: open_memstream failed — falling back to direct stdout" ); return real; } // Finish the buffer, decide against the budget, and either write the buffered body to `real` (under budget // — byte-identical to the unflagged run, measuring never shapes) or withhold it and print a small refusal // record instead (shaped as XML or JSON to match what the caller asked for), naming actual vs budget on // stderr. Returns 3 when over budget (the caller must return it immediately — nothing may write to `real` // after that), 1 when the buffer did not finish whole, std::nullopt otherwise (caller continues normally). // // THE BUFFER IS THE ANSWER HERE. Every other memstream in the tree measures a document it can still write another // way; this one holds the map itself, rendered once, and nothing can render it again. So a buffer that did not finish // whole (rw::MemoryStream::finish: a write lost inside it, or the close failed) is not printed short. The run says so // on stderr in every build and exits 1, the exit code main's own A4-F18 check gives a short write to stdout. // L1: the PRICE a compact-posture run will actually print for this body. The budget gate decides BEFORE the compact // layer (runWithCompactLegend) rewrites stdout, so without this it withheld a map on its FULL-dialect price — a map the // caller would have received inside the budget, lost to prose it was never going to be sent. The body is compacted // here the same way the layer will compact it and repriced by the same rule (compactlegend.h compactRepricedTokens), // so the number decided on is the number the root prints. A body the dialect cannot shape keeps its own price — an // empty one (the map streamed unbuffered) and a --json one (no posture) included. // map-inventory-d7: the number decided on is READ from the compacted body — the root's est_tokens=, or the map header's // field when --stable keeps the root free of it — never recomputed from compacted.size(). That recount priced the bytes // AFTER the layer had rewritten the est digits themselves (a 5-digit full price repriced to 4 digits is 2 bytes shorter // in the root and the header), so on that boundary the gate said one token less than the header it was asserting // (tokenbudgetcheck #9: src --top-k=233 on 7608d1f6, header 9965, gate 9964). The recount stays only as the fallback for // a compacted body that prints no price at all. static std::size_t compactPostureMapPrice( const rw::Config& cfg, std::string_view body, std::size_t fullEstTokens ) { if( cfg.legend != "compact" || fullEstTokens == 0 ) { return fullEstTokens; } std::string compacted( body ); if( rw::applyCompactDialect( compacted, compactLegendHint( cfg, compacted ) ) != rw::CompactOutcome::Rewritten ) { return fullEstTokens; } const std::string_view doc = compacted; const rw::CompactRootInfo root = rw::findCompactRoot( doc ); if( const std::size_t printed = root.tag.empty() ? 0 : rw::rootUnsignedAttr( doc.substr( root.openBegin, root.openEnd - root.openBegin ), "est_tokens" ); printed > 0 ) { return printed; } const std::string_view header = rw::compactMapHeader( doc ); if( const std::size_t at = header.find( " est_tokens=" ); at != std::string_view::npos ) { std::size_t v = 0; bool any = false; for( std::size_t i = at + 12; i < header.size() && header[ i ] >= '0' && header[ i ] <= '9'; ++i ) { v = v * 10 + std::size_t( header[ i ] - '0' ); any = true; } if( any && v > 0 ) { return v; } } // no printed price to read (a body whose root and header carry none): the recount is the only number there is return static_cast( rw::compactRepricedTokens( static_cast( fullEstTokens ), body.size(), compacted.size() ) ); } inline std::optional finishTokenBudgetGate( rw::MemoryStream& stream, TokenBudgetStream& sink, std::FILE* real, std::size_t fullEstTokens, std::size_t tokenBudget, const rw::Config& cfg ) { const bool asJson = cfg.json; const bool isBuffered = stream.isOpen(); const rw::MemoryStreamBytes body = isBuffered ? stream.finish() : rw::MemoryStreamBytes{}; if( isBuffered && !body.isWhole ) { DISCLOSE( sink, TokenBudgetStream::DisclosureWhy::LostWrite, "finishTokenBudgetGate: the --token-budget buffer did not finish whole — map withheld, exit 1" ); } if( sink.isLost ) { rw::emitRaw( stderr, "ripwire: write error — the --token-budget buffer lost bytes; the map is withheld, not printed short\n" ); return 1; } const std::size_t mapEstTokens = compactPostureMapPrice( cfg, body.bytes, fullEstTokens ); if( tokenBudget > 0 && mapEstTokens > tokenBudget && sink.isUnbuffered ) { // The map above went straight to stdout: it carries its own est_tokens=, and it was NOT withheld. Say that, and // exit 3 as any over-budget run does — never the withheld_ spelling, which names a map the caller did not get. rw::emitTo( stderr, "ripwire: --token-budget exceeded: est_tokens={} > budget={} — the map above was NOT withheld: its measuring buffer " "could not open, so it streamed directly\n", mapEstTokens, tokenBudget ); return 3; } if( tokenBudget > 0 && mapEstTokens > tokenBudget ) { // §B7.8 — withheld_est_tokens=, not est_tokens=. `est_tokens` is normatively about what THIS RUN // PRINTED (pageview.h's truncation vocabulary, rule 1), and on this record the run printed the record // itself and nothing else: the number describes the artifact the caller did NOT receive. --recall // renamed the identical semantic for exactly that reason (recall.h's emitRecallBudgeted, N3) and this // sibling — inside the same round's own fix — kept the old spelling. Same vocabulary now, all three // channels (XML record, JSON record, stderr), so a script can key on one name. rw::emitTo( stderr, "ripwire: --token-budget exceeded: withheld_est_tokens={} > budget={}\n", mapEstTokens, tokenBudget ); if( isBuffered ) { if( asJson ) { rw::emitTo( real, "{{\"withheld_est_tokens\":{},\"budget\":{},\"withheld\":true}}", mapEstTokens, tokenBudget ); } else { rw::emitTo( real, "", mapEstTokens, tokenBudget ); } } return 3; } if( isBuffered ) { std::fwrite( body.bytes.data(), 1, body.bytes.size(), real ); } return std::nullopt; } // §A9.6 — --rank-by=churn's teleport AND the window label the map has to disclose, as ONE answer. git // change-frequency as the PageRank teleport is a proven prior (bias importance toward where the action is); // no git in PATH / no history → churnTeleport returns uniform, so the whole verb degrades cleanly. // --since=REV|DATE scopes the window to recent churn instead of the fixed 18-month default; an // unresolvable value degrades to inactive → the default window, and the label says so. // // Lifted out of runDefaultMap because the ranking and its label are the same decision — which window was // mined — and returning only the ranking left the caller to re-derive the label from cfg. That split is // exactly how the churn map came to ship byte-shaped like a PageRank map for a whole release line. // Multi-root has no per-root --since resolution (the workspace teleport mines the default window; churn is // a rank prior there, not a report), so it reports the default label. // §B2.2: whether the window mined ANY commits is the THIRD part of the same decision, and it belongs here for // the same reason the label does. A window that mined nothing returns the uniform prior, so the ranking IS the // structural one (ranks byte-identical to --rank-by=pagerank, stderr empty before this) and the label alone // cannot tell the reader which of the two they are holding. So this function stamps the disclosure into the // window (churnWindowStamp, the machine surface) AND prints the human line, in the shape --map-diff's // git-unavailable degrade uses ("using uniform ranking") — one place owns "which window was mined, what to // call it, and saying so when it was empty". Multi-root emits no stamp at all (mapAnn is single-root), which // makes stderr its ONLY disclosure — another reason for the note to live on this side of the return. // W2-F: `pr` rides along because churn ranking IS a PageRank run — a teleport variant, not a separate // method — so the map it produces owes the same pr_iters= / pr_converged= disclosure a uniform one does. struct ChurnRanking { std::vector rank; std::string window; rw::RankDisclosure pr; std::vector recent; // F3: churn-decay, single-root only — the map's rows std::size_t recentOf = 0; // files any mined commit touched (the of= the rows were cut from) std::uint32_t mergeBombsSkipped = 0; // commits the >kChurnMergeBombMaxFiles rule skipped in the window () // WAS HISTORY MINED — the fact, carried, not re-derived (CodeRabbit, review 5195637558). It used to be read // back off the output (`recent.empty() && mergeBombsSkipped == 0` ⇒ no block), and a window whose commits // touch no INDEXED file — the commit that deletes a file is the smallest such window — has zero rows AND a // mined history, so it was reported as "no history was mined". Different answers must not share a document. // Only the decay pass sets it: it is the pass that builds , and a plain --rank-by=churn run has no // block for the fact to be about (src/prcontext.h E1: gate on the count, never on a match over the body). bool recentAnyHistory = false; // C1-b (2026-09-12): --in=DIR — ONE page of DIR's rows from the SAME mining pass (hasScoped ⇒ the block is emitted, even // empty); scopedOf = DIR's files any counted commit touched (its of=); scopedOffset = the row the page started at. std::vector scoped; std::size_t scopedOf = 0; std::size_t scopedOffset = 0; bool hasScoped = false; }; inline constexpr std::size_t kRecentRows = 40; // F3: ~45 B a row; the file-level answer, not the file list // P0-4: the DEFAULT window label of each churn ranker, which is also the difference between them that a // reader has to see. Plain churn mines a bounded 18-month wall-clock window; churn-decay mines the whole // history and lets the half-life do the windowing, so its label says so AND names the half-life — the // constant is a choice, and a choice that is not in the output is not disclosed. inline std::string churnDecayWindowLabel( std::string_view minedSpan ) { std::string label{ minedSpan }; label += " half-life="; label += std::to_string( int( rw::kChurnDecayHalfLifeDays ) ); label += "d"; return label; } // The crawl-scope flags a pasted continuation must replay so it indexes the SAME files this run did: --exclude, // --max-file-size, --no-ignore, --ignore-tests (the enumeration below says why these four and no others). Each is // returned with its leading space; empty at the defaults. Shared by scopedMapNextInvocation and leanMapPointer. inline std::string crawlScopeFlags( const rw::Config& cfg ) { std::string inv; for( const std::string& x : cfg.excludes ) { inv += " " + rw::nextFlag( "--exclude=", x ); } // The ceiling is replayed as the BYTE COUNT it resolved to, not as the caller's "2K": parseByteSize accepts // plain digits, and the number is what shaped the crawl. Omitted at the default, so the common hint is // unchanged to the byte. if( cfg.maxFileBytes != rw::kDefaultMaxFileBytes ) { inv += " --max-file-size=" + std::to_string( cfg.maxFileBytes ); } if( cfg.noIgnore ) { inv += " --no-ignore"; } if( cfg.ignoreTests ) { inv += " --ignore-tests"; } return inv; } // C1-b (2026-09-12): --in=DIR's page of rows, from the SAME mining pass as the global block. The prefix is // matched on the ROOT-RELATIVE spelling the map prints (serialize.h pathRel: rootRelativeUri against the crawl root as // typed), so a scoped row is byte-identical to its global twin — a sub-root-relative spelling missed every held-out gold // (0/30 raw vs 19/30 prefixed). DIR's trailing slash is stripped the way the crawl root's is (sarif::rootPrefixOf), so // `db/` and `db` are one answer and one next=. The page is [--offset, --offset + max(--limit, kRecentRows)). // C1-b (Fable review on #212) — THE ONE COMPOSER behind both next= strings the scoped map carries. // // THE DEFECT. Both were hand-spelled as "--rank-by=churn-decay [--since=V]" and nothing else, so every flag // that shapes the CORPUS was dropped from the invocation the tool told the caller to paste: --exclude, // --no-ignore, --ignore-tests, --stable, --legend, --max-tokens, --token-budget. `--in=a --exclude=b` reported // total="6" and handed back a next= that yields twelve rows — a page pointer into a different corpus, which is // worse than no pointer at all. The scoped next= carries no LENGTH cap either, the same as every other next= // producer (forPageInvocation, flipimpact): nextAttrXml (nextverb.h) never truncates or drops next= on length // (2026-09-25 fix for a cut answer's next= being dropped past 120 B); a long --since plus a deep DIR plus // --limit/--offset is simply emitted in full. // // THE SHAPE. One function, two callers, one cap. `withIn` picks the scoped page (--in=DIR carried, at the next // offset) or the stub's "same run without --in". // // WHAT IT REPLAYS, and the line: the flags that decide WHICH ROWS EXIST — the CORPUS (--exclude, // --max-file-size, --no-ignore, --ignore-tests) and the WINDOW (--since, --limit/--offset) — and nothing else. // A presentation flag (--legend=compact, --max-tokens, --token-budget) changes how the same rows are rendered, // never which rows the page holds, so replaying it would spend the 120-byte budget on bytes that cannot make // the pasted page name a different answer. of= is the number this attribute is a page INTO, and of= moves only // with the corpus and the window. // // They are named explicitly because they are parseArgs' hand-written arms (--exclude= is repeatable; // --limit/--offset/--rank-by are closed-value arms) and no table row can see them; the TABLE-ROW flags cannot // appear at all, because inPreemptedBy refuses every one that is not a ride-along before this run reaches the // map. Past the cap it returns "" — a hint that pastes wrong is worse than none. // // --max-file-size WAS MISSING (CodeRabbit, review 5195637558), and one miss is the evidence that the list was // hand-picked rather than enumerated against the flags that can ride here: the SIZE CEILING drops files out of // ing.files (ingest_crawl.h, why=oversize), so a hint emitted under --max-file-size=2K named a page of a // corpus three files wide where the run that emitted it had two — MEASURED of="2" against of="3" // (recentscopecheck arm 12). So the set is now ENUMERATED, and this is the enumeration. Everything that can be // set beside --in is either a table row on kInRideAlong or a member the firstFlagOutside walk cannot see // (a vector, an int, a size_t); each one is either replayed above or listed here with the reason it is not: // --exclude / --max-file-size / --no-ignore / --ignore-tests REPLAYED: each one decides which files the // crawl indexes, and 's rows are indexed files. // --since / --limit / --offset REPLAYED: the window and the page itself. // --cache= / --no-cache NOT corpus: the blob is keyed on realpath(root) + the lean/rich class and stores // per-FILE parse records (ingest_cache.h v15); the CRAWL decides membership, and a subset run reads only // its own records. Warm and cold index the same files. // --refetch NOT replayed, and deliberately the other way round: it re-clones a git-URL root, // so replaying it could fetch a NEWER tree — omitting it is what keeps the page on the corpus this run // cloned. // --scip= NOT corpus: a precision overlay on call EDGES (prov="scip"); it adds and removes // no file, and is a file-level answer. // --max-tokens / --token-budget NOT corpus: the first shapes the ranked map (which --in has already // stubbed) and the second only asserts a ceiling; neither can move of=. // --legend / --stable / --no-stable / --order aliases / --route / --no-route / --no-post-check / --compress // / --pin-census / --json NOT corpus: presentation, ranking posture or a side file. The rows' order is // decayedRecentRowsSorted's (newest commit first) whatever these say. (--json is refused beside --in.) // --top-k / --pack-top-n / --expand / --outline / --export=cc.json / a second root cannot be here at all: // inPreemptedBy and validateModifierGuards refuse each one before the map is built. inline std::string scopedMapNextInvocation( const rw::Config& cfg, std::string_view scopeDir, bool withIn, std::size_t nextOffset ) { std::string inv = "--rank-by=churn-decay"; if( !cfg.since.empty() ) { inv += " " + rw::nextFlag( "--since=", cfg.since ); } inv += crawlScopeFlags( cfg ); if( withIn ) { inv += " " + rw::nextFlag( "--in=", scopeDir ); inv += " --offset=" + std::to_string( nextOffset ); if( cfg.pageLimit > 0 ) { inv += " --limit=" + std::to_string( cfg.pageLimit ); } } // Built and returned in full, whatever its length (2026-09-25 fix: a cut answer's next= used to be // dropped past 120 B): nextAttrXml (nextverb.h) carries no ceiling on next=, so a long --since plus a // deep DIR plus --limit/--offset is simply pasted in full rather than truncated or dropped. return inv; } inline void scopedRecentPage( const MainDispatch& d, const std::vector& sorted, ChurnRanking& cr ) { using namespace rw; const std::string rootPrefix = sarif::rootPrefixOf( d.cfg.roots[0] ); const std::string dirPrefix = sarif::rootPrefixOf( d.cfg.inDir ) + "/"; const auto underDir = [ & ]( std::uint32_t f ) { return sarif::rootRelativeUri( d.ing.files[f], rootPrefix ).starts_with( dirPrefix ); }; const std::size_t pageRows = std::size_t( effectiveRowCap( d.cfg.pageLimit, int( kRecentRows ) ) ); cr.scopedOffset = d.cfg.pageOffset > 0 ? std::size_t( d.cfg.pageOffset ) : 0; // The SAME sorted list the global block was cut from — one build, one sort, one HEAD-epoch read for both pages. cr.scoped = recentPageFromSorted( sorted, underDir, pageRows, cr.scopedOffset, &cr.scopedOf ); cr.hasScoped = true; } // C1-b (Fable review on #212) — THE SECOND HALF of validating --in=DIR, and the only half that can tell a // directory from a typo. // // inDirIsUnderRoot (below) asks the FILESYSTEM, and the filesystem answers a different question from the one // the block will be built from. On APFS `--in=DB` resolves to `db/` and is a directory; `--in=dblink` where // dblink -> db is a directory; `--exclude=tests --in=tests` is a directory the crawl was told to drop. All // three passed, and all three produced `` — the typo-reads-as-nothing-changed // answer the refusal exists to prevent, in the exact shape the flag's own comment promised it would not take. // // The crawl's root-relative spellings are the ground truth, so the check is against THEM, byte-exact: at least // one indexed file must begin `DIR/`. A case-folded name, a symlink alias and an excluded subtree all fail it // for the same honest reason — nothing under that spelling is in the corpus this answer is about — and the // message says which three causes to look at, because they are the three that make a real directory miss. inline bool inDirMatchesCrawl( const rw::Config& cfg, const rw::IngestResult& ing, const std::string& rootArg ) { using namespace rw; const std::string rootPrefix = sarif::rootPrefixOf( rootArg ); const std::string dirPrefix = sarif::rootPrefixOf( cfg.inDir ) + "/"; for( const std::string& f : ing.files ) { if( sarif::rootRelativeUri( f, rootPrefix ).starts_with( dirPrefix ) ) { return true; } } rw::emitTo( stderr, "ripwire: --in={}: no indexed file is under \"{}\" — the directory exists but this crawl holds nothing spelled that way, " "so a scoped block would say \"nothing changed\" for a directory it never read. Name it as the map spells it " "(case-exact, the real path and not a symlink to it, and not a subtree --exclude dropped); " "ripwire --rank-by=churn-decay lists the spellings under p=\n", std::string_view( cfg.inDir.data(), cfg.inDir.size() ), std::string_view( dirPrefix.data(), dirPrefix.size() - 1 ) ); return false; } // The uniform-ranking fallback disclosure, shared by all three arms below: a churn map whose window mined // NOTHING is byte-identical to --rank-by=pagerank, and this stderr line is the only place that fact appears. // A free function rather than the capturing lambda it was, because the decay arm is its own function now // (churnDecayRanking) and a lambda cannot be shared across the two without being handed to it. // THE SCOPED RUN RANKS NOTHING, so it cannot have fallen back to a ranking (CodeRabbit, review of #212). The // no-evidence notice said three things that are false under --in=DIR, on a real path // (`--rank-by=churn-decay --in=src --since=HEAD`, a window that reads no commit): "using uniform (structural) // ranking" names a computation that did not run — the stub default-constructs the rank vector and zero-fills // it, which is why no pr_iters= rides the header; "this map" names a document the run does not contain, since // the map IS the counted stub and the blocks are both absent; and the comparison it offers, // --rank-by=pagerank, is REFUSED beside --in, so the reader was pointed at a command this tool rejects. // One wrong sentence, three ways, in shipped output — the disclosure rule's own subject. // // The scoped branch states what actually happened and keeps the pagerank equivalence where it IS true: on the // unscoped run the reader gets by dropping --in. inline void discloseUniformChurnFallback( bool hasChurnEvidence, bool stubbed, const char* verbLabel, const std::string& windowStamp ) { if( hasChurnEvidence ) { return; } if( stubbed ) { rw::emitTo( stderr, "ripwire: {} found no commits in its window, so NEITHER block rides this run and the symbol map is the " "counted stub — nothing was ranked at all, so there is no ranking to have fallen back (header: window=\"{}\"). " "Widen the window (--since), or drop --in=DIR for the map, which is then byte-identical to --rank-by=pagerank\n", verbLabel, windowStamp.c_str() ); return; } rw::emitTo( stderr, "ripwire: {} found no commits in its window; using uniform (structural) ranking — this map is " "byte-identical to --rank-by=pagerank (header: window=\"{}\")\n", verbLabel, windowStamp.c_str() ); } // --rank-by=churn-decay's ARM, lifted whole out of churnRankedGraph — which is a three-way dispatcher that was // carrying this entire body inline. C1-b's stub branch pushed that function from ccx 18 to 24 against a bar of // 15, and the answer to a dispatcher growing a fourth concept is a name for the concept, not an ack: the decay // arm mines once and spends that one pass three ways (the teleport prior, the global page, and under // --in=DIR the scoped page), which is a nameable job and now has the name. inline ChurnRanking churnDecayRanking( const MainDispatch& d, const rw::SinceScope& sinceScope, bool isScoped, const char* verbLabel ) { using namespace rw; // F3: ONE mining pass feeds both the teleport prior (churnPriorFromDecayed, exactly what churnDecayTeleport // builds) and the map's file-level rows — so the file-level answer costs no second git walk. const std::string windowArgs = isScoped ? sinceLogArgs( sinceScope, "" ) : std::string{}; const DecayedChurnMined mined = gitLogDecayedFileMining( d.root, d.ing, windowArgs, kChurnMergeBombMaxFiles ); // same merge-bomb cap as churnTeleport // C1-b efficiency: under --in=DIR the symbol map is a STUB — not one ranked row is printed — so the // power iteration that produces those rows' k= is work whose entire output is discarded. It is SKIPPED, // and the header then carries no pr_iters=/pr_converged= because no iteration ran: an honest absence, // not a number for a computation that did not happen (prconverge.h isPageRank=false). The rank vector is // still SIZED (zero-filled) so every index serialize takes stays in range on a stubbed document. const bool stubbed = !d.cfg.inDir.empty(); rw::RankedGraph ranked = stubbed ? rw::RankedGraph{} : rankGraphTeleport( d.g, churnPriorFromDecayed( d.ing, mined.weights, mined.anyHistory ) ); if( stubbed ) { ranked.rank.assign( d.ing.symbols.size(), 0.0f ); } // 0.6.6 command sweep: "all-history" over a depth-1 clone's single commit was false — the decay mined only what // was fetched. A shallow clone's unscoped span is named for what it is, and the stamp says why. const bool shallow = gitstamp::isShallow( d.root ); std::string window = churnWindowStamp( churnDecayWindowLabel( isScoped ? std::string_view( d.cfg.since ) : std::string_view( shallow ? "fetched-history" : "all-history" ) ), mined.anyHistory, shallow ); discloseUniformChurnFallback( mined.anyHistory, stubbed, verbLabel, window ); ChurnRanking cr{ std::move( ranked.rank ), std::move( window ), { ranked.iterationCount, ranked.hasConverged, !stubbed } }; // ONE build + ONE sort of the decayed rows, shared by both blocks (gitmine.h decayedRecentRowsSorted). const std::vector sorted = decayedRecentRowsSorted( d.root, d.ing, mined ); cr.recent = recentPageFromSorted( sorted, []( std::uint32_t ) { return true; }, kRecentRows, 0, &cr.recentOf ); cr.mergeBombsSkipped = mined.mergeBombsSkipped; cr.recentAnyHistory = mined.anyHistory; // the fact this pass learned, handed on rather than left to be guessed if( stubbed ) { scopedRecentPage( d, sorted, cr ); // C1-b: --in=DIR's page, from the same pass and the same sort } return cr; } inline ChurnRanking churnRankedGraph( const MainDispatch& d ) { using namespace rw; const bool isDecay = ( d.cfg.rankBy == RankBy::ChurnDecay ); const char* const verbLabel = isDecay ? "--rank-by=churn-decay" : "--rank-by=churn"; bool hasChurnEvidence = false; if( d.multiRoot ) { std::vector rootDirs; for( const WorkspaceRoot& r : d.ws ) { rootDirs.push_back( r.arg ); } rw::RankedGraph ranked = isDecay ? rankGraphTeleport( d.g, churnDecayTeleportWorkspace( rootDirs, d.ing, &hasChurnEvidence ) ) : rankGraphTeleport( d.g, churnTeleportWorkspace( rootDirs, d.ing, "18 months ago", &hasChurnEvidence ) ); std::string window = churnWindowStamp( isDecay ? churnDecayWindowLabel( "all-history" ) : rw::defaultWindowLabel( d.root, "18mo" ), hasChurnEvidence ); // stubbed=false: the workspace (multi-root) arm cannot be scoped — --in=DIR is refused under multi-root // and outside --rank-by=churn-decay — so this arm always ranked, and the uniform wording is correct here. discloseUniformChurnFallback( hasChurnEvidence, false, verbLabel, window ); return { std::move( ranked.rank ), std::move( window ), { ranked.iterationCount, ranked.hasConverged, true } }; } const SinceScope sinceScope = resolveSinceScope( d.root, d.cfg.since ); const bool isScoped = !d.cfg.since.empty() && sinceScope.active; // the §P9 N7 rule, one verb over if( isDecay ) { return churnDecayRanking( d, sinceScope, isScoped, verbLabel ); } rw::RankedGraph ranked = rankGraphTeleport( d.g, churnTeleport( d.root, d.ing, "18 months ago", d.cfg.since.empty() ? nullptr : &sinceScope, &hasChurnEvidence ) ); // F1: the DEFAULT window's stamp names the anchor that produced it ("18mo@HEAD"); an ACTIVE --since is // the user's own value and is stamped verbatim, exactly as before. const std::string defaultWindow = rw::defaultWindowLabel( d.root, "18mo" ); std::string window = churnWindowStamp( isScoped ? std::string_view( d.cfg.since ) : std::string_view( defaultWindow ), hasChurnEvidence, gitstamp::isShallow( d.root ) ); // 0.6.6: the window mined only the fetched commits // stubbed=false: this is the undecayed --rank-by=churn arm, which --in=DIR does not ride (it is refused // outside churn-decay), so a ranking really did run and the uniform sentence is the true one. discloseUniformChurnFallback( hasChurnEvidence, false, verbLabel, window ); return { std::move( ranked.rank ), std::move( window ), { ranked.iterationCount, ranked.hasConverged, true } }; } // ── M6 (density audit 2026-08-08): --expand cheapest-complete-answer serving, the two decisions ────── // Hoisted out of runDefaultMap (already this file's largest dispatcher) because both are nameable // concepts with pure inputs; the emission stays in runDefaultMap where the streams live. // // THE SCOPE: a bare --expand and nothing else. No explicit --top-k (the agent asked for the map, or for // its absence at 0 — serve exactly that), no --outline/--pack-signatures/--pack-top-n (composed payloads // keep the classic envelope), no --query/--adaptive/--max-tokens (all three shape the MAP, so the map is // wanted), no --json (it refuses --expand anyway), no range slice (SYM:START-END asks for LESS than the // symbol — serving the whole file would invert the ask; test/expandrangecheck.sh pins that contract), // and only when the §H7 pre-render succeeded — a degraded render has no measured bytes to compare. // D2 (audit regressions, 2026-08-08): --compress and --pack-budget-bytes are deliberately NOT in this // predicate — they are body-SHAPING modifiers, and shaping COMPOSES with mode selection instead of // disabling it: renderWholeFiles compresses the whole-file candidate, chooseExpandServe holds the // whole-file candidate to the same pack budget packBodies enforces on the bundle, and the reason= // disclosure compares the shaped candidates. Gates: compresscheck / overbudgetcommentcheck. // #60: …and NOT when every expanded symbol is a module-scope owner. Such a symbol is bodyless by // construction (its Symbol extent is empty — ingest_model.h assignSymbols), so the bundle costs almost // nothing and the whole file always undercuts it. Serving the file would answer a question nobody asked: // the module scope is the statements OUTSIDE every definition, and the file is mostly the definitions. // Every legend that names this kind says it has no body to expand, so the verb must not hand back one. inline bool expandAutoServeScope( const rw::Config& cfg, bool anyExpandRange, bool bodiesRendered, bool allModuleScope ) { return !cfg.expand.empty() && !cfg.topKExplicit && !cfg.json && cfg.outline.empty() && !cfg.packSignatures && cfg.packTopN == 0 && cfg.query.empty() && !cfg.adaptive && cfg.maxTokens == 0 && !anyExpandRange && bodiesRendered && !allModuleScope; } // THE CHOICE: whole-file when the served file bytes undercut the measured bundle, else bundle; ties go // to the bundle (the richer answer at equal cost). A wf.complete=false render (a file unreadable NOW) // makes whole-file not a candidate, and the reason says so instead of fabricating a byte comparison. // D2 (audit regressions, 2026-08-08): both candidates are SHAPED before they are compared — wf.rawBytes // is post---compress when that flag is on (renderWholeFiles), and bundleBytes was always the shaped // bundle — so the disclosure never claims a comparison between two forms the caller was not offered. // And --pack-budget-bytes composes rather than being silently outrun: a whole file cannot be // budget-truncated and still be "the complete answer", so a file over the budget is NOT a candidate // (the bundle's packBodies enforces that same budget with its over-budget omission markers), with the // reason saying exactly that. The disclosure is mandatory and deterministic — every number is measured, // never estimated. < in the reason spelling: a raw '<' is ill-formed inside an XML attribute (G4). struct ExpandServeChoice { bool serveWholeFile = false; std::string ctxOpen; }; // The whole-file serving's own legend (PR #215 review item 9), as ONE constant: the bytes CHARGED in the // bundle-vs-file comparison below and the bytes APPENDED to the root in runDefaultMap are the same object, so // the choice cannot be made on a price the document does not pay. It carries no "--": it rides inside an XML // comment, where a double hyphen is ill-formed (G4). inline constexpr std::string_view kExpandWholeFileLegend = ""; // #289's ride-along `note=` FORMAT STRING, as ONE constant shared by every site that prices or prints it // (the §F5 top-K search, the §F5 ceiling verdict, and the actual noteBuf fill in runDefaultMap) — CodeRabbit // PR #292 findings 4052087920 (the search/verdict must charge these bytes, not just the bundle selector) and // 4052087924 (payload-neutral wording: `noteAppliesToBundle` also covers an outline-only request, which // emits , never ). "payload" covers --expand's and --outline's alike // without naming either — the same neutral word the --top-k=0 --help text already uses for this exact // concept ("Use it when you want the body ... PAYLOAD-ONLY", docs/COMMANDS.md). inline constexpr const char* kMapRidesAlongFmt = " note=\"the ranked top-{} map below rides along with the requested payload; --top-k=0 for the payload " "alone (--top-k=1 for a minimal map)\""; // ── expand-lean-k64: --expand/--outline whose requested definitions were ALL served ride no map ───────────────────── // A multi-definition name used to ship the generic top-200 map beside bodies that already answer it (0 of 185 rows named // the symbol). When every requested definition is served there is nothing left to disambiguate: top-k defaults to 0, the // root says so (topk_default="0", the exact-name default's own disclosure) and carries ONE recoverable pointer to the map // it did not ship (map_next=, a shell line: the map is the default verb of `ripwire ROOT`). The bodies' headers already // carry p= l= t= for every definition. When some were NOT served (cut by the byte budget, unreadable), the root names // exactly those — unserved_total= (the true count) and unserved_next= (the --expand/--outline call serving them, quoted by // the next= house rule) — never the generic map. kMaxUnservedSelectors is a blow-up guard (the kMaxExpandSibs precedent), // not a target: past it unserved_next= serves the FIRST kMaxUnservedSelectors and unserved_listed= says how many it lists; // the rest are recovered by the same call with a larger --pack-budget-bytes (the legend says so). Language-neutral: the // decision reads the served-id record, not any language's syntax. An EXPLICIT --top-k keeps today's shape. inline constexpr std::size_t kMaxUnservedSelectors = 16; // `ripwire ROOT…`: EVERY root this call crawled (a multi-root call's map is the map of all of them — an empty mapRootArg // there used to fall back to ".", the parent tree), each quoted by the next= house rule, plus the crawl-scope flags that // decided which files were indexed, so the pasted call maps the same corpus. Presentation and payload flags stay out. inline std::string leanMapPointer( const rw::Config& cfg ) { std::string inv = "ripwire"; for( const std::string_view root : cfg.roots ) { inv += " " + rw::nextFlag( "", root ); } if( cfg.roots.empty() ) { inv += " ."; } return inv + crawlScopeFlags( cfg ); } struct UnservedDefs { std::size_t requested = 0; // valid, non-module-scope requests std::size_t unserved = 0; // of those, the ones with no body/outline in the answer std::string attrs; // ` unserved_total="N" [unserved_listed="K"] unserved_next="--expand=P:L:N,…"` — empty when unserved == 0 }; inline UnservedDefs unservedDefsOf( const rw::IngestResult& ing, const std::vector& requested, std::vector served, std::string_view verbFlag, std::string_view rootArg ) { UnservedDefs u; std::sort( served.begin(), served.end() ); const std::string rootPrefix = rootArg.empty() ? std::string() : rw::sarif::rootPrefixOf( rootArg ); std::string selectors; std::size_t listed = 0; for( rw::NodeId id : requested ) { if( id >= ing.symbols.size() || ing.symbols[ id ].kind == rw::SymKind::ModuleScope ) { continue; } ++u.requested; if( std::binary_search( served.begin(), served.end(), id ) ) { continue; } ++u.unserved; if( listed >= kMaxUnservedSelectors ) { continue; } const rw::Symbol& sym = ing.symbols[ id ]; const std::string_view path = rootArg.empty() ? std::string_view( ing.files[ sym.fileId ] ) : rw::sarif::rootRelativeUri( ing.files[ sym.fileId ], rootPrefix ); selectors += ( listed == 0 ? "" : "," ); selectors += std::string( path ) + ":" + std::to_string( sym.line ) + ":" + sym.name; ++listed; } if( u.unserved > 0 ) { // unserved_listed= only when the guard cut the list: unserved_next= then serves the first `listed`, not all of them. u.attrs = " unserved_total=\"" + std::to_string( u.unserved ) + "\"" + ( listed < u.unserved ? " unserved_listed=\"" + std::to_string( listed ) + "\"" : std::string() ) + rw::nextAttrXml( rw::nextFlag( std::string( verbFlag ) + "=", selectors ), "unserved_next" ); } return u; } // ── ONE PRICE FOR BOTH SERVING CANDIDATES (CodeRabbit, PR #215, second round on this comparison) ───── // THE DEFECT was not a missing addend, it was two counters. The bundle candidate was priced to the byte // (envelope, root attributes, the unproven residue, the map, the rendered , the closing tag) by the // arithmetic in runDefaultMap, while the file candidate was priced HERE as `wf.rawBytes + the legend` — no // `` envelope, no root attributes, no ``, and the file's RAW bytes instead of the // blocks that actually carry them. Measured on an 864 B file: reason= said "file 1100B" for a document that // came out 1263 B, and inside that 163 B band the tool chose — and DISCLOSED — the whole-file form while the // bundle it rejected was the smaller document (pre-fix binary: 1262 B served under // reason="file 1100B < bundle 1193B"). The FIRST asymmetry in this same comparison, found one review round // earlier, was the whole-file legend; a second occurrence of one defect class means the comparison is being // built by hand on each side, which is the bug to fix. // // THE RULE APPLIED is the one src/prcontext.h states verbatim above its own estimator — "ONE estimator for // every root, never two counters (serialize.h's standing rule)". Each candidate DESCRIBES itself as an // ExpandServeDocument and both are priced by priceExpandServeDocument, so the comparison is symmetric by // construction: a field one side fills and the other forgets is the only way to reintroduce the defect, and a // third candidate is priced by the same function or not priced at all. struct ExpandServeDocument { std::size_t payloadBytes = 0; // this mode's payload AS EMITTED: the section, or the whole file's blocks std::size_t mapBytes = 0; // the ranked map, when this mode emits one (0 when it does not) std::size_t rootAttrBytes = 0; // attributes only this mode carries (root=, topk_default=, a payload-priced est_tokens=) std::size_t legendBytes = 0; // legend comments only this mode emits double selfPriceRate = 0.0; // >0: this mode prices ITSELF on its root (est_tokens=), at this B/token rate std::ptrdiff_t compactDeltaBytes = 0; // L1 fix round (rv-r1-L1 MED-7): under the compact posture, how many bytes the compact // layer ADDS to this candidate (negative: takes out) — its prose legend out, the compact // legend and schema= in (the whole-file legend GROWS: a longer compact legend), // measured on the candidate itself — so the choice and the reason= figures are the // DELIVERED sizes. 0 in the full posture: nothing moves. }; // The whole served document, envelope included. `disclosureBytes` is the mode=/reason= attribute pair the // choice itself writes onto the root: it is self-referential — its digits ARE the numbers it reports — so the // caller solves it the way pricedRootAttr solves est_tokens, and hands the settled spelling's length back in. inline std::size_t priceExpandServeDocument( const ExpandServeDocument& doc, std::size_t sharedRootBytes, std::size_t disclosureBytes ) { std::size_t total = ( sizeof( "" ) - 1 ) + sharedRootBytes + doc.rootAttrBytes + disclosureBytes + doc.legendBytes + doc.mapBytes + doc.payloadBytes + ( sizeof( "" ) - 1 ); if( doc.selfPriceRate > 0.0 ) { // …through pricedRootAttr itself, not a re-derived digit count: the attribute this charges is the one // the emission path splices, from the same fixpoint over the same byte total (serialize.h). std::size_t estTokens = 0; total += rw::pricedRootAttr( total, doc.selfPriceRate, 0, &estTokens ).size(); } const std::ptrdiff_t delivered = static_cast( total ) + doc.compactDeltaBytes; return delivered > 0 ? static_cast( delivered ) : total; } inline ExpandServeChoice chooseExpandServe( const ExpandServeDocument& bundleDoc, const ExpandServeDocument& fileDoc, std::size_t sharedRootBytes, const rw::WholeFileRender& wf, std::size_t budgetBytes ) { ExpandServeChoice c; char open[ 200 ]; if( !wf.complete ) { rw::formatTo( open, sizeof( open ), "" ); c.ctxOpen = open; return c; } // The pack-budget ceiling is deliberately still read off wf.rawBytes: that question is about the FILE's own // size against --pack-budget-bytes, not about which of two documents is cheaper to serve. No comparison is // made on this path, so no price is claimed and none is charged. if( wf.rawBytes > budgetBytes ) { rw::formatTo( open, sizeof( open ), "", wf.rawBytes, budgetBytes ); c.ctxOpen = open; return c; } // THE FIXPOINT. Each candidate carries its own mode=/reason= pair when it wins, so each is charged its own // spelling: price, spell, re-price, at most four passes — pricedRootAttr's bound, for pricedRootAttr's // reason. Digit counts are the only thing that can move, so it settles on pass two on every real input; // a fourth pass that still disagrees keeps the last spelling, which is the one the document receives. char fileOpen[ 200 ] = { 0 }; char bundleOpen[ 200 ] = { 0 }; std::size_t fileBytes = 0, bundleBytes = 0, fileDisclosure = 0, bundleDisclosure = 0; for( int pass = 0; pass < 4; ++pass ) { fileBytes = priceExpandServeDocument( fileDoc, sharedRootBytes, fileDisclosure ); bundleBytes = priceExpandServeDocument( bundleDoc, sharedRootBytes, bundleDisclosure ); rw::formatTo( fileOpen, sizeof( fileOpen ), "", fileBytes, bundleBytes ); rw::formatTo( bundleOpen, sizeof( bundleOpen ), "", bundleBytes, fileBytes ); // the subtraction's precondition: both spellings begin with the literal "', so // neither can be shorter than the envelope it is measured against (formatTo always NUL-terminates). ASSUME( std::strlen( fileOpen ) >= ( sizeof( "" ) - 1 ) && std::strlen( bundleOpen ) >= ( sizeof( "" ) - 1 ) ); const std::size_t nextFile = std::strlen( fileOpen ) - ( sizeof( "" ) - 1 ); const std::size_t nextBundle = std::strlen( bundleOpen ) - ( sizeof( "" ) - 1 ); if( nextFile == fileDisclosure && nextBundle == bundleDisclosure ) { break; } fileDisclosure = nextFile; bundleDisclosure = nextBundle; } // Ties still go to the bundle — the richer answer at equal cost. c.serveWholeFile = fileBytes < bundleBytes; c.ctxOpen = c.serveWholeFile ? fileOpen : bundleOpen; return c; } int runDefaultMap( const MainDispatch& d ) { using namespace rw; const Config& cfg = d.cfg; const IngestResult& ing = d.ing; const Graph& g = d.g; const std::string& root = d.root; const bool multiRoot = d.multiRoot; const std::vector& ws = d.ws; const std::vector* fanInPtr = d.fanInPtr; const std::vector* cboPtr = d.cboPtr; const std::vector* testedPtr = d.testedPtr; const std::vector* lcom4Ptr = d.lcom4Ptr; const std::vector* ampPtr = d.ampPtr; const std::vector* impurePtr = d.impurePtr; RedactCounts* redactPtr = d.redactPtr; RedactCounts& redactCounts = d.redactCounts; // R-E (2026-08-17 harvest): the SAME single-root condition emitGrepReport uses, so the default map's // root="…" and --grep's cannot diverge on when it appears. Multi-root already carries its own // roots=/ disclosure inside serialize() and is untouched (rootArg stays empty there). const bool mapSingleRoot = ing.realPaths.empty() && cfg.roots.size() == 1; const std::string_view mapRootArg = mapSingleRoot ? cfg.roots[0] : std::string_view(); // C1-b: the crawl-side half of --in=DIR's validation, taken as soon as the corpus exists and BEFORE any // history is mined — a directory this crawl does not hold cannot be answered about, and must not be // answered about with an empty block. (The filesystem half ran at root resolution; see inDirMatchesCrawl.) if( !cfg.inDir.empty() && !inDirMatchesCrawl( cfg, ing, std::string( mapRootArg ) ) ) { return 1; // the refusal is on stderr (inDirMatchesCrawl) } std::vector rank; // The map scope (docs/EVALS.md "Map data Sections never crowd code out of the default map"): the plain map — XML, // --json, --html, --max-tokens — at the default --rank-by with no payload verb picks its rows code-first // (serialize.h codeFirstKeep) and discloses the Sections that pick swapped out. The rank vector is untouched. // --query, --map-diff, churn, authority/hub/rrf and a payload verb's ride-along map (--expand, --outline, // --pack-signatures, --pack-top-n) keep the plain rank-order cut. const bool isDefaultMapScope = cfg.query.empty() && !cfg.mapDiff && cfg.rankBy == RankBy::PageRank && cfg.expand.empty() && cfg.outline.empty() && !cfg.packSignatures && cfg.packTopN <= 0; // W2-F: the map header's pr_iters= / pr_converged= (src/prconverge.h). Default-constructed is // isPageRank=false — CORRECT for the arms below that run no power iteration (a lexical query score, the // HITS vectors that overwrite `rank`); the PageRank arms fill it via rw::takeRank (graph.h), never apart. rw::RankDisclosure rankDisclosure; std::string queryRouteNote; // leading routed comment for --query (empty under --no-route) std::size_t mapDiffChanged = 0; // D6: teleport-seed file count, only meaningful when mapDiffActive bool mapDiffActive = false; // true only under --map-diff — gates the header's changed= attribute std::vector recentFiles; // F3: rank-by=churn-decay's file-level rows (0 rows is an ANSWER, see recentAnyHistory) std::size_t recentOf = 0; bool recentAnyHistory = false; // did the decay pass READ a commit — the fact the block's presence is gated on std::uint32_t recentMergeBombsSkipped = 0; // : the window's skipped >100-file commits std::vector scopedRecent; // C1-b: --in=DIR's page of rows (hasScopedRecent ⇒ the block is emitted) std::size_t scopedRecentOf = 0; std::size_t scopedOffset = 0; bool hasScopedRecent = false; std::string churnWindowLabel = rw::defaultWindowLabel( root, "18mo" ); // §A9.6: churn's window label (F1: "@HEAD" when anchored); an ACTIVE --since overrides it below if( !cfg.query.empty() ) { // --query: PURE lexical (BM25) relevance. eval-at-scale showed fusing PageRank importance // into the lexical signal HURTS relatedness (RRF(struct,lex) lost to lex on 25/35 commits) — // PageRank ranks centrality, not relevance-to-a-query. So --query is lexical only. // ROUTING (default on, same confidence-gated classifier as --for): name-exact when the query NAMES a // symbol, else subtoken+body. --no-route forces subtoken+body (the pre-default behavior, byte-identical). // The map header comes from serialize (not ours to extend), so the routed pick is a LEADING comment // before the map, mirroring how --adaptive on --query surfaces its cut. // §P4 tier de-prioritization — same query-independent multiplier as --for's computeLensRanking // (filter.h): --query is a ranking lens too. --recall (docs lane) deliberately does NOT take this. const std::vector tierMul = rankTierSymbolMultipliers( ing ); if( !cfg.noRoute ) { const RouteChoice rc = chooseForRanker( ing, cfg.query ); rank = ( rc.which == LexMode::NameExact ) ? lexicalScoresNameExactRanked( ing, cfg.query, &tierMul ) : lexicalScoresTiered( ing, g.outOff, g.outTargets, cfg.query, 0, nullptr, &tierMul ); // §B4 (capture-audit-4) — the SEVENTH comment-echo site, and the only one W3FIX M3 missed. It // hand-rolled the std::unique dash collapse that xmlCommentText's header names as the pattern it // replaced, and scrubbed NEITHER control bytes nor invalid UTF-8 — while RouteChoice::reason // embeds `identifierHit`, the user's own query token (lexical.h). So `--query=$'parseArgs\001x'` // put a raw 0x01 inside an XML comment and `--query=$'parseArgs\377x'` an invalid UTF-8 byte: // ripwire exited 0 and xmllint rejected the whole document, which is a G4 breach at exit 0 — // the ONE real breach a control byte swept through 19 value-taking verbs found. // // The dash collapse is byte-identical to what stood here (both reduce a run of '-' to one), so // every input that carried no control byte and no invalid sequence emits exactly the bytes it did // before; the fix is purely what the hand-rolled version could not see. The wrapping // delimiters are still added AFTER, so they stay intact. queryRouteNote = ""; } else { rank = lexicalScoresTiered( ing, g.outOff, g.outTargets, cfg.query, 0, nullptr, &tierMul ); } } else if( cfg.mapDiff ) { // Multi-root (§5 / A8): the teleport seed is the UNION of the per-root diffs, each mined per repo. std::vector changed( ing.files.size(), 0 ); bool gitOk = false; if( multiRoot ) { for( std::uint32_t r = 0; r < ws.size(); ++r ) { if( gitChangedFiles( ws[r].arg, ing, changed, r ) ) { gitOk = true; } } } else { gitOk = gitChangedFiles( root, ing, changed ); } mapDiffActive = true; // D6: header emits changed= regardless of gitOk — 0 on a clean tree / no-git degrade too for( char c : changed ) { if( c ) { ++mapDiffChanged; } } if( gitOk ) { rank = rw::takeRank( rankGraphTeleport( g, diffTeleport( ing, changed ) ), rankDisclosure ); } else { rw::emitRaw( stderr, "ripwire: --map-diff requires git in PATH; using uniform ranking\n" ); rank = rw::takeRank( rankGraph( g ), rankDisclosure ); } } else if( cfg.rankBy == RankBy::Churn || cfg.rankBy == RankBy::ChurnDecay ) { ChurnRanking cr = churnRankedGraph( d ); // §A9.6: the ranking and the window label it must disclose rank = std::move( cr.rank ); rankDisclosure = cr.pr; // W2-F: churn is a PageRank TELEPORT variant — it runs the power iteration too churnWindowLabel = std::move( cr.window ); // §B2.2: already carries "(no churn evidence)" when the window mined nothing recentFiles = std::move( cr.recent ); // F3: the rows (churn-decay, single-root; empty otherwise) recentOf = cr.recentOf; recentMergeBombsSkipped = cr.mergeBombsSkipped; recentAnyHistory = cr.recentAnyHistory; // B: propagated, never re-derived from the rows below scopedRecent = std::move( cr.scoped ); scopedRecentOf = cr.scopedOf; scopedOffset = cr.scopedOffset; hasScopedRecent = cr.hasScoped; } else { rank = rw::takeRank( rankGraph( g ), rankDisclosure ); } // HITS: surface hubs (entrypoints/orchestrators) or authorities (core APIs) instead of the default // PageRank order. (Churn is a PageRank-teleport variant handled above, not a HITS axis.) if( cfg.rankBy == RankBy::Authority || cfg.rankBy == RankBy::Hub || cfg.rankBy == RankBy::Rrf ) { auto [ authority, hub ] = hits( g ); if( cfg.rankBy == RankBy::Rrf ) { rank = rrfFuse( { &rank, &authority, &hub } ); // fuse pagerank + authority + hub } else { // W2-F: hub and authority REPLACE the PageRank vector, so its disclosure must not survive either // (rrf above KEEPS it — pagerank is one of the three vectors it fuses, so that run shaped the order). rankDisclosure = rw::RankDisclosure{}; rank = ( cfg.rankBy == RankBy::Hub ) ? std::move( hub ) : std::move( authority ); } } // R6 (A4-R6) — --format=candidates on --query: the same FLAT top-K export as the --for path, over the // lexical (BM25) rank. Bypasses the map/adaptive/html emission below (a single root, G4-clean). // Guarded to a real --query (cli.h refuses --format=candidates without --for/--query); capped by --top-k. // // §P12.2 fix: --adaptive used to no-op here for the same "bypasses adaptive" reason stated above — // emitCandidates() now cuts BEFORE the bypass, exactly like --query's own default-map cut below // (scanFullDistribution=false: --query's ceiling is already the full top-k, no "capped at 40" to correct). if( cfg.candidates && !cfg.query.empty() ) { // §A4f: --query does not go through --for's ranker router at all — it exports the query-personalized // graph rank, so route= names THAT and anchored= is 0 by construction (the §B8 mention anchor is a // --for-lens stage). Naming it explicitly beats leaving a reranker to assume the --for scale. emitCandidates( stdout, ing, rank, cfg.topK, cfg.adaptive, /*scanFullDistribution=*/false, CandidateProvenance{ "query-personalized", 0, false }, redactPtr, mapRootArg ); reportRedactions( stderr, d.redactCounts ); // W3-N1: same seam, same disclosure, on the --query arm return 0; } // --max-tokens=N: binary-search the largest top-K whose serialized map fits ~N tokens. T1: the map // output tokenizes at ~2.4-2.6 B/tok (MEASURED vs tiktoken; NOT 4) — the old ×4 over-filled by ~1.6×. // We size the byte budget with kMinBytesPerToken (the DENSEST language rate) × kBudgetHeadroom (90%) // so N is a real CEILING: the packed map's actual token count stays under N for any language mix. // (Claude's tokenizer isn't public → calibration + headroom, never a vendored BPE table; §2f.) // // §B13.4 — the two Ns are NOT the same unit, and now the map says so. --max-tokens=N fits BYTES // (N x 2.36 x 0.90) while est_tokens reports this corpus's own language-weighted rate, so a conformant fit // lands under N in the reported currency — MEASURED on src/: N=1500 honoured 3155 of its 3186-byte ceiling // and reported est_tokens=1216, i.e. 81% of the budget, with the shortfall disclosed nowhere and a --help // that invited composing --max-tokens with --token-budget as though the numbers were comparable. The // conservative ceiling STAYS — a cap that can be overshot is not a cap, and the tokenizer we are estimating // against is not public, so the densest-language rate plus a headroom factor is the only bound that holds // for any corpus. What was wrong was the silence, so mapAnn below carries max_tokens= and fit_bytes= onto // the shaped map, kMaxTokensFitLegend defines them, and --help states the relationship. // §F5 — THE ANNOTATIONS, RESOLVED ONCE, so the probe below and the emission at the bottom of this function // cannot describe two different documents. `mapAnn` used to be built ~250 lines down, AFTER the search, and // the search hand-built a second, SHORTER one (`probeAnn`: the fit only). Every annotation the probe did not // carry became a silent ceiling breach in the emitted map — MEASURED on src/: `--map-diff` +31 B (changed=, // at=) over the cap at N=3000/6000/12000, `--rank-by=churn` +118..204 B (rank_by=, window=, kChurnRankLegend) // over it at nearly every N up to 12000. `at=` is the one part that costs anything to compute, and its guard // is unchanged, so this hoist adds no git subprocess to any path that did not already run one. const bool isChurnRanked = ( cfg.rankBy == RankBy::Churn || cfg.rankBy == RankBy::ChurnDecay ) && !multiRoot; const std::string mapDiffAt = ( mapDiffActive && !multiRoot ) || isChurnRanked ? gitstamp::stampAt( root ) : std::string(); // §A4d: BOTH serializations take the same per-edge provenance vector — resolved once here rather than // spelled as the same empty-check ternary on each arm, so the two formats cannot drift on this input. const std::vector* mapProvPtr = g.outProv.empty() ? nullptr : &g.outProv; int mapTopK = cfg.topK; rw::MapAnnotations::MaxTokensFit maxTokensFit; // §B1.2: the run-provenance annotations, resolved ONCE for both serializations for the same reason // mapProvPtr is — the churn stamp used to be spelled on the XML arm only, so `--rank-by=churn --json` // and `--rank-by=pagerank --json` emitted keyset-identical headers over differently-meaning k=. // §B2.1: the windowless ranker label, from the ONE table serialize.h holds — so "which rankers stamp" // is a property of that table rather than of a switch here that a new ranker could be added without. // Churn is deliberately excluded: it stamps through churnWindowLabel (it has a window to disclose), and // the two fields are mutually exclusive by construction, which is what the serializer's `else if` asserts. const char* const rankByLabel = ( cfg.rankBy == RankBy::Authority ) ? "authority" : ( cfg.rankBy == RankBy::Hub ) ? "hub" : ( cfg.rankBy == RankBy::Rrf ) ? "rrf" : nullptr; rw::MapAnnotations mapAnn{ mapDiffActive ? &mapDiffChanged : nullptr, &mapDiffAt, isChurnRanked ? &churnWindowLabel : nullptr, cfg.rankBy == RankBy::ChurnDecay ? "churn-decay" : "churn", // P0-4 cfg.maxTokens > 0 ? &maxTokensFit : nullptr, // §B13.4 rankByLabel, // §B2.1 rankDisclosure, // W2-F: pr_iters= / pr_converged= // F3: rows, churn-decay single-root only. The POINTER is the rows; whether the block RIDES is // decided by recentAnyHistory below — the mining fact, propagated. This slot used to carry the decision as // well ("empty rows and no skipped bomb ⇒ nullptr"), which inferred "no history was mined" from what // happened to be emitted: an all-bomb window (a shallow clone of a large tree: one 183,835-file commit) and // a window whose only commit touched no indexed file BOTH have zero rows, and only one of them read nothing. &recentFiles, recentOf }; mapAnn.recentMinedHistory = recentAnyHistory; // the block rides on the FACT (serialize.h writeRecentRows) mapAnn.recentMergeBombsSkipped = recentMergeBombsSkipped; // rides (the rows' own window), filled by assignment like seed mapAnn.viaLegendStripped = cfg.legend == "compact" && !cfg.json; // FE-B: the rewrite would strip the map's via comment mapAnn.notesDegraded = d.notesDegraded; // L3 follow-up (CodeRabbit 4053600616): onto every this run emits mapAnn.codeFirstRows = isDefaultMapScope; // the code-first row pick + its data_sections_cut= / next= (serialize.h) mapAnn.inventoryTier = isDefaultMapScope && !cfg.noInventory; // D7: the tier (mapInventoryWanted also drops it under --max-tokens) // C1-b (2026-09-12): --in=DIR — the scoped block and the map stub, filled by assignment like seed. The two next= strings // outlive every serialize() call below (mapAnn holds views into them). The scoped next= is the SAME run at the next // offset, page size carried when the caller set one; the stub's next= is the same run without in= (the map it stubbed). std::string scopedNext; std::string stubNext; const std::string scopeDirStr = hasScopedRecent ? rw::sarif::rootPrefixOf( cfg.inDir ) : std::string(); if( hasScopedRecent ) { mapAnn.scopedRecent = &scopedRecent; mapAnn.scopeDir = scopeDirStr; mapAnn.scopedRecentOf = scopedRecentOf; mapAnn.scopedOffset = scopedOffset; mapAnn.scopedLimit = cfg.pageLimit; const std::size_t nextOffset = scopedOffset + scopedRecent.size(); if( nextOffset < scopedRecentOf ) { scopedNext = scopedMapNextInvocation( cfg, mapAnn.scopeDir, /*withIn=*/true, nextOffset ); mapAnn.scopedNext = scopedNext; // always the full invocation now (no length ceiling on next=) } stubNext = scopedMapNextInvocation( cfg, mapAnn.scopeDir, /*withIn=*/false, 0 ); mapAnn.stubSymbols = true; mapAnn.stubNext = stubNext; } // T3's auto-flip changes the order= spelling ("important-last(auto:fill)" is 11 bytes longer than // "important-first"), so it is a BYTE fact, not only an ordering one — the comment that used to sit here // claimed the search was "unaffected by emit order", and at N=20000 on src/ the flip fires. One value, // read by the probe and by the emission. const bool mapAutoOrder = !cfg.noAutoOrder; // §F5 — ONE measurement of the map's own emitted bytes, used by the --max-tokens search below AND by the // ceiling VERDICT taken further down (`maxTokensFit.isOverCeiling`). Parameterized on extraPayloadTokens // because est_tokens is printed twice in the map's own header, so charging an appended §H7 payload grows // the map's own digit count: MEASURED on src/ at N=6000 --pack-signatures the map portion is 12 749 B // against a 12 744 B cap — 5 bytes over, from digits alone. The SEARCH prices the map alone (that is what // --max-tokens has always shaped, and the payload is not charged until ~250 lines below); the VERDICT is // taken afterwards, with the real number, so the label cannot miss that member either. // // DEGRADE: open_memstream failure returns 0, which reads as "fits" — the pre-§F5 behaviour, and the safe // direction here: a size this path could not measure must not mint an over_ceiling label it cannot support. // L1 fix round: the map's TEXT, for the one caller that must compact a candidate to price what it delivers (--expand's // serving choice under the compact posture). The same render measureMapBytes measures; empty when the buffer failed. const auto renderMapText = [ & ]( int k, std::size_t extraPayloadTokens ) -> std::string { rw::MemoryStream probe; std::FILE* const m = rw::openChargeStream( probe ); if( !m ) { return {}; } serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls ); const rw::MemoryStreamBytes measured = probe.finish(); return measured.isWhole ? std::string( measured.bytes ) : std::string(); }; const auto measureMapBytes = [ & ]( int k, std::size_t extraPayloadTokens ) -> std::size_t { rw::MemoryStream probe; std::FILE* const m = rw::openChargeStream( probe ); if( !m ) { DISCLOSE( maxTokensFit, rw::MapAnnotations::MaxTokensFit::DisclosureWhy::ProbeUnmeasured, "runDefaultMap: open_memstream failed for the --max-tokens fit probe — the map is emitted unshaped and its ceiling unverified" ); return 0; } serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls ); const rw::MemoryStreamBytes measured = probe.finish(); if( !measured.isWhole ) { // a short size would read as a SMALLER map and pass a ceiling the real one breaks; 0 is the documented unmeasured answer DISCLOSE( maxTokensFit, rw::MapAnnotations::MaxTokensFit::DisclosureWhy::ProbeUnmeasured, "runDefaultMap: the --max-tokens fit probe's buffer did not finish whole — the map is emitted unshaped and its ceiling unverified" ); return 0; } return measured.bytes.size(); }; // §C4 (capture-audit-4, wave 3) — the same measurement in THE DIALECT THAT WILL ACTUALLY BE BUILT. // // measureMapBytes above calls serialize(), i.e. it prices the XML rendering, and under --json the document // that reaches stdout is serializeJson()'s. serialize.h's own §C4 note calls that out and records the fix as // "a main.cpp change, not smuggled in here" — this is that change, taken at the VERDICT and deliberately // NOT at the search (see the verdict site below for why the split, and what is still routed). // // MEASURED on src/, sweeping --max-tokens: the JSON document is under the ceiling up to N≈6000 and OVER it // from N≈6500 — +49 B at 6500, +319 B at 10000, +1545 B at 20000 — with no over_ceiling label anywhere, // because the label was decided from the XML measurement. So this is not a hypothetical dialect mismatch: // `--max-tokens=10000 --json` emitted 21559 B against the fit_bytes=21240 it printed in its own header and // called the cap honoured. Trap #8, on the key that exists to stop exactly that. // // serializeJson takes no extraPayloadTokens because it has no payload to take: jsonUnsupportedVerb refuses // every payload verb (--expand/--outline/--pack-signatures/--pack-top-n) under --json, so that argument is // provably 0 on this path — asserted rather than assumed. const auto measureEmittedMapBytes = [ & ]( int k, std::size_t extraPayloadTokens ) -> std::size_t { if( !cfg.json ) { return measureMapBytes( k, extraPayloadTokens ); } ASSUME( extraPayloadTokens == 0 ); // the payload verbs are all refused under --json rw::MemoryStream probe; std::FILE* const m = rw::openChargeStream( probe ); if( !m ) { DISCLOSE( maxTokensFit, rw::MapAnnotations::MaxTokensFit::DisclosureWhy::ProbeUnmeasured, "runDefaultMap: open_memstream failed for the --max-tokens JSON ceiling probe — the ceiling verdict is unverified" ); return 0; // reads as "fits" — the same safe direction measureMapBytes takes } serializeJson( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, mapProvPtr, mapAnn, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls ); const rw::MemoryStreamBytes measured = probe.finish(); if( !measured.isWhole ) { DISCLOSE( maxTokensFit, rw::MapAnnotations::MaxTokensFit::DisclosureWhy::ProbeUnmeasured, "runDefaultMap: the --max-tokens JSON ceiling probe's buffer did not finish whole — the ceiling verdict is unverified" ); return 0; // the same "unmeasured" answer the open failure above gives } return measured.bytes.size(); }; const std::size_t maxTokensCeilingBytes = budgetBytesForTokens( std::size_t( cfg.maxTokens ) ); // §F5 (cont.) — THE WRAPPER IS PART OF THE MAP PORTION THE CALLER RECEIVES. A payload verb // (--pack-signatures / --pack-top-n / --expand / --outline) opens `` BEFORE serialize()'s own bytes // (the fprintf below, ahead of the §H7 pre-render), so everything through `` is 5 bytes larger than // what measureMapBytes measures — and neither the search nor the verdict charged them: MEASURED on src/ // at N=6000 --pack-signatures, serialize's own portion fit the 12 744 B cap by 4 bytes and the delivered // map portion was 12 745 B — 1 byte over, unlabelled (estchargecheck #5b's exact red). Charged in BOTH // the search and the verdict so the two keep describing the same delivered document. Computed from // `hasExtension` itself (hoisted to here — pure cfg, nothing the search changes) so the charge and the // wrapper fprintf below can never drift; every payload verb is refused under --json, so the JSON path's // charge is provably 0. The closing `` lands AFTER ``, in payload territory, and stays // charged to est_tokens like the rest of the payload, not to fit_bytes. const bool hasExtension = cfg.packSignatures || cfg.packTopN > 0 || !cfg.expand.empty() || !cfg.outline.empty(); const std::size_t mapCtxOpenBytes = ( hasExtension && !cfg.json ) ? sizeof( "" ) - 1 : 0; // CodeRabbit PR #292 finding 4052087920: the ride-along `note=` attribute (kMapRidesAlongFmt, formatted // into `noteBuf` below once `mapTopK` is settled) lands INSIDE the same `` open tag mapCtxOpenBytes // charges — so like mapCtxOpenBytes itself, it belongs to the map portion the ceiling gates, not to // payload territory. Neither the search below nor §F5's verdict charged it, so a near-limit map could // emit `note=` past `fit_bytes` with `over_ceiling` never set. `noteWouldApplyGivenTopK` is // `noteAppliesToBundle` minus its `mapTopK > 0` term (every candidate this search tries is >= 1, so that // term is trivially true here); `noteBytesForTopK` FORMATS the real string for each candidate k rather // than hand-deriving its digit count, so this can never drift from what `noteBuf` itself prints — the // same "measure, don't assume" discipline `measureEmittedMapBytes` already applies. Shares // kMapRidesAlongFmt with the actual `noteBuf` fill below so the two format strings cannot drift apart. const bool noteWouldApplyGivenTopK = !cfg.topKExplicit && ( !cfg.expand.empty() || !cfg.outline.empty() ) && !cfg.json; const auto noteBytesForTopK = [ & ]( int k ) -> std::size_t { if( !noteWouldApplyGivenTopK ) { return 0; } char probeBuf[ 220 ] = { 0 }; rw::formatTo( probeBuf, sizeof( probeBuf ), kMapRidesAlongFmt, k ); return std::strlen( probeBuf ); }; if( cfg.maxTokens > 0 ) { maxTokensFit = { std::size_t( cfg.maxTokens ), maxTokensCeilingBytes, /*isOverCeiling=*/false }; // maxTokens is > 0 here (guarded above) // §B13.4 — the probe must price the shape it will actually BUILD. The disclosure this item adds // (max_tokens=/fit_bytes= plus kMaxTokensFitLegend) is ~215 bytes charged against this very ceiling, so // a probe that did not carry the same annotations chose a top-K whose real emission overran the ceiling // by exactly the disclosure's own size — MEASURED at N=1200: 3029 B against a 2548 B cap, caught by // estchargecheck #5. climbCeilingLadder states the general rule ("a caller can never price a shape it // then fails to build"); this is that rule at the map's own ceiling, and §F5 is the same rule again for // the three annotations the hand-built probeAnn dropped — hence `mapAnn`, the one the emission uses. int lo = 1, hi = int( ing.symbols.size() ), best = 1; while( lo <= hi ) { const int mid = lo + ( hi - lo ) / 2; if( measureMapBytes( mid, 0 ) + mapCtxOpenBytes + noteBytesForTopK( mid ) <= maxTokensCeilingBytes ) { best = mid; lo = mid + 1; } else { hi = mid - 1; } } mapTopK = best; } // routed pick for --query: a LEADING comment before the map (serialize owns the map header, not ours to // extend) — mirrors how --adaptive surfaces its cut. Emitted only for a real --query with routing on, and // NOT on the --html path (which returns above with its own document). Under --no-route the note is empty. if( !cfg.query.empty() && !cfg.html && !queryRouteNote.empty() ) { std::fputs( queryRouteNote.c_str(), stdout ); } // --adaptive on --query (lever 2): cut the ranked map at the relevance cliff, exactly like // the --for path. --query's rank IS the lexical score, so the cliff is meaningful; floor=5, ceiling=the // current mapTopK (post --max-tokens). We emit the note as a LEADING comment (the map header comes from // serialize and is not ours to extend). Only fires with --query (the guard in cli.h requires --for OR // --query); on a plain map cfg.adaptive is false → mapTopK untouched, output byte-identical. if( cfg.adaptive && !cfg.query.empty() ) { const AdaptiveCut ac = adaptiveCut( rank, 5, std::size_t( mapTopK ) ); char nb[ 208 ]; if( !ac.hitCeiling && ac.cliffRank < ac.kept ) { rw::formatTo( nb, sizeof( nb ), "", ac.kept, mapTopK, ac.cliffRank, ac.dropPct, ac.kept ); } else if( !ac.hitCeiling ) { rw::formatTo( nb, sizeof( nb ), "", ac.kept, mapTopK, ac.cliffRank, ac.dropPct ); } else if( ac.positiveHits <= ac.kept ) { rw::formatTo( nb, sizeof( nb ), "", ac.kept, mapTopK, ac.positiveHits ); } else { rw::formatTo( nb, sizeof( nb ), "", ac.kept, mapTopK ); } std::fputs( nb, stdout ); mapTopK = int( ac.kept ); } // --html[=FILE]: emit self-contained HTML force-directed graph instead of the default XML map. // Purely additive: when absent the default path is completely unchanged. if( cfg.html ) { // churn for the --color-by=churn lens: THE shared per-file pass (mineChurnPerFile — also // --export=cc.json's and --hotspots'). Costs one git subprocess per root, matching cc.json's // posture; no evidence ⇒ the page's CHURN_OK legend note discloses instead of lying zeros. std::vector htmlChurn( ing.files.size(), 0 ); const rw::SinceScope htmlScope; // inactive: --html has no --since form // ONE spelling of the window, mined with it and then printed by the page's churn legend. It used to // be a bare literal here and the legend said only "0 1-2 3-9 10-29 30+" — five buckets of an unnamed // unit over an unstated horizon, which reads as "commits ever". Naming a constant and passing it is // what keeps the page from making a claim the run cannot back. static constexpr const char* kHtmlChurnWindow = "18 months ago"; const bool htmlChurnOk = mineChurnPerFile( ing, root, multiRoot, ws, std::string_view(), htmlScope, kHtmlChurnWindow, htmlChurn ); // tested for the --color-by=tested lens: QMetrics is computed upstream only under // --metrics/--for/--exemplar, so on a bare --html run testedPtr is null and every node would // read ts:0 — a "not computed" masquerading as "untested". Compute it here instead // (computeQMetrics is pure graph work, no git subprocess) so ts= is always a measured fact. QMetrics htmlQm; if( !testedPtr ) { htmlQm = computeQMetrics( ing, g ); testedPtr = &htmlQm.tested; } std::FILE* htmlOut = stdout; if( !cfg.htmlFile.empty() ) { // open the target file for writing; report failure and exit cleanly const std::string htmlPath( cfg.htmlFile ); htmlOut = std::fopen( htmlPath.c_str(), "wb" ); if( !htmlOut ) { DISCLOSE( Diagnostics::answerRefused, "--html exits 1 naming the file it cannot open on stderr; nothing is written", "writeHtml: could not open output file" ); rw::emitTo( stderr, "ripwire: --html={}: cannot open file for writing\n", htmlPath.c_str() ); return 1; } } // 2026-09-06 stranger audit: the page names what it maps (last path segment only — the path itself never // reaches the page), anchors to the commit like every XML root does, and says which binary drew it. HtmlColorExtras htmlColor{ testedPtr, &htmlChurn, htmlChurnOk, cfg.colorBy, kHtmlChurnWindow, cfg.rankBy }; const HtmlProvenance htmlProv = htmlProvenanceFor( root, multiRoot ); htmlColor.atStamp = htmlProv.atStamp; htmlColor.rootName = htmlProv.rootName; htmlColor.version = kRipwireVersion; writeHtml( htmlOut, ing, rank, g, mapTopK, htmlColor, mapRootArg, /*codeFirstRows=*/isDefaultMapScope ); // R-R if( htmlOut != stdout ) { std::fclose( htmlOut ); } return 0; } // When an extension verb appends additional blocks after the main map, the combined output // would have multiple XML root elements — invalid per the XML spec (G4 violation). Fix: wrap // the ENTIRE output (map + extension blocks) in a single root whenever extension output // is present. The DEFAULT map (no extension verb) is emitted UNwrapped, exactly as before, so // the golden and all existing callers that parse the bare … are unaffected. // (`hasExtension` itself is defined beside mapCtxOpenBytes above, so the wrapper's 5 delivered // bytes and the --max-tokens search/verdict that charge them read the same predicate.) // --expand est_tokens bugfix: the block that packBodies appends AFTER the map is // part of the payload the caller receives, so the header's est_tokens must include it (before this fix it // reported the map only — a --token-budget gate on a large --expand under-budgeted by ~2×). Resolve the // expand nodes HERE (once) so both the header estimate below and the packBodies emission later reuse the // SAME node/range set — no drift between "estimated" and "emitted", and no double name-resolution. // D7: a miss exits 1, like --callers/--impact. // r27-emitters T3: the resolution now runs BEFORE the first stdout byte, and a miss returns immediately — // --expand was the only verb that paired a refusal (exit 1) with a 22 KB payload of UNRELATED map, which // reads to a caller as "here is your answer" with a stray non-zero code. Refuse and write nothing, exactly // like --callers/--callees/--impact/--lego/--around/--edit-check. std::vector expandNodes; HashMap expandRanges; // H1: the decl→def residue of every --expand and --outline item, SUMMED: the two verbs share one root, a bare // NAME item adds 0, and the remedy is the same for any item (widen its file:name spelling). Unreported, a file:name // item whose definitions were dropped served the declaration's text alone with nothing saying what was left out. std::size_t ctxUnprovenDefs = 0; if( !cfg.expand.empty() ) { bool expandMissed = false; for( const std::string& tok : cfg.expand ) { // §P8 seam 1: resolveAllByNameQualified — the SAME resolver --callers/--callees/--impact use, // so `file:name` / `file:line:name` / a canonical id / a bare name all mean here exactly what // they mean there. On a bare name it is byte-identical to the resolveAllByName it replaces. const ExpandToken et = parseExpandToken( tok, "--expand" ); std::size_t tokUnprovenDefs = 0; const std::vector matches = resolveAllByNameQualified( ing, et.selector, &tokUnprovenDefs ); ctxUnprovenDefs += tokUnprovenDefs; if( matches.empty() ) { // §M7 (W3FIX): --expand takes the same file:name grammar as --uses/--callers and refused in the // pre-§B4.2 dialect — a bare did-you-mean about a NAME that may well exist, when the fault is // the path half. selectorFaultClause does the split (and states when the path itself is // unindexed); this arm keeps its own flag-first sentence byte-for-byte ahead of it. expandMissed = true; rw::emitTo( stderr, "ripwire: --expand={} matched no symbol{}\n", et.selector.c_str(), rw::selectorFaultClause( ing, et.selector, "--expand=" ).c_str() ); } else if( matches.size() > 8 ) { // final-segment names (reset/size/update) collide widely rw::emitTo( stderr, "ripwire: --expand={} matches {} symbols; emitting all up to --pack-budget-bytes (qualify with file:name to narrow)\n", et.selector.c_str(), matches.size() ); } for( NodeId id : matches ) { expandNodes.push_back( id ); if( et.range.hasRange ) { expandRanges[id] = et.range; // same range applies to every match of this token } } } if( expandMissed ) { return 1; // refusal + empty stdout — nothing has been printed yet } } // V1 (routing note ugrep RN2, 2026-08-15): an EXACT-NAME --expand — one token that resolved to exactly // one symbol — needs no orientation map: the caller already named the exact target, so the top-200 // ranked map is pure overhead in front of the one body it exists to summarize (MEASURED on this repo: // 12,944 est_tokens of map ahead of a ~1.4 KB body). Default top-k to 0 for this shape. An explicit // --top-k=N (0 included) always overrides — see the explicit-top-k guard just below. A MULTI-match name // (matches.size() > 1) or a multi-token --expand keep the caller's ordinary default: there IS something // to disambiguate/orient there, the exact reason expandAutoServeScope's lean-does-not-auto-compete // rationale still holds. Composed verbs already claim the map for their own purpose, so they are // excluded here exactly like expandAutoServeScope excludes them below. const bool exactNameExpandDefault = cfg.expand.size() == 1 && expandNodes.size() == 1 && !cfg.topKExplicit && cfg.outline.empty() && !cfg.packSignatures && cfg.packTopN == 0 && cfg.query.empty() && !cfg.adaptive && cfg.maxTokens == 0 && !cfg.json; if( exactNameExpandDefault ) { mapTopK = 0; } // --outline=NAME,...: same resolution, same refusal contract. A miss used to emit the map and exit 0, // so a typo'd outline was indistinguishable from a successful one. std::vector outlineNodes; if( !cfg.outline.empty() ) { bool outlineMissed = false; for( const std::string& rawNm : cfg.outline ) { // --outline has no range form (a control-flow skeleton is a whole-symbol shape), but SYM:START-END // is the muscle memory --expand teaches. Strip the range and say so, rather than letting the // literal "SYM:5-10" miss and get reported as a TYPO with a did-you-mean — the name was fine. // §P10 X7: `verb` is passed so a malformed range on --outline no longer emits a note blaming // --expand. §P8 seam 1: the file:name / file:line:name selector resolves here exactly as it does // on --expand and --callers (same resolveAllByNameQualified), so an --outline of ONE overload is // finally expressible. const ExpandToken ot = parseExpandToken( rawNm, "--outline" ); const std::string nm = ot.range.hasRange ? ot.selector : rawNm; if( ot.range.hasRange ) { rw::emitTo( stderr, "ripwire: --outline={}: --outline has no line-range form — outlining the whole symbol " "(use --expand={}:{}-{} for a body slice)\n", rawNm.c_str(), ot.selector.c_str(), ot.range.startLine, ot.range.endLine ); } std::size_t nmUnprovenDefs = 0; const std::vector matches = resolveAllByNameQualified( ing, nm, &nmUnprovenDefs ); ctxUnprovenDefs += nmUnprovenDefs; // H1: onto the root --expand shares if( matches.empty() ) { // §M7 (W3FIX): same grammar, same shared refusal as --expand above. outlineMissed = true; rw::emitTo( stderr, "ripwire: --outline={} matched no symbol{}\n", nm.c_str(), rw::selectorFaultClause( ing, nm, "--outline=" ).c_str() ); } for( NodeId id : matches ) { outlineNodes.push_back( id ); } } if( outlineMissed ) { return 1; } } // H1: the residue's attribute and its clause, for the root --expand and --outline share. Built ONCE, because // three things read their bytes and must agree: the payload charge below (so est_tokens covers them in every serving // mode), the M6 bundle price, and the --max-tokens ceiling verdict. All three are empty at zero, so an answer that // dropped nothing prices, chooses and emits byte-identically. const std::string ctxUnprovenAttr = rw::unprovenDefsAttrXml( ctxUnprovenDefs ); const std::string ctxUnprovenLegend = rw::unprovenDefsVerbComment( rw::UnprovenDefsVerb::Expand, ctxUnprovenDefs > 0, ""; out += ""; out += ""; for( const rw::taskroute::RouteChoice& choice : route.choices ) { out += ""; } out += "\n"; std::fputs( out.c_str(), stdout ); return 0; } // #350: an edit over an index the memory guard cut refuses first (runEditVerb / editplan::run), and that refusal line // already names the guard — so it answers for the stop and exits 5, rather than 1 plus the backstop's second line int editRefusalExit() noexcept { if( !rw::memguard::hasUnansweredStop() ) { return 1; } rw::memguard::answerStops(); return 5; } std::optional runCliEditPlan( const rw::Config& cfg ) { const bool hasMode = cfg.editPlanDryRun || cfg.editPlanApply; if( cfg.editPlan.empty() && !hasMode ) { return std::nullopt; } if( cfg.editPlan.empty() && !cfg.editCheckSym.empty() ) { return std::nullopt; // card A1: --dry-run beside --edit-check is the PREVIEW's mode flag, not this verb's } if( cfg.editPlan.empty() ) { rw::emitRaw( stderr, "ripwire: --dry-run/--apply requires --edit-plan=FILE\n" ); return 1; } if( cfg.editPlanDryRun == cfg.editPlanApply ) { rw::emitRaw( stderr, "ripwire: --edit-plan requires exactly one of --dry-run or --apply\n" ); return 1; } if( cfg.roots.size() != 1 ) { rw::emitRaw( stderr, "ripwire: --edit-plan is single-root only; pass one \n" ); return 1; } const rw::editplan::Outcome outcome = rw::editplan::run( std::string( cfg.rootPath ), std::string( cfg.editPlan ), cfg.editPlanApply, cfg.maxFileBytes ); if( !outcome.ok ) { rw::emitTo( stderr, "ripwire edit-plan: {}\n", outcome.message.c_str() ); return editRefusalExit(); } std::puts( outcome.receipt.c_str() ); return 0; } // the spelling of the ONE CLI edit verb this invocation selected — for the refusals that must name it const char* cliEditVerbSpelling( const rw::Config& cfg ) noexcept { if( !cfg.replaceSymbolBody.empty() ) { return "--replace-symbol-body"; } return !cfg.insertBeforeSymbol.empty() ? "--insert-before-symbol" : "--insert-after-symbol"; } std::optional runCliEdit( const rw::Config& cfg ) { const int editCount = int( !cfg.replaceSymbolBody.empty() ) + int( !cfg.insertBeforeSymbol.empty() ) + int( !cfg.insertAfterSymbol.empty() ); const bool hasModifier = !cfg.editPayload.empty() || !cfg.editTargetFile.empty(); if( editCount == 0 && !hasModifier ) { return std::nullopt; } if( editCount == 0 && !cfg.editCheckSym.empty() ) { return std::nullopt; // card A1: --edit-payload beside --edit-check is the PREVIEW's payload, not a write } if( editCount == 0 ) { rw::emitRaw( stderr, "ripwire: --edit-payload/--edit-target-file requires one of --replace-symbol-body, " "--insert-before-symbol or --insert-after-symbol\n" ); return 1; } if( editCount != 1 ) { rw::emitRaw( stderr, "ripwire: pass exactly one CLI edit verb per invocation\n" ); return 1; } if( cfg.roots.size() != 1 ) { rw::emitRaw( stderr, "ripwire: CLI edit verbs are single-root only; pass one \n" ); return 1; } if( cfg.editPayload.empty() ) { // capture-audit 2026-09-04 (L1, the modifier-pairing dialect): name the verb the caller TYPED, not "a CLI // edit" — every sibling pairing refusal names both flags and shows the composed call. const char* const editVerb = cliEditVerbSpelling( cfg ); rw::emitTo( stderr, "ripwire: {}=SYM is a CLI edit and needs --edit-payload=FILE (or --edit-payload=- for stdin) — pass both " "(e.g. ripwire {}=SYM --edit-payload=new_body.cpp); an absent payload never means delete\n", editVerb, editVerb ); return 1; } // card A1: the four payload refusals — unreadable, EMPTY (never a delete), oversize against // --max-file-size, and NUL-bearing (rw::looksBinary itself, so the claim is exactly the condition that // would drop the file from the index) — now live ONCE in editpreview.h and are shared with the pre-apply // preview. Two copies of this ladder is two places for an agent to meet two vocabularies for one refusal. std::string payload, payloadErr; if( !rw::editpreview::readPayload( cfg.editPayload, cfg.maxFileBytes, payload, payloadErr ) ) { rw::emitTo( stderr, "ripwire: {}\n", payloadErr.c_str() ); return 1; } const rw::mcpedit::Op op = !cfg.replaceSymbolBody.empty() ? rw::mcpedit::Op::ReplaceBody : !cfg.insertBeforeSymbol.empty() ? rw::mcpedit::Op::InsertBefore : rw::mcpedit::Op::InsertAfter; const std::string_view sym = !cfg.replaceSymbolBody.empty() ? cfg.replaceSymbolBody : !cfg.insertBeforeSymbol.empty() ? cfg.insertBeforeSymbol : cfg.insertAfterSymbol; const rw::mcpedit::Outcome outcome = rw::runEditVerb( std::string( cfg.rootPath ), op, std::string( sym ), std::string( cfg.editTargetFile ), payload, !cfg.noPostCheck ); if( !outcome.ok ) { // M9 / lens 6 F8: name the VERB, not the family. All three CLI edit verbs printed "ripwire edit:", // so a refusal in a log could not be tied back to the command that produced it — the one thing // every other refusal in this binary does. const char* const editFlag = !cfg.replaceSymbolBody.empty() ? "--replace-symbol-body" : !cfg.insertBeforeSymbol.empty() ? "--insert-before-symbol" : "--insert-after-symbol"; rw::emitTo( stderr, "ripwire: {}: {}\n", editFlag, outcome.message.c_str() ); return editRefusalExit(); } std::fputs( outcome.resultJson.c_str(), stdout ); std::fputc( '\n', stdout ); // A4: print the RESOLVED file:symbol, never the caller's own argument. `sym` may be a sym# handle, which // --edit-check does not accept — so the printed command used to fail every time after a handle-addressed // edit — and a bare name narrowed by --edit-target-file would point --edit-check at a different // same-named definition. Both follow-ups are now spelled out concretely enough to paste. // P9 (capture-audit 2026-09-04): the two commands this line names are IN the receipt now, so the line // says which world the caller is in. Both pasteable spellings STAY either way — a re-ask after a // further edit needs the qualified `file:symbol` form, and this is the one place the tool teaches it // (test/rootrelemitcheck.sh ARM7 asserts the printed --edit-check actually runs). // E2 (terminality round A): ONE next, the receipt's own (METHODOLOGY §9 #3). The receipt carries the post-edit // region and blob_sha always, plus edit_check/tests_to_run unless --no-post-check — so this line names what is // in hand and the single call that follows; never a second command. test/receiptpostcheck.sh (12) and // test/edithandlehintcheck.sh assert the printed next is the receipt's and RUNS. rw::emitTo( stderr, "ripwire edit: applied atomically; receipt carries region, blob_sha{}; next: {}\n", cfg.noPostCheck ? " (post-check skipped)" : ", edit_check, tests_to_run", outcome.next.c_str() ); return 0; } } // namespace // ── P1 (capture-audit 2026-09-04, lane L7): the --legend=compact dialect on every XML verb ──────────────── // // --for/--grep/--slice compact their own legend (each emitter branches on cfg.legend); every other XML verb // is compacted HERE, after the fact: the run's stdout is captured into an anonymous tmpfile, the finished // document is rewritten once by compactlegend.h (prose comments out, ONE compact legend + schema= in, every // payload byte untouched), and written to the real stdout. Exit codes pass through unchanged. A run that // produced no XML root (a refusal already happened, or a text verb slipped past validateLegendModifier's // list) is refused here naming the flag — never served as if the posture had applied. // 2026-09-06 stranger audit: a root that exists but cannot be opened (chmod 000, another user's checkout) came // back as an EMPTY map at exit 0 — indistinguishable from "no source here". Probe the directory the way the // crawl will; refuse with the reason instead of serving nothing. A non-directory root is left to the crawl. // C1-b (2026-09-12): --in=DIR names a directory UNDER the root, root-relative — not absolute, no '.' / '..' segment — and it // must exist as a directory. Checked here, before any crawl, because the alternative is a block scoped to nothing that says // so only by being empty: a typo would read as "nothing changed there". Trailing slashes are stripped (sarif::rootPrefixOf, the crawl root's own rule) so // `db/` and `db` are one answer; the syntactic refusal and the existence refusal are two messages because they have two // remedies. static bool inDirIsUnderRoot( std::string_view inDirArg, const std::string& resolvedRoot ) { namespace fs = std::filesystem; const std::string dir = rw::sarif::rootPrefixOf( inDirArg ); bool isRelative = !dir.empty() && dir.front() != '/'; for( std::size_t at = 0; isRelative && at <= dir.size(); ) { const std::size_t slash = dir.find( '/', at ); const std::string_view seg = std::string_view( dir ).substr( at, slash == std::string::npos ? std::string::npos : slash - at ); if( seg.empty() || seg == "." || seg == ".." ) { isRelative = false; } at = slash == std::string::npos ? dir.size() + 1 : slash + 1; } if( !isRelative ) { rw::emitTo( stderr, "ripwire: --in={} must be a root-relative directory (no leading '/', no '.' or '..' segment) — e.g. ripwire --rank-by=churn-decay --in=src\n", std::string_view( inDirArg.data(), inDirArg.size() ) ); return false; } std::error_code ec; const fs::path scoped = fs::path( resolvedRoot ) / fs::path( dir ); if( !fs::is_directory( scoped, ec ) || ec ) { rw::emitTo( stderr, "ripwire: --in={}: {} is not a directory under the root {} — name an existing directory (e.g. ripwire --rank-by=churn-decay --in=src)\n", std::string_view( inDirArg.data(), inDirArg.size() ), std::string_view( dir ), resolvedRoot.c_str() ); return false; } return true; } static bool rootIsReadable( const std::string& resolvedRoot ) { namespace fs = std::filesystem; std::error_code ec; if( !fs::is_directory( fs::path( resolvedRoot ), ec ) || ec ) { return true; } fs::directory_iterator probe( fs::path( resolvedRoot ), ec ); if( !ec ) { return true; } rw::emitTo( stderr, "ripwire: root path cannot be read: {} ({}) — fix its permissions, or point at a directory you can open\n", resolvedRoot.c_str(), ec.message().c_str() ); return false; } // 2026-09-06 stranger audit: --cache= read as "corrupt", wrote nothing, and served a byte-identical // map at exit 0 — the fixed --cache= bug's twin. The flag names a FILE; say so, with the form. static bool cachePathIsDirectory( const std::string& cachePath ) { namespace fs = std::filesystem; std::error_code ec; if( !fs::is_directory( fs::path( cachePath ), ec ) || ec ) { return false; } rw::emitTo( stderr, "ripwire: --cache={}: that is a directory; --cache names the blob FILE to read and write, e.g. --cache={}/ripwire.bin\n", cachePath.c_str(), cachePath.c_str() ); return true; } // OWNER DECISION 2026-09-12 — the ways a --scip path the caller NAMED cannot be read as an index AT ALL, decided in one // place from ONE open: it does not open; it is a directory; it is any other kind of file that is not a regular file (a // FIFO, a device); it is an empty regular file. Returns the reason for dispatchMain's refusal sentence, or an empty view // when the path goes on to loadScipOverlay. A directory, a device and an empty file used to reach loadScipOverlay's // "cannot read index" and serve the name-based map at exit 0. A FIFO with no writer HUNG instead, inside a blocking open // that waits for a writer for ever, so the probe opens O_NONBLOCK: a read-only open of a FIFO then returns at once. fstat // answers on that descriptor — no second path resolution, so a rename between the checks cannot make them describe two // different files. Still a degrade, decided in scip.h: bytes that do not DECODE (corrupt/truncated), an index over its // size bound, a short read. static std::string_view scipIndexUnreadableReason( const std::string& scipPath ) { const int probeFd = rw::os::open( scipPath.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC ); if( probeFd < 0 ) { return "cannot open the index"; } rw::os::stat_t probeStat; const bool isStatted = rw::os::fstat( probeFd, &probeStat ) == 0; rw::os::close( probeFd ); if( !isStatted ) { DISCLOSE( Diagnostics::answerUnchanged, "loadScipOverlay reads the index either way: a good one loads unchanged, a bad one degrades with its own shipped notice", "--scip: fstat on the opened index failed — file kind and size undecided, loadScipOverlay's read decides" ); return {}; } if( S_ISDIR( probeStat.st_mode ) ) { return "is a directory, not an index file"; } if( !S_ISREG( probeStat.st_mode ) ) { return "is not a regular file (a FIFO or a device), not an index file"; } if( probeStat.st_size == 0 ) { return "is empty (0 bytes), not an index"; } return {}; } // --for / --metrics / --exemplar mine 18 months of history after the graph build (the amp=/churn= block in // dispatchMain). That walk is `git` subprocesses plus a blob read and touches nothing the ingest produces, so it // starts BEFORE the ingest, one thread per root, and runs beside it; the amp=/churn= block only resolves each walk's // paths against `ing`. Empty when no such verb runs — and for --help-task, the one verb that answers between the // two, because a run that stops before the block still waits for its walks (a std::async future joins). static std::vector> startHistoryWalks( const rw::Config& cfg, bool multiRoot, const std::vector& ws, const std::string& root ) { std::vector> walks; if( ( !cfg.metrics && cfg.forTask.empty() && cfg.exemplar.empty() ) || !cfg.helpTask.empty() ) { return walks; } for( std::size_t r = 0; r < ( multiRoot ? ws.size() : 1u ); ++r ) { walks.push_back( std::async( std::launch::async, [ rootArg = multiRoot ? ws[r].arg : root ]() { return rw::quality::gitHistoryWalk( rootArg ); } ) ); } return walks; } static int dispatchMain( const rw::Config& cfg, char** argv ); // The key for a SHARED root (`r` = the map family, `ctx` = the bundle family) is read off the ANSWER, never off a flag order. // The ROOT picks the family: one flag order across both families cannot be right, because verb precedence interleaves them, and // a key from the other family took that root's FIRST spec, so --pack-task --metrics compacted as pack-signatures // (test/compactlegendcheck.sh (D36)). Nor can a flag order WITHIN a family: the bundle hint read --expand before --pack-task and // the map hint --around before --query, so --pack-task --expand compacted as expand and --query --around as around, over // documents byte-identical to --pack-task's and --query's alone ((D38), CodeRabbit on #203). So within the family a verb with a // mark of its own names its key only when the document carries that mark, and a flag whose verb lost dispatch cannot lend its // schema to the verb that answered. The row order is left as a TIE-BREAK between marks that share one answer: --metrics renders // into --map-diff's and --around's answers and keeps its old place between them, and a pack-task root carries from-trace's // task= beside its own budget_tokens=. A rule with no mark (--query, and the bundle modifiers only the default map renders, which // answers last) is read only after every marked verb was ruled out, which the static_asserts below hold. enum class CompactKeyMark : std::uint8_t { None, // no mark of its own: the flag alone names the key RootAttr, // the root's own open tag carries needle=" FirstChild, // the root's first child element, past the legend comments before it, is MapHeaderField, // the map header comment carries the unquoted field needle= CommentOpener, // a comment outside CDATA opens with needle (the verb's own legend block) }; struct CompactKeyRule { std::string_view key; bool ( *isAsked )( const rw::Config& ) noexcept; CompactKeyMark mark; std::string_view needle; }; // Each mark read against its one emitter: changed= is serialize.h's changedCount, which only runDefaultMap's map-diff arm passes // (a clean tree still prints changed=0, and the query arm ahead of it never does); the metrics block is serialize.h's // `if( metrics )` legend; of= is the SeedDisclosure only --around's annotation fills. static constexpr CompactKeyRule kMapKeyRules[] = { { "map-diff", []( const rw::Config& c ) noexcept { return c.mapDiff; }, CompactKeyMark::MapHeaderField, "changed" }, { "metrics", []( const rw::Config& c ) noexcept { return c.metrics; }, CompactKeyMark::CommentOpener, "